Prima Get an API key

Module kit / Prospecting

Outreach Confirm Gate and Send

prospecting/outreach-confirm-gate-and-send

Kind backend
Domain prospecting
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, apscheduler-3.11, google-api-python-client-2.154, google-auth-2.41, beautifulsoup4-4.12, lxml-5.3, pydantic-2.12
Depends on _shared/fastapi-project-layout, _shared/workspace-scoping, _shared/auth-dependency-pattern, _shared/scheduler-and-background-work, _shared/alembic-migrations
Frontends prospecting/ui/outreach-campaign-workspace
Reference implementation
  • alchemy_labs/backend/services/outreach/outreach_service.py
  • alchemy_labs/backend/services/outreach/gmail_service.py
  • alchemy_labs/backend/services/outreach/tracking_service.py
  • alchemy_labs/backend/models/outreach_model.py
  • alchemy_labs/backend/api/routers/outreach_router.py
  • alchemy_labs/backend/api/routers/tracking_router.py
  • alchemy_labs/backend/app_fastapi.py
  • alchemy_labs/backend/services/scheduled_jobs.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

Once a lead's cold-outreach email has been scraped, AI-drafted and put in front of a human, this recipe is what turns that draft into a sent, tracked message: a one-way draft → approved gate a person clicks, a scheduler that later reads approved rows under a per-campaign daily cap and sends them through Gmail with open/click tracking woven into the HTML, and an hourly sweep that polls the Gmail thread for a reply. The invariant that makes it trustworthy is that approve_email refuses to fire from any status other than draft (outreach_service.py 271-272), so nothing reaches the send queue without a human having looked at the exact drafted copy, and the daily cap in process_queue is counted fresh per campaign on every scheduler tick (sent_at >= today_start), not decremented from a stored budget, so a crash mid-run cannot leave a campaign owing itself sends.

When to use

Read it before touching approve_email, approve_all_drafts, process_queue, send_email, inject_tracking, record_open, record_click, or detect_replies/check_thread_replies; before changing what counts toward daily_send_limit; before changing the tracking-pixel or link-redirect endpoints; when someone asks "why didn't this campaign send today", "why did an approved email never go out", "did this lead's email actually get opened", or "why doesn't the reply show up". Trigger phrases: "approve draft", "approve-all", "daily send limit", "outreach queue", "tracking pixel", "link redirect /r/", "reply detection", "Gmail thread".

Not this recipe: campaign CRUD (create_campaign/get_campaign/list_campaigns/update_campaign, outreach_service.py 35-71) and the draft/active/paused/completed CampaignStatus transitions themselves, a neighbouring recipe on the same service; lead import (add_leads_to_campaign, import_csv, outreach_router.py POST /campaigns/{id}/leads and /import); the scrape + AI-compose pipeline that produces the draft row this recipe gates (scrape_and_generate, scraper_service.scrape_lead, composition_service.compose_batch, outreach_service.py 189-238), read for context only, not this recipe's subject; the Google OAuth grant and token storage on CalendarIntegration itself (calendar_integration_service, calendar_integration_model.py), this recipe only calls calendar_service.decrypt_token/encrypt_token and reads access_token/refresh_token/scopes/provider_account_id, it does not own the OAuth flow; the Scribe pixel/beacon system the outreach pixel route sits beside in tracking_router.py (unrelated first-party analytics, same file, different section).

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), APScheduler==3.11.0 for both scheduled jobs, google-api-python-client==2.154.0 and google-auth==2.41.1 for the Gmail API client and OAuth Credentials/Request refresh, beautifulsoup4==4.12.3 with the lxml==5.3.0 parser for tracking injection, pydantic==2.12.0 for the request/response schemas, psycopg2-binary==2.9.10, alembic==1.16.5 (backend/requirements.txt). Foundations assumed: _shared/fastapi-project-layout for the app and mount_router (the router is mounted at prefix="/api/crm" combined with the router's own prefix="/outreach", app_fastapi.py 878, so every route under Application logic is /api/crm/outreach/...); _shared/workspace-scoping for WorkspaceID/_get_workspace_id, every campaign- and email-scoped call in this recipe re-verifies the parent campaign's workspace_id before acting (see Security & tenancy); _shared/auth-dependency-pattern for AdminUser (get_current_admin_user, api/dependencies.py 364-374), every route in this recipe requires is_admin; _shared/scheduler-and-background-work for the two APScheduler jobs under Deploy & ops; _shared/alembic-migrations for the two chained revisions under Deploy & ops.

Environment, in core/config.py: API_BASE_URL (line 42, default http://localhost:8000) is the host inject_tracking builds the pixel and redirect URLs from; GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET back the Credentials object _get_gmail_service constructs (gmail_service.py 165-166). No dedicated feature flag gates any of this: no OUTREACH_ENABLED-shaped setting exists in core/config.py or scheduled_jobs.py (grepped, none found; see Gotchas for what that implies about "prod"). Fail behaviour is per-call, not seam-wide: approve_email raises HTTPException(400) on a status other than draft (outreach_service.py 271-272); send_email never raises, it writes status=failed and error_message and returns False on every failure path, including "no Gmail integration configured" and "lead not found" (297-349); process_queue and detect_replies both run inside try/except-free loops at the top level but each per-email send_email/check_thread_replies call is itself non-raising, so one bad row does not stop the sweep; the scheduler wrapper functions (run_outreach_queue, run_reply_detection, scheduled_jobs.py 1839-1875) each carry their own outer try/except that logs and returns, matching the pattern in _shared/scheduler-and-background-work.

Data model

Three tables, no dedicated migration for this recipe's own columns beyond the two below (all columns pre-exist from the wider outreach feature; this recipe reads and writes a subset of them). Full column list in models/outreach_model.py 44-146; the columns this recipe actually touches:

outreach_campaigns (
  id                 uuid PRIMARY KEY,
  workspace_id       uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,   -- added by outreach_ws_001, backfilled, then NOT NULL
  status             outreach_campaign_status,       -- draft | active | paused | completed; process_queue only reads 'active'
  daily_send_limit   integer DEFAULT 50,               -- the per-campaign cap process_queue enforces
  gmail_integration_id uuid REFERENCES calendar_integrations(id),
  sender_name        varchar DEFAULT 'Miriam',
  total_sent, total_opened, total_clicked, total_replied, total_bounced  integer DEFAULT 0  -- denormalized; total_bounced is declared but never incremented by any code path in this recipe (Gotchas)
);

outreach_emails (
  id                 uuid PRIMARY KEY,
  campaign_id        uuid NOT NULL REFERENCES outreach_campaigns(id),
  lead_id            uuid NOT NULL REFERENCES leads(id),
  subject, body_html, body_text  text,
  status             outreach_email_status,  -- pending_scrape|scraping|scrape_failed|pending_compose|composing|draft|approved|queued|sending|sent|opened|clicked|replied|bounced|failed
                                              -- 'queued' and 'bounced' are declared in the enum but this recipe never assigns either (Gotchas)
  tracking_id        uuid UNIQUE NOT NULL,    -- default uuid4; the pixel's own identifier, distinct from any OutreachLink id
  gmail_message_id, gmail_thread_id  varchar,
  open_count, click_count  integer DEFAULT 0,
  approved_at, sent_at, opened_at, clicked_at, replied_at, failed_at  timestamptz,
  error_message      text
);

outreach_links (
  id                 uuid PRIMARY KEY,
  email_id           uuid NOT NULL REFERENCES outreach_emails(id),
  tracking_id        varchar(32) UNIQUE NOT NULL,   -- secrets.token_urlsafe(16)[:16], one row minted per <a href> at send time
  destination_url    text NOT NULL,
  click_count         integer DEFAULT 0,
  first_clicked_at, last_clicked_at  timestamptz
);

What is NOT stored anywhere in this recipe: the Gmail OAuth token itself (gmail_service._get_gmail_service reads integration.access_token/refresh_token through calendar_service.decrypt_token, out of scope, owned by CalendarIntegration); the body of a reply (check_thread_replies reads only the From header via metadataHeaders=["From"], gmail_service.py 113-118, it never fetches message content); the destination URL's query string is stored verbatim in destination_url as typed into the draft, not re-validated or re-encoded on the way out.

API surface

RouteAuthTenancyStatus codesNotes
POST /api/crm/outreach/emails/{email_id}/approve (outreach_router.py 149-153)AdminUseremail_id looked up with no workspace filter, then its parent campaign is re-fetched with workspace_id (outreach_service.py 265-270), a cross-workspace email_id 404s at that second lookup200 {"success": true, "status": "approved"}; 404 email not found or campaign not in this workspace; 400 "Cannot approve email with status {status}" when not draftThe gate. Sets status=approved, approved_at=now().
POST /api/crm/outreach/campaigns/{campaign_id}/approve-all (156-160)AdminUsercampaign re-fetched scoped to workspace_id first (approve_all_drafts, outreach_service.py 280-281)200 {"success": true, "approved_count": n}Bulk gate: one UPDATE ... WHERE status='draft' (283-291), not a per-row loop, so it does not raise per-row on a non-draft row, it simply does not match it.
GET /api/crm/outreach/stats (165-168)AdminUserfilters campaigns by workspace_id200 StatsResponseReads total_sent/total_opened/total_clicked/total_replied across the workspace's campaigns; out of this recipe's write path but the only read surface for what it produces.
GET /api/scribe/pixel/{tracking_id} (tracking_router.py 1454-1487, mounted surface_exempt("public", ...))none, public by designnone; tracking_id is the sole keyalways 200, a 1x1 GIF, even on an unknown or malformed idRecords an open. tracking_id is validated isalnum() and <= 64 chars before any query is run (1467); a failing record_open is caught and logged, never surfaces as a non-200 (1476-1479).
GET /r/{tracking_id} (app_fastapi.py 1086-1110, surface_exempt("public", "outreach click redirect, no session"))none, public by designnone; the link's own tracking_id is the key302 to the stored destination_url, or 302 to https://alchemy.zuerich on an unknown id or any exceptionRecords a click, then redirects. The fallback destination is a hardcoded literal in this file, not a config value (1103, 1105).

Two Python entrypoints the scheduler calls directly, not routes, but the actual send/detect surface a rebuild reimplements:

FunctionOwns its own transactionNever raisesReturns
process_queue(db) (outreach_service.py 353-395)per-email, via send_emailyes at the top level (no route depends on it; the scheduler wrapper catches){"sent": int, "failed": int, "skipped": int}
detect_replies(db) (397-435)one db.commit() at the end, after the loopyes at the top level{"replies_found": int, "checked": int}

Application logic

The gate, approve_email (outreach_service.py 265-277): look up the email with no workspace filter; look up its campaign scoped to workspace_id, 404 if the campaign is not in this workspace (implicitly re-scoping the email); if email.status != OutreachEmailStatus.draft, raise 400 naming the current status; otherwise set status=approved, approved_at=now(UTC), commit. approve_all_drafts (279-293) does the equivalent as a single bulk UPDATE ... WHERE campaign_id=... AND status='draft', returns the row count; a row not in draft is silently skipped by the WHERE, it is not an error path the way the single-email route is.

The queue processor, process_queue (353-395), run by the scheduler, in order: 1) load every campaign with status == CampaignStatus.active (360-362); 2) per campaign, compute today_start as UTC midnight (366) and count OutreachEmail rows with sent_at >= today_start for this campaign (367-372); 3) remaining = daily_send_limit - sent_today (374), and if remaining <= 0 the campaign is skipped entirely for this tick (375-377), no partial send; 4) load up to remaining rows with status == approved (.limit(remaining), 380-385); 5) call send_email on each in the order returned (no explicit ORDER BY, so insertion/PK order under Postgres, not FIFO by approved_at); 6) tally sent/failed from each call's boolean return.

Sending one email, send_email (297-349): 1) load the campaign; no gmail_integration_id set ⇒ status=failed, error_message="No Gmail integration configured", return False (303-307); 2) load the lead; missing ⇒ status=failed, error_message="Lead not found", return False (309-314); 3) call inject_tracking, falling back to the untouched body_html if it returns falsy (317-319); 4) set status=sending and db.flush() before calling Gmail (321-322, so a crash mid-send leaves the row visibly sending, not silently approved); 5) call gmail_service.send_email(...); 6) on result["success"]: status=sent, sent_at=now, gmail_message_id/gmail_thread_id copied from the result, campaign.total_sent += 1, commit, return True; on failure: status=failed, failed_at=now, error_message=result.get("error", "Unknown error"), commit, return False (334-349). There is no retry counter and no backoff schedule anywhere in this path: a failed row is terminal until something outside this recipe (a human, via update_email) moves it back, process_queue never re-selects a failed row.

Gmail send, GmailService.send_email (gmail_service.py 29-96): builds a MIMEMultipart("alternative") with the text part attached before the HTML part (69-74, so mail clients that prefer plaintext get it); sets From to "{sender_name} <{provider_account_id}>" only when sender_name is given (62-67), otherwise Gmail fills the authenticated account's own address; base64url-encodes the raw MIME and calls service.users().messages().send(userId="me", body={"raw": raw}); returns {"success": True, "gmail_message_id": ..., "thread_id": ...} from the API response, or {"success": False, "error": str(e)} on any exception (91-96), the exception is never re-raised.

Tracking injection, inject_tracking (tracking_service.py 24-80): parses body_html with BeautifulSoup's lxml parser; for every <a href> not starting with mailto:, tel: or # (40-45), mints a 16-character secrets.token_urlsafe(16)[:16] tracking id, inserts an OutreachLink row pointing at the original href, then rewrites the anchor's href to {API_BASE_URL}/r/{link_tracking_id}; appends a 1x1 <img> pointing at {API_BASE_URL}/api/scribe/pixel/{email.tracking_id} (the email's own tracking_id, not a link's) into <body> if one exists, else onto the document root (60-74); db.flush()s the new OutreachLink rows (not commit, the caller's send_email is mid-transaction) and returns the serialized HTML as a string.

Recording an open, record_open (tracking_service.py 82-115): locks the OutreachEmail row FOR UPDATE by tracking_id; unknown id ⇒ log and return False, no row touched; always increments open_count; only on the first open (opened_at IS NULL) does it set opened_at, flip status from sent to opened (a clicked or already-opened row is left alone), and increment the campaign's total_opened (also locked FOR UPDATE); commits; returns True.

Recording a click, record_click (117-158): locks the OutreachLink row by tracking_id; unknown id ⇒ return None (the caller redirects to the fallback URL); always bumps link.click_count and last_clicked_at, sets first_clicked_at if unset; then, only on the email's first click (email.clicked_at unset), bumps the email's own click_count, sets clicked_at, flips status to clicked only if it was sent or opened (146), and bumps the campaign's total_clicked; commits; returns destination_url for the caller to redirect to.

Reply detection, detect_replies (outreach_service.py 397-435): selects every OutreachEmail with status IN (sent, opened, clicked) and a non-null gmail_thread_id (403-412, so a replied, failed or bounced row is never re-checked); per row, loads the campaign and skips if it has no gmail_integration_id; calls gmail_service.check_thread_replies; on has_reply=True and status != replied, sets status=replied, replied_at=now, bumps campaign.total_replied; commits once after the whole loop, not per row.

Thread check, check_thread_replies (gmail_service.py 98-144): fetches the thread with format="metadata", metadataHeaders=["From"] (113-118, body never fetched); reads the integration's own provider_account_id as "us"; walks every message after the first (messages[1:], 130, the original outbound message is always skipped); has_reply=True on the first message whose From header does not contain our_email as a substring (131-135, a substring match, not an exact address comparison, see Gotchas); returns {"has_reply": bool, "message_count": int}, or {"has_reply": False, "message_count": 0, "error": str(e)} on any exception.

Invariants

  • The gate only opens from draft: approve_email raises 400 on any other current status, and there is no route or function anywhere in this recipe that sets status=approved from a different state (outreach_service.py 271-273; no known-answer test exists, see Tests).
  • A campaign's daily cap is recomputed from sent_at timestamps every tick, never stored as a remaining counter, so no crash between ticks can under- or over-count it (process_queue, 366-374).
  • send_email sets status=sending and flushes before calling Gmail, so a crash inside the Gmail call is visible as a stuck sending row rather than reverting to approved and risking a second send on the next tick, though nothing in this recipe automatically resweeps a sending row either (see Gotchas).
  • record_open and record_click both take a row-level FOR UPDATE lock before mutating counters, so two near-simultaneous pixel hits or redirect hits on the same row cannot lose an increment (tracking_service.py 90, 125, 140, 150).
  • A reply is only ever detected on a row still in sent/opened/clicked; once replied, detect_replies's own WHERE excludes it from being re-checked (outreach_service.py 405-409).

Frontend

The module that renders this, by id:

prospecting/ui/outreach-campaign-workspace   the per-campaign admin view (Approve / Approve all,
                                              the daily-send-limit field, per-email status badges
                                              including opened/clicked/replied, and the Gmail
                                              integration picker), frontend/src/pages/
                                              AdminOutreachCampaign.tsx and
                                              frontend/src/services/outreachService.ts at this
                                              commit; approveEmail()/approveAll() there call
                                              exactly the two gate routes under API surface

Minimum admin/debug surface beyond that UI (no dedicated route exists at this commit, these are queries a human runs by hand): SELECT id, campaign_id, status, error_message, failed_at FROM outreach_emails WHERE status = 'failed' ORDER BY failed_at DESC (the dead-letter list process_queue never retries); SELECT id, status, last_attempt := updated_at FROM outreach_emails WHERE status = 'sending' AND updated_at < now() - interval '1 hour' (a row a crashed worker left stuck, see Gotchas); SELECT c.name, c.daily_send_limit, count(*) FILTER (WHERE e.sent_at >= date_trunc('day', now())) AS sent_today FROM outreach_campaigns c JOIN outreach_emails e ON e.campaign_id = c.id WHERE c.status='active' GROUP BY c.id (the same cap computation process_queue does, for a human to check against it).

Security & tenancy

Auth dependency: AdminUser (get_current_admin_user, api/dependencies.py 364-374) on every route under API surface except the two public tracking endpoints; it requires current_user.is_admin, 403 otherwise, itself built on get_current_active_user. Workspace derivation: WorkspaceID (_get_workspace_id, api/dependencies.py 680-741) resolves from request.state.workspace_id (pre-validated by WorkspaceMiddleware) first, then an X-Workspace-ID header re-validated against the caller's access, then a single-workspace fallback, 409 on zero workspaces, 400 on an ambiguous multi-workspace caller with no header. This recipe's own tenancy check is layered on top: approve_email and update_email fetch the OutreachEmail by id with no workspace filter at all, then call get_campaign(db, email.campaign_id, workspace_id=...), which 404s if the campaign is not in the caller's workspace (outreach_service.py 269-270, 256-257); the email itself is never directly workspace-scoped, its campaign's FK is the only fence.

Fail direction: the gate fails closed on state (400 on a non-draft email, no code path lets a PUT /emails/{id} on update_email change status around the gate, EmailUpdate schema has no status field); the two public tracking endpoints fail open on the network response deliberately (outreach_tracking_pixel always returns the GIF even for a malformed tracking_id, outreach_link_redirect always 302s somewhere even on an exception), because a 4xx/5xx here is visible to the recipient's mail client and a training signal to the recipient that they are being tracked; both endpoints are surface_exempt("public", ...) by design, not a gap.

Credentials and PII at rest: no OAuth token or PSP-equivalent credential is written by this recipe, it only reads through calendar_service.decrypt_token; error_message on a failed send stores str(e) from the Gmail client, which can include the recipient's own address (it is already the to_email this code holds, not a new exposure, but it is unredacted free text, unlike the class-name-only convention in billing-credits/auto-topup-charge-and-retry). Rate limits: none dedicated to any route in this recipe; the daily cap in process_queue is a sending-volume control, not a request-rate control, and there is no per-IP or per-token limit on the two public tracking GETs beyond the tracking_id shape check (isalnum(), <= 64 chars, outreach_tracking_pixel, tracking_router.py 1467). Attacker-controlled inputs: tracking_id on both public routes (opaque, looked up by exact/likely-unguessable match, uuid4 for the pixel, token_urlsafe(16)[:16] for a link, no enumeration protection beyond that unguessability); destination_url on outreach_link_redirect is never re-validated as a safe scheme or same-origin before the 302, it is whatever was in the original drafted <a href> (an open-redirect surface if a draft's HTML can ever come from something other than the AI composer this recipe does not own, worth flagging to whoever owns that input).

Tests

No test file for this recipe exists at this commit: find backend/tests -iname "*outreach*" returns nothing, and grepping backend/tests/ for outreach_service, OutreachEmail, OutreachCampaign, outreach_tracking_service or gmail_service matches nothing either (measured 2026-09-12; the only two hits for the bare word "outreach" anywhere under backend/tests/ are test_booking_lead_bridge.py and integration/test_workspace_isolation.py, and neither imports or exercises this recipe's code). This is a genuine gap, not a gate this recipe passes quietly: the gate's own 400-on-non-draft behavior, the daily-cap arithmetic, the tracking row-locking, and the reply-detection substring match are all unverified by any automated test in this repository. A rebuild should not treat the absence of a failing test as evidence of correctness here.

Deploy & ops

Config keys per app: API_BASE_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (all above). Migrations, in chain order (backend/migrations/versions/): 20260225_add_outreach_tables.py (outreach_001, down-revision metal_tiers_001) creates outreach_campaigns, outreach_emails, outreach_links and the leads.scraped_data/scrape_status/scraped_at columns, guarded by _table_exists/_column_exists checks so it is safe to run twice; 20260408b_add_workspace_id_to_outreach.py (outreach_ws_001, down-revision leads_ws_001) adds outreach_campaigns.workspace_id, backfills every existing row to the deployment's primary workspace, then makes it NOT NULL. A third file, backend/migrations/deploy_outreach.sql (89 lines, plain SQL, not an Alembic revision), duplicates the first migration's DDL with a header comment "Run this on Render Postgres before deploying the outreach feature"; this confirms the deploy target (Render) but its existence alongside the real Alembic revision is itself worth flagging (Gotchas).

Scheduler (scheduled_jobs.py 248-264): run_outreach_queue on IntervalTrigger(minutes=15), id outreach_queue; run_reply_detection on IntervalTrigger(hours=1), id outreach_reply_detection; both replace_existing=True. Each wrapper (1839-1876) opens its own session via get_db_session(), calls the service function, logs a one-line summary, and closes the session in a finally, matching _shared/scheduler-and-background-work's pattern; neither job is behind a settings flag, both register unconditionally whenever the scheduler starts.

Argus-style checks: POST /api/crm/outreach/campaigns/{id}/approve-all on a campaign with zero draft rows returns 200 {"approved_count": 0} (proves the route is reachable without side effects); SELECT count(*) FROM outreach_emails WHERE status = 'sending' AND updated_at < now() - interval '1 hour' should be 0 in steady state (a non-zero count is a worker that died mid-send, see Gotchas). Rollback: no destructive migration in this recipe's chain; rolling back the code leaves sending/approved rows exactly where they were, the next deploy's process_queue tick picks approved rows back up, but a sending row from before the rollback is not automatically resumed by anything (no code path re-selects status=sending).

Gotchas

  • 2026-09-12 (measured at 62ba4fc): no feature flag of any shape (OUTREACH_ENABLED or similar) exists in core/config.py or scheduled_jobs.py, the router is mounted unconditionally at /api/crm/outreach, the two scheduler jobs register unconditionally, and the frontend nav item at frontend/src/components/nav.tsx is not gated behind any flag either. maturity: prod reflects that it is live and reachable, not a separate claim that its send volume has been operated at scale; no test coverage exists to have caught a regression here (Tests).
  • 2026-09-12 (measured at 62ba4fc, outreach_service.py 321-349): send_email sets status=sending and flushes before calling Gmail, but nothing in this recipe ever re-selects a row stuck in sending, not process_queue (it only selects approved), not any scheduler job. A worker process killed between the flush and the Gmail response leaves that row permanently sending until a human intervenes (see the debug query under Frontend), the same shape of risk billing-credits/auto-topup-charge-and-retry's claim-before-send pattern solves with a reconcile job; this recipe has no reconcile step.
  • 2026-09-12 (measured at 62ba4fc, models/outreach_model.py 32, 39, 69): OutreachEmailStatus.queued and OutreachEmailStatus.bounced, and OutreachCampaign.total_bounced, are declared but grepping backend/services/outreach/ and backend/api/routers/outreach_router.py for .queued or .bounced finds no assignment anywhere. A rebuild should not assume a bounce-handling path exists; none of the code in this recipe ever classifies a send as a bounce.
  • 2026-09-12 (measured at 62ba4fc, gmail_service.py 131-135): the reply check treats a message as ours by substring match (our_email not in from_header), not an exact address comparison against the From header's parsed address. An outbound alias or display-name collision that happens to contain the sending account's address as a substring of a different address would be misread; this has not been exercised by any test (Tests).
  • 2026-09-12 (measured at 62ba4fc): backend/migrations/deploy_outreach.sql is a hand-run SQL file duplicating 20260225_add_outreach_tables.py's DDL, with a comment aimed at a human running it directly against Render Postgres. Whether it or the Alembic revision is the one actually run before a real deploy is not determinable from the repository alone; a rebuild should pick one mechanism, not carry both.
  • 2026-09-12 (measured at 62ba4fc): outreach_link_redirect's fallback destination on an unknown tracking_id or any exception is the literal string "https://alchemy.zuerich" (app_fastapi.py 1103, 1105), not a configured value; a rebuild for a different brand must not copy this literal.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/outreach/outreach_service.py (464 lines, OutreachService class from line 30): approve_email (265-277); approve_all_drafts (279-293); send_email (297-349); process_queue (353-395); detect_replies (397-435); get_global_stats (439-461, out of this recipe's write path, cited for the read surface).
  • alchemy_labs/backend/services/outreach/gmail_service.py (185 lines, whole file in scope): GmailService.send_email (29-96); check_thread_replies (98-144); _get_gmail_service (146-182, the OAuth Credentials build and refresh-on-expiry).
  • alchemy_labs/backend/services/outreach/tracking_service.py (161 lines, whole file in scope): inject_tracking (24-80); record_open (82-115); record_click (117-158).
  • alchemy_labs/backend/models/outreach_model.py (146 lines, whole file in scope): CampaignStatus (17-21); OutreachEmailStatus (24-39); OutreachCampaign (44-77); OutreachEmail (80-124); OutreachLink (127-146).
  • alchemy_labs/backend/api/routers/outreach_router.py (169 lines): approve_email route (149-153); approve_all route (156-160); get_stats route (165-168, context).
  • alchemy_labs/backend/api/routers/tracking_router.py: _TRANSPARENT_GIF (1446-1451); outreach_tracking_pixel (1454-1487).
  • alchemy_labs/backend/app_fastapi.py: the router mount (878); outreach_link_redirect (1086-1110).
  • alchemy_labs/backend/services/scheduled_jobs.py: job registrations (248-264); run_outreach_queue (1839-1856); run_reply_detection (1859-1876).
  • alchemy_labs/backend/migrations/versions/20260225_add_outreach_tables.py, 20260408b_add_workspace_id_to_outreach.py; alchemy_labs/backend/migrations/deploy_outreach.sql (the duplicate hand-run SQL, Gotchas).
  • No test file exists for this recipe (Tests).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: no reconcile job for a row stuck in sending; queued and bounced are dead enum values; the reply-detection "is this us" check is a substring match, not an exact address comparison; two overlapping migration mechanisms (Alembic revision and a hand-run SQL file) exist for the same schema; zero automated test coverage.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe); no scratch-database harness is provided by this recipe since no test file exists (Tests), so these are direct source checks and one live-shape check, not a test-suite run.

  1. grep -n "if email.status != OutreachEmailStatus.draft" services/outreach/outreach_service.py prints line 271, confirming the gate's guard clause is still exactly one condition on draft.
  2. grep -n "remaining = (campaign.daily_send_limit or 50) - sent_today" services/outreach/outreach_service.py prints line 374, confirming the daily cap is recomputed from a fresh count every call, not read from a stored remaining-budget column.
  3. grep -n "email.status = OutreachEmailStatus.sending" services/outreach/outreach_service.py prints line 321, and grep -c "OutreachEmailStatus.sending" services/outreach/outreach_service.py prints 1 (that assignment is the only reference to the sending status in the service, confirming no code path ever re-reads or clears it, the stuck-row gap in Gotchas).
  4. grep -c "OutreachEmailStatus.queued\|OutreachEmailStatus.bounced" services/outreach/outreach_service.py api/routers/outreach_router.py prints 0, confirming those two statuses are never assigned outside the model file itself.
  5. find tests -iname "*outreach*" 2>/dev/null | wc -l prints 0.
  6. grep -n "\"/r/{tracking_id}\"" app_fastapi.py prints line 1087, and grep -n "url=\"https://alchemy.zuerich\"" app_fastapi.py prints two matches (1103, 1105), confirming both fallback branches point at the same hardcoded literal.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.