Multi-tenant SaaS for automated, AI-personalised cold email outreach with intelligent follow-ups, Gmail send/receive integration, and document-grounded personalisation.
Live: lvoutreach.duckdns.org
Users sign in with Google, connect their Gmail inbox, upload product documents to a shared library, and launch personalised cold-email campaigns. The backend generates each email using an LLM grounded in the product brief and per-lead context, sends through the user's own Gmail (via XOAUTH2), threads follow-ups into the original conversation, and detects replies via IMAP polling so dead leads stop receiving mail.
It's built for small teams running their own cold outreach — not a delivery-as-a-service layer. Every email goes out through the user's own Gmail account, so deliverability and sender reputation stay in their hands.
- Create, edit (draft/paused only), duplicate, and delete campaigns
- Scheduled start: set a datetime, the scheduler auto-activates when time arrives
- Per-campaign rate limiting (50 emails / hour by default) and daily Gmail send caps (450 / day)
- Analytics per campaign: reply rate, average emails before reply, lead-quality score, status breakdowns
- Manual add, bulk CSV import with client-side validation + preview
- Bulk delete with checkboxes and a floating action bar
- Edit all lead fields; edit is hidden on terminal statuses (replied / completed / failed)
- Email activity timeline per lead with smooth accordion expansion
- LLM-powered personalisation using DeepSeek v3.2 (or any OpenAI-compatible
provider via
moonlight-aiabstraction) - Post-generation critic pass: a second LLM call scores the draft against a banned-pattern checklist (inference-from-fact openers, filler phrases, em-dashes, capability menu-dumps) and regenerates with feedback if violations are found
- Email preview endpoint: generate a draft for any lead without sending
- Threading: follow-ups go out with
Re: {original subject}so Gmail bundles them into the same conversation
- Upload PDF, DOCX, PPTX, TXT, or MD
- LlamaParse (agentic tier) extracts to markdown
- Gemini / DeepSeek / your-chosen-LLM distils to a 300-500 word product brief
- Brief is injected into every email generation for campaigns that attach the document
- Account-wide library: upload once, attach to multiple campaigns (cap: 2 documents per campaign to bound LLM input)
- Gmail IMAP XOAUTH2 polling every 60 seconds
- Targeted pre-send check per user immediately before each send cycle to avoid race conditions
- Primary match by
In-Reply-To/Referencesheaders; sender-email fallback with a date-guard to reject stale cross-campaign replies - Reply date stored from the message's actual
Dateheader, not poll timestamp
- Google OAuth2 with Gmail scope (
https://mail.google.com/) - OAuth tokens encrypted at rest (Fernet /
TOKEN_ENCRYPTION_KEY) - JWT sessions (HS256, 7-day expiry)
- Every query filtered by
user_id; cross-tenant access blocked at the query level and verified by 20+ ownership tests
| Layer | Technology |
|---|---|
| Backend | FastAPI (Python), APScheduler |
| Database | PostgreSQL via Supabase (pooler, port 6543) |
| LLM | DeepSeek v3.2 via moonlight-ai (swappable) |
| Document extract | LlamaParse (agentic tier) |
| Email send | Gmail SMTP XOAUTH2 |
| Email receive | Gmail IMAP XOAUTH2 (60s poll) |
| Auth | Google OAuth2 + JWT |
| Frontend | React 19, Vite, TypeScript, Tailwind |
| UI components | shadcn/ui (Mira preset, Inter Variable) |
| Deployment | AWS EC2 (eu-north-1), Caddy, systemd |
| CI/CD | GitHub Actions (push to main → SSH deploy) |
- Python 3.12
- Node.js 20+
- PostgreSQL 15+ (local or Supabase project)
- Google Cloud project with OAuth consent screen configured
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# Fill in DATABASE_URI, Google OAuth creds, LLM provider key, etc.
uvicorn app:app --reloadScheduler and DB migrations run automatically on startup via the FastAPI lifespan hook.
cd frontend
npm install
npm run devThe Vite dev server proxies /api/* to localhost:8000.
cd backend
TEST_DATABASE_URI=postgresql:///campaign_test \
LLAMA_CLOUD_API_KEY=llx-test \
pytest tests/ -vCurrent suite: 132 tests, ~20s full run. No external service calls —
Google OAuth, Gmail, LlamaParse, and the LLM are all mocked. See
backend/conftest.py for the test harness.
Copy backend/.env.example to backend/.env and fill in:
# Database (Supabase pooler — use port 6543, not 5432, on EC2)
DATABASE_URI=postgresql://postgres.PROJREF:[email protected]:6543/postgres
# LLM (provider aliases: deepseek, groq, openrouter, google, openai)
LLM_SOURCE=deepseek
LLM_API_KEY=sk-your-deepseek-key
LLM_MODEL=deepseek-chat
# LlamaParse (for document upload extraction)
LLAMA_CLOUD_API_KEY=llx-your-llamaparse-key
# Email-generation quality gate (default on)
CRITIQUE_ENABLED=true
# Google OAuth
GOOGLE_CLIENT_ID=xxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...
GOOGLE_REDIRECT_URI=https://yourhost/auth/callback
# Security
JWT_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
TOKEN_ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
# Gmail limits
GMAIL_DAILY_SEND_LIMIT=450
GMAIL_INTER_SEND_DELAY_MS=200
REPLY_CHECK_INTERVAL_SECONDS=60
# CORS
CORS_ORIGINS=https://yourhostThe backend is a single FastAPI process running on EC2 t3.micro. The scheduler (APScheduler, in-process) runs three jobs every 60 seconds:
process_leads_job— picks eligible leads, generates emails concurrently, batch-records sendscheck_replies_job— polls each active user's Gmail inbox over IMAP, matches new mail to leads, marks as repliedcheck_scheduled_campaigns— auto-activates any draft campaigns whosescheduled_start_atis in the past
All database access goes through a connection pool; Supabase pooler at port 6543 is required on IPv4-only hosts like t3.micro.
All routes are mounted under /api behind Caddy's reverse proxy.
| Method | Endpoint | Notes |
|---|---|---|
| GET | /auth/google/login |
Returns Google auth URL |
| POST | /auth/google/callback |
Exchanges code for JWT |
| GET | /auth/me |
Current user |
| POST | /auth/logout |
Clears server-side session |
| Method | Endpoint | Notes |
|---|---|---|
| GET | /campaigns |
List (user-scoped) |
| POST | /campaigns |
Create |
| GET | /campaigns/{id} |
Includes documents[] array |
| PATCH | /campaigns/{id} |
Edit (draft/paused only) |
| DELETE | /campaigns/{id} |
Cascade delete |
| PATCH | /campaigns/{id}/status |
?action=start / stop |
| POST | /campaigns/{id}/duplicate |
Copies settings + all leads |
| POST | /campaigns/{id}/preview |
Generate email for ?lead_id |
| GET | /campaigns/{id}/stats |
Analytics (single-query CTE) |
| Method | Endpoint | Notes |
|---|---|---|
| GET | /campaigns/{id}/leads |
List |
| POST | /campaigns/{id}/leads |
Add single |
| POST | /campaigns/{id}/leads/bulk |
Bulk insert (execute_values) |
| POST | /campaigns/{id}/leads/bulk-delete |
Batch delete by id list |
| DELETE | /campaigns/{id}/leads/{lead_id} |
Single delete |
| GET | /leads/{id} |
Detail |
| PATCH | /leads/{id} |
Edit |
| GET | /leads/{id}/activity |
Email timeline |
| Method | Endpoint | Notes |
|---|---|---|
| POST | /documents |
Upload + parse + summarise |
| GET | /documents |
Library list (user-scoped) |
| GET | /documents/{id} |
Single document + brief |
| DELETE | /documents/{id} |
Cascades to campaign_documents |
| PUT | /campaigns/{id}/documents |
Replace attachments (cap 2) |
| Method | Endpoint | Notes |
|---|---|---|
| GET | /health |
DB connection check |
├── backend/
│ ├── app.py # FastAPI entry + lifespan (scheduler, DB)
│ ├── requirements.txt
│ ├── conftest.py # Pytest fixtures (per-request-header auth)
│ ├── tests/ # 132 tests, local Postgres
│ └── src/
│ ├── api/
│ │ ├── auth/ # OAuth + JWT + token encryption
│ │ ├── campaigns.py
│ │ ├── leads.py
│ │ ├── documents.py # Library + campaign attachments
│ │ └── models.py # Pydantic schemas
│ ├── db/
│ │ ├── base.py # Schema + migrations (9 applied)
│ │ └── engine.py # psycopg2 pool
│ ├── documents/
│ │ ├── parser.py # LlamaParse wrapper
│ │ └── summarizer.py # Brief generation via LLM
│ ├── mail/
│ │ ├── agent.py # Email generation (+ critic loop)
│ │ ├── critic.py # Second-pass quality gate
│ │ ├── gmail.py # SMTP XOAUTH2 send
│ │ ├── imap.py # IMAP XOAUTH2 reply polling
│ │ └── replies.py # Reply extraction + persistence
│ └── scheduler/
│ └── job.py # Three APScheduler jobs
├── frontend/
│ └── src/
│ ├── pages/ # Campaigns, CampaignDetail, CampaignCreate,
│ │ # LeadDetail, Documents, DocumentDetail
│ ├── components/
│ │ ├── ui/ # shadcn/ui (Mira preset)
│ │ ├── AppLayout.tsx # Persistent sidebar
│ │ ├── AttachedDocumentsCard.tsx
│ │ ├── ImportCSVModal.tsx # papaparse-backed validation
│ │ └── PreviewEmailModal.tsx
│ ├── contexts/ # Auth, Breadcrumb
│ └── lib/
│ ├── api.ts # JWT-authenticated fetch wrapper
│ ├── status.ts # Shared status → Badge config
│ └── errors.ts # parseApiError
├── deploy/
│ ├── Caddyfile # Reverse proxy + static frontend
│ ├── outreach-api.service # systemd unit
│ └── setup.sh # EC2 one-time setup
└── .github/workflows/
└── deploy.yml # CI/CD: push to main → SSH deploy
Every email goes through a two-call generate-and-critique loop:
- Generate (
_generate_draft): the ROLE prompt enforces tone, banned patterns (inference-from-fact openers, filler phrases, em-dashes, menu-dumps of capabilities), proof-point selectivity, and format. The generator reads campaign goal, recipient info, attached product-brief concatenation, and previous emails in sequence. - Critique (
critic.py): a second LLM call scans the draft against a strict checklist and returns{passed, violations[]}. - Regenerate (optional): if the critic fails, the loop retries once with the violation list injected as additional guidance. Returns whatever the second attempt produces.
Cost: 2 LLM calls per clean email, 3 on failure. Latency: +3-5s per
email. Disable via CRITIQUE_ENABLED=false if the cost/latency tradeoff
changes.
The IMAP poller needs to match incoming mail to the right lead across campaigns that may reuse the same email address over time. Three match strategies in order:
In-Reply-Toheader matches one of our sentmessage_idvaluesReferenceschain — any ref matches a sent message_id- Sender-email fallback — only accepted if the reply's
Dateheader is after the earliest email we sent to that lead. This date-guard rejects stale replies from previous campaigns where the same address was a lead.
Stored sent_at for received messages uses the email's actual Date
header (when parsable) rather than the polling timestamp, so the activity
timeline reflects when the lead actually sent, not when we detected.
The live system runs on a single AWS EC2 t3.micro in eu-north-1:
/home/ubuntu/appholds the repo checkoutoutreach-api.service(systemd) runs uvicorn on127.0.0.1:8000- Caddy reverse-proxies
/api/*to the backend and serves the built frontend fromfrontend/dist/on everything else - SSL via Caddy's auto Let's Encrypt integration
- Database is Supabase (eu-north-1 cluster), same region as EC2 for ~5 ms DB round-trip latency
- Pushing to
maintriggers.github/workflows/deploy.ymlwhich SSHes into EC2, pulls, rebuilds the frontend, reinstalls Python deps, and restarts the service
See deploy/setup.sh for the one-time server provisioning script.
- Rate limits enforced in two places (DB query-time filter + pre-send targeted IMAP check) to handle overlapping scheduler runs safely on multi-worker deployments
- Deterministic idempotency on (lead_id, sequence_number) before each send — crashes between SMTP delivery and DB write don't cause duplicates
- Bulk lead import uses
execute_values— 500-lead CSV imports finish in one DB round-trip instead of N - Campaign stats use a single CTE query instead of the 8 sequential queries of the original design — critical when DB and app were in different regions
- Pydantic structured output via
moonlight-aifor the LLM calls — the response is parsed into typed models, not free-form text - OAuth tokens encrypted at rest with Fernet — a stolen DB snapshot does not give an attacker access to any user's Gmail
- Client-side CSV parsing with papaparse handles quoted newlines and UTF-8 correctly — common failure modes on exports from Excel and LinkedIn Sales Navigator
- Document uploads don't hold request connections open during parse —
the frontend uses
toast.promise()on a fire-and-forget fetch so users can navigate away during the ~30-60s LlamaParse + summarisation round trip
Private / internal project for Lakshmi Vacuum Technologies. Not currently licensed for public use.

