Prima Get an API key

Module kit / Prospecting

Outreach Campaign and Lead Import

prospecting/outreach-campaign-lead-import

Kind backend
Domain prospecting
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, openpyxl-3.1
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/test-layout
Frontends prospecting/ui/outreach-campaign-workspace
Reference implementation
  • alchemy_labs/backend/services/outreach/outreach_service.py
  • alchemy_labs/backend/api/routers/outreach_router.py
  • alchemy_labs/backend/models/outreach_model.py
  • alchemy_labs/backend/schemas/outreach_schemas.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

An admin creates an outreach campaign, then gets a batch of prospects into it three ways (one at a time, a JSON array, or a CSV/XLSX upload), and every one of those paths ends at the same two guarantees: one Lead row per workspace-scoped email address, ever, and one OutreachEmail row per (campaign, lead) pair, ever, freshly created rows always starting at pending_scrape. The invariant that makes this trustworthy is that both de-duplications are real: the lead de-dup is a live database unique index (uq_leads_workspace_email on (workspace_id, lower(email)), not just an application-side check), and the campaign-membership de-dup is an explicit existence check before every OutreachEmail insert (outreach_service.py:117-126), so re-uploading the same list into the same campaign is a safe no-op rather than a pile of duplicate outreach emails.

When to use

Read it before touching create_campaign, update_campaign, add_leads_to_campaign, or import_csv (outreach_service.py:35-185); before changing the CSV/XLSX header-aliasing rules (email/website/url/name/contact/company/firma); and when someone asks "why didn't my CSV import create new leads", "why are there fewer outreach emails than rows in my spreadsheet", or "can I re-run this import safely". Trigger phrases: "outreach campaign", "import leads", "lead import", "pending_scrape", "add leads to campaign", "cold outreach CSV".

Not this recipe, same files, same domain, a neighbouring send/scrape/compose pipeline recipe not yet written: scrape_and_generate and composition_service/scraper_service (turning a pending_scrape email into a draft with AI-generated copy, outreach_service.py:189-238); approve_email/approve_all_drafts and the draft-review workflow (outreach_service.py:265-293); send_email/process_queue and the Gmail-send + daily-limit machinery, scheduled every 15 minutes (scheduled_jobs.py:248-254, outreach_service.py:297-395); detect_replies, scheduled hourly (scheduled_jobs.py:256-262, outreach_service.py:397-435); outreach_tracking_service's open-pixel and click-redirect handling (OutreachLink, the public /r/{tracking_id} route at app_fastapi.py:1088-1101); get_global_stats (outreach_service.py:439-461, GET /outreach/stats). This recipe ends the moment an OutreachEmail row exists at pending_scrape; everything after that belongs to the pipeline above.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for request/response schemas, openpyxl==3.1.2 for .xlsx/.xls parsing (stdlib csv for .csv), psycopg2-binary==2.9.10, alembic==1.16.5 (all versions from backend/requirements.txt). Foundations assumed: S4 (fastapi-project-layout) for the app and mount_router, this router is mounted the same way as every other production router, surfaces=LABS_SURFACES, no feature flag (app_fastapi.py:878); S5 (auth-dependency-pattern) for AdminUser; S6 (workspace-scoping) for WorkspaceID; S8 (alembic-migrations) for the two chained migrations under Data model; S9 (test-layout) for where a hermetic test for this recipe would live, see Tests, none exists yet.

No dedicated environment variable gates this recipe (grep -ni outreach backend/core/config.py finds nothing but a comment). The only external collaborator this recipe's own scope touches is the workspace's leads table, shared with the rest of the CRM domain, not owned here.

Data model

outreach_campaigns, one row per campaign, workspace_id added later than the table itself (models/outreach_model.py:44-77; migrated across two chained revisions, see Deploy & ops):

outreach_campaigns (
  id                       uuid PRIMARY KEY,
  workspace_id             uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  name                     varchar NOT NULL,
  description              text,
  status                   varchar DEFAULT 'draft',           -- draft | active | paused | completed, see below
  send_interval_minutes    integer DEFAULT 15,                 -- ⚠️ written and returned, never read (Gotchas)
  daily_send_limit         integer DEFAULT 50,                 -- the ONLY outbound-volume control, enforced only in process_queue, not in this recipe's scope
  gmail_integration_id     uuid REFERENCES calendar_integrations(id),  -- FK into calendar-booking's own table, not owned here
  template_prompt          text,
  sender_name              varchar DEFAULT 'Miriam',
  sender_email             varchar,                            -- ⚠️ written and returned, never read (Gotchas)
  total_leads              integer DEFAULT 0,                   -- a live COUNT(*) over outreach_emails, recomputed on every import, not an incremented counter
  total_sent, total_opened, total_clicked, total_replied, total_bounced   integer DEFAULT 0,  -- owned by the send/tracking pipeline, out of this recipe's scope
  created_at timestamptz DEFAULT now(), updated_at timestamptz
)

outreach_emails, one row per (campaign, lead) pair this recipe is responsible for creating exactly once (models/outreach_model.py:80-124):

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            -- NULL until the compose pipeline fills them in, out of this recipe's scope
  status        varchar DEFAULT 'pending_scrape',   -- the only status this recipe ever writes
  tracking_id   uuid UNIQUE DEFAULT gen_random_uuid(),
  gmail_message_id, gmail_thread_id        varchar,  -- owned by the send pipeline
  open_count, click_count                  integer DEFAULT 0,
  scraped_at, composed_at, approved_at, queued_at, sent_at, opened_at,
  clicked_at, replied_at, bounced_at, failed_at      timestamptz,
  error_message text,
  created_at timestamptz DEFAULT now(), updated_at timestamptz
)
-- full status vocabulary (models/outreach_model.py:24-39), this recipe only ever writes the first:
--   pending_scrape (this recipe) -> scraping -> scrape_failed | pending_compose -> composing -> draft
--   -> approved -> queued -> sending -> sent -> opened -> clicked -> replied | bounced | failed

outreach_links, populated by the tracking pipeline, not by this recipe, listed here because it is part of the same schema (models/outreach_model.py:127-146): email_id FK, its own tracking_id varchar(32) UNIQUE (distinct scheme from outreach_emails.tracking_id, which is a uuid), destination_url, click counters.

What is NOT stored: no Gmail OAuth credential anywhere in this recipe's tables, gmail_integration_id is a bare FK into calendar_integrations, a table this recipe does not own; no raw scraped page content on outreach_emails, structured scrape output lives on leads.scraped_data (JSONB), a column this migration also adds but a table this recipe does not otherwise touch; no AI prompt or raw LLM request/response, only the eventual subject/body_html/body_text once the (out-of-scope) compose step runs.

⚠️ status on both tables is a plain varchar at the database level, not a Postgres native enum and not CHECK-constrained (migrations/versions/20260225_add_outreach_tables.py creates both columns as sa.String()). The Python-side Column(Enum(CampaignStatus)) / Column(Enum(OutreachEmailStatus)) on the ORM model (models/outreach_model.py:51,93) is the only thing refusing an invalid value; a raw SQL statement or a future ORM bypass is not stopped by the schema itself, unlike a table with real CHECK constraints.

leads (shared with the rest of CRM, not owned by this recipe, cited because add_leads_to_campaign writes to it): email, first_name, last_name, company_name, website, source (this recipe always writes LeadSource.cold_outreach), status (always LeadStatus.new on creation here), plus the scraping columns this migration adds (scraped_data, scrape_status, scraped_at). De-duplication is enforced by uq_leads_workspace_email UNIQUE (workspace_id, lower(email)) (migrations/versions/20260408_add_workspace_id_to_leads.py:56-59), which replaced an earlier globally unique email index when workspace scoping was added to leads (same migration, lines 53-55).

API surface

Six routes, all under router = APIRouter(prefix="/outreach", ...) mounted at /api/crm (app_fastapi.py:878), so the live paths are /api/crm/outreach/.... The scrape/compose/send/approve/stats routes live in the same router file but are a neighbouring recipe, see When to use.

RouteAuthTenancyBodyCodesNotes
POST /outreach/campaigns (outreach_router.py:26-29)AdminUserWorkspaceIDCampaignCreate (outreach_schemas.py:13-21)201; 422 on schema validationoutreach_service.create_campaign
GET /outreach/campaigns (32-35)AdminUserWorkspaceIDnone200, List[CampaignResponse]list_campaigns, ordered created_at DESC (outreach_service.py:60)
GET /outreach/campaigns/{campaign_id} (38-41)AdminUserWorkspaceIDnone200; 404 "Campaign not found" (outreach_service.py:53) if missing or in another workspaceget_campaign, the workspace-scoped lookup every other method in this recipe calls first
PUT /outreach/campaigns/{campaign_id} (44-47)AdminUserWorkspaceIDCampaignUpdate, exclude_unset=True (outreach_schemas.py:24-33)200; 404 as aboveupdate_campaign; generic setattr loop bounded by the Pydantic schema's own field set, not truly arbitrary (Security & tenancy)
POST /outreach/campaigns/{campaign_id}/leads (52-56)AdminUserWorkspaceIDLeadImportRequest = {leads: [LeadImportItem]}, email: EmailStr (pydantic-validated) (outreach_schemas.py:62-70)200, {leads_created, emails_created, skipped}; 422 on a malformed email, before the service runsadd_leads_to_campaign
POST /outreach/campaigns/{campaign_id}/import (59-77)AdminUserWorkspaceIDmultipart/form-data, one file200, same shape as above; 400 "Only CSV and XLSX files are supported" (71) on a bad extension; 413 "File too large (max 10MB)" (75)import_csv; extension check is case-sensitive .endswith((".csv",".xlsx",".xls")) (70); the whole body is read into memory (await file.read(), 73) before the size check, so an oversized upload is still fully buffered, not streamed or rejected early

Application logic

create_campaign / get_campaign / list_campaigns / update_campaign (outreach_service.py:35-71): plain workspace-scoped CRUD, nothing conditional. get_campaign is the single choke point every other method in this recipe calls first (.filter(id==, workspace_id==).first(), 404 if either doesn't match, 44-54); a campaign in a different workspace is indistinguishable from one that doesn't exist.

add_leads_to_campaign (outreach_service.py:75-149), in commit order:

  1. Resolve the campaign via get_campaign (79); its own workspace_id, not the caller's raw input, is used for every lead lookup from here on (91).
  2. For each item in leads_data: normalize email = item.get("email","").lower().strip(); an empty result is skipped, counted, and the loop continues (85-88), this is the only email validation this path performs, see Security & tenancy for the contrast with the CSV/XLSX path.
  3. Find-or-create the Lead, scoped to (email, workspace_id) (92-96). On create: first_name/last_name are split from item["name"] by whitespace, first token is first_name, the remaining tokens joined are last_name (only when there's more than one token, 101-102); company_name/website copied verbatim; source=LeadSource.cold_outreach, status=LeadStatus.new (105-106); db.flush() immediately so lead.id exists for step 5 without ending the transaction (109).
  4. If the lead already existed and had no website, backfill it from this item (113-114); an existing website is never overwritten.
  5. Check for an existing OutreachEmail at (campaign_id, lead.id) (117-122); if found, skip and count it, no update, no second row (124-126).
  6. Otherwise create the OutreachEmail at status=OutreachEmailStatus.pending_scrape (129-135).
  7. After the loop, campaign.total_leads is recomputed as COUNT(*) over outreach_emails for this campaign (138-140), a live recount rather than an increment.
  8. One db.commit() for the entire batch (142).

import_csv (outreach_service.py:151-185): for .xlsx/.xls, openpyxl.load_workbook(..., read_only=True), headers taken from row 1 lower-cased, each subsequent row zipped against those headers; for anything else (assumed CSV), file_content.decode("utf-8-sig") (handles a leading BOM) into csv.DictReader. Both paths normalize the same four logical fields with the same aliases: email; website (falls back to url); name (falls back to contact); company (falls back to firma, German for "company"). The resulting list is handed straight to add_leads_to_campaign (185), so everything above applies identically; there is no format validation on the email cell itself, an empty-string check is the only guard.

Invariants

  • At most one Lead per (workspace_id, lower(email)), enforced by the database (uq_leads_workspace_email), not merely by this service's own SELECT-then-INSERT (test: none exists yet, see Tests).
  • At most one OutreachEmail per (campaign_id, lead_id), enforced by this service's own existence check before insert (outreach_service.py:117-126); a re-import of the same list into the same campaign changes nothing beyond the skipped counter.
  • Every OutreachEmail this recipe creates starts at pending_scrape and only at pending_scrape (model default and explicit constructor argument agree: outreach_model.py:93, outreach_service.py:132).
  • One add_leads_to_campaign call is one transaction: leads are flush()-ed mid-loop only so their id is available for the following insert, the whole batch shares one db.commit() at the end (109, 142); an exception partway through rolls back every lead and email created earlier in that same call, there is no partial-batch commit.
  • campaign.total_leads can never drift from the emails actually created, it is recomputed, not incremented, every call (138-140).

Frontend

The module that renders this, by id:

prospecting/ui/outreach-campaign-workspace   the admin campaign list + detail view (create/edit a campaign,
                                              paste or upload a lead list) this recipe's endpoints back; at
                                              62ba4fc the reference implementation is
                                              frontend/src/pages/AdminOutreach.tsx (list),
                                              frontend/src/pages/AdminOutreachCampaign.tsx (detail),
                                              frontend/src/components/OutreachLeadImport.tsx (the JSON/CSV/XLSX
                                              entry form), calling frontend/src/services/outreachService.ts,
                                              which hits every route in API surface at `/api/crm/outreach/...`
                                              and nothing else outside this recipe's scope

Minimum admin/debug surface (no dedicated route exists at this commit): SELECT id, name, status, total_leads FROM outreach_campaigns WHERE workspace_id = :ws ORDER BY created_at DESC (did the campaign get created); SELECT status, count(*) FROM outreach_emails WHERE campaign_id = :id GROUP BY status (did the import actually queue leads at pending_scrape, or did everything get skipped); the log lines OUTREACH: Created campaign '%s' (%s) in workspace %s (outreach_service.py:40) and OUTREACH: Added %d leads to campaign %s (144, this line does not report the skipped count, only emails_created).

Security & tenancy

Auth dependency: AdminUser (api/dependencies.py:568, Depends(get_current_admin_user)) on every route in this recipe. The chain underneath: get_current_user (187-234) resolves a JWT bearer token, cookie, or X-API-Key, 401 "Could not validate credentials" on failure; get_current_active_user (338-349) then requires is_active, 403 "Inactive user"; get_current_admin_user (364-375) requires is_admin, 403 "Admin access required". No route in this recipe accepts a non-admin caller.

Workspace derivation: WorkspaceID (api/dependencies.py:779, _get_workspace_id, 680-778). Resolution order: request.state.workspace_id if the JWT-auth middleware already validated one; else the X-Workspace-ID header, re-validated against the resolved user's access; else, if the user has exactly one accessible workspace, that one; else 400 if ambiguous (multiple workspaces, no header) or 409 {"error": "no_workspace"} if the user has none. Every method in this recipe takes workspace_id as an explicit argument and the first thing every one of them does (directly, or via get_campaign) is filter on it; there is no code path in this recipe's scope that reads or writes a Lead or OutreachCampaign row without that filter.

Fail direction: closed. A campaign that exists but belongs to another workspace answers the same 404 as one that doesn't exist at all (outreach_service.py:52-53), the caller cannot distinguish "wrong workspace" from "no such campaign."

update_campaign's generic setattr loop (outreach_service.py:66-68, for key, value in data.items(): if hasattr(campaign, key) and value is not None: setattr(...)) looks unbounded but isn't in practice: data is always CampaignUpdate.dict(exclude_unset=True) from the router (outreach_router.py:47), and CampaignUpdate declares only nine fields, none of them id, workspace_id, or any of the total_* counters (outreach_schemas.py:24-33). The schema, not the service method, is what bounds this.

Attacker-controlled inputs, all behind AdminUser: the JSON leads path validates email: EmailStr before the service ever runs (outreach_schemas.py:63), a malformed address is refused at 422; the CSV/XLSX path validates nothing about the email cell beyond non-empty (Application logic), so a spreadsheet with a garbage "email" column silently becomes Lead.email values with no format check anywhere in this recipe. No dedicated rate limit exists on either import route (no limiter decorator on outreach_router.py); the only volume control anywhere in this file, daily_send_limit, is read exclusively by process_queue (out of this recipe's scope) and does not bound how many leads a single POST .../leads or .../import call can create.

Secrets at rest: none held by this recipe's own tables. gmail_integration_id is an FK into calendar_integrations, a table and credential this recipe never reads or writes.

Tests

No test file in backend/tests/ exercises outreach_service.py or outreach_router.py directly (grep -rl "outreach_service\|OutreachService\|outreach_router\|OutreachCampaign" backend/tests --include="*.py" returns nothing). Two files reference the domain only incidentally, using LeadSource.cold_outreach as a fixture value, never calling anything in this recipe's scope: backend/tests/test_booking_lead_bridge.py:166,180 and backend/tests/integration/test_workspace_isolation.py:113.

Measured coverage, from the project's own pytest.ini (addopts always runs --cov=. --cov-report=html, so this is generated on any full-suite run, not a one-off), backend/htmlcov/ (gitignored), created 2026-09-12 23:28 +0700, same day as this recipe's measured_on: services/outreach/outreach_service.py 15%, api/routers/outreach_router.py 52%, models/outreach_model.py 100%, schemas/outreach_schemas.py 100% (the two 100%s are declaration-only files, covered by import, not by exercised behaviour). The project's [coverage:report] fail_under = 75 is an aggregate threshold across the whole backend, so these two low-coverage files do not fail CI on their own.

What a rebuild adds and this branch lacks: any hermetic test for create_campaign/add_leads_to_campaign/import_csv; a test proving the uq_leads_workspace_email unique index actually gets hit (and how it's handled, see Gotchas) under a concurrent import; a test for the CSV/XLSX header-aliasing table; a test distinguishing the JSON path's EmailStr validation from the CSV path's lack of one.

Deploy & ops

No dedicated config key. 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, adds scraped_data/scrape_status/scraped_at to leads, and adds 'cold_outreach' to the leadsource Postgres enum; 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 agency's own primary workspace, then locks the column NOT NULL. leads_ws_001 (20260408_add_workspace_id_to_leads.py, a leads-table migration this recipe depends on but doesn't own) is what actually creates uq_leads_workspace_email.

⚠️ backend/migrations/deploy_outreach.sql is a standalone raw-SQL script carrying a header comment "Run this on Render Postgres before deploying the outreach feature", and its CREATE TABLE IF NOT EXISTS statements duplicate the same three tables outreach_001 creates through Alembic. The repository does not say which of the two actually created these tables on the current production database; both are idempotent (IF NOT EXISTS) so running either after the other is harmless, but a rebuild should treat this as a genuinely undetermined fact, not resolve it either way.

Router mount: mount_router(app, outreach_router.router, prefix="/api/crm", tags=["Outreach"], surfaces=LABS_SURFACES) (app_fastapi.py:878), the same call shape and the same LABS_SURFACES set as billing_router, commerce_router, and every other production router in the file; no feature flag or conditional wraps this line. Rollback: outreach_campaigns/outreach_emails rows are never deleted by this recipe's own code; a code rollback needs no data migration.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, grep -rn "send_interval_minutes\|sender_email" backend/services/outreach/*.py backend/api/routers/outreach_router.py returns nothing): both outreach_campaigns.send_interval_minutes and outreach_campaigns.sender_email are real, writable, returned schema fields (CampaignCreate/CampaignUpdate/CampaignResponse) that no code anywhere in the service or router layer ever reads. sender_email is meant to override the Gmail "from" display, but gmail_service.send_email's signature (gmail_service.py:29-38) has no sender_email parameter at all, only sender_name, which the send path does pass. An admin can set either field in the campaign UI and it will have zero effect.
  • 2026-09-12 (measured at 62ba4fc, models/outreach_model.py:51,93 vs migrations/versions/20260225_add_outreach_tables.py): both status columns are plain varchar at the database level with no CHECK constraint and no native Postgres enum, only SQLAlchemy's client-side Enum() type stops an invalid value from the ORM's own write path; a raw SQL statement is not refused by the schema.
  • 2026-09-12 (measured at 62ba4fc, outreach_service.py:92-109): lead de-dup in add_leads_to_campaign is a plain SELECT-then-INSERT with no row lock and no try/except around the flush()/commit(). The only thing that would actually stop two concurrent imports from both trying to create the same (workspace_id, email) lead is the database's uq_leads_workspace_email unique index, and a violation of it is not caught anywhere in this file: it would surface as an uncaught IntegrityError, failing the whole batch (Application logic Invariant: one call is one transaction) rather than gracefully falling back to the existing row.
  • 2026-09-12 (measured at 62ba4fc): the CSV/XLSX import path validates nothing about the email column beyond non-empty; the JSON POST .../leads path validates email: EmailStr before the service runs. The two entry points this recipe exposes for the "same" operation have materially different input guarantees.

Reference implementation

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

  • alchemy_labs/backend/services/outreach/outreach_service.py (464 lines, 15 methods): create_campaign/get_campaign/list_campaigns/update_campaign (35-71); add_leads_to_campaign (75-149); import_csv (151-185); everything from scrape_and_generate (189) onward is the neighbouring pipeline, out of scope, see When to use.
  • alchemy_labs/backend/api/routers/outreach_router.py (168 lines, 12 routes): the six in scope (26-77); the remaining six (scrape-and-generate, emails, emails/{id} PUT, emails/{id}/approve, approve-all, stats, 82-168) are out of scope.
  • alchemy_labs/backend/models/outreach_model.py (146 lines): CampaignStatus/OutreachEmailStatus (17-39); OutreachCampaign (44-77); OutreachEmail (80-124); OutreachLink (127-146).
  • alchemy_labs/backend/schemas/outreach_schemas.py (130 lines, whole file in scope for the six routes above): CampaignCreate/CampaignUpdate/CampaignResponse (13-57); LeadImportItem/LeadImportRequest (62-70).
  • alchemy_labs/backend/models/crm_model.py: LeadSource/LeadStatus (16-35); Lead (70-112), cited for context, owned by the wider CRM domain.
  • alchemy_labs/backend/app_fastapi.py: the router mount (878).
  • alchemy_labs/backend/migrations/versions/20260225_add_outreach_tables.py, 20260408_add_workspace_id_to_leads.py, 20260408b_add_workspace_id_to_outreach.py; alchemy_labs/backend/migrations/deploy_outreach.sql (see Deploy & ops).
  • Frontend, cited for the frontend_modules boundary, not otherwise measured by this backend recipe: alchemy_labs/frontend/src/pages/AdminOutreach.tsx, AdminOutreachCampaign.tsx, alchemy_labs/frontend/src/components/OutreachLeadImport.tsx, alchemy_labs/frontend/src/services/outreachService.ts; routed at /admin/outreach and /admin/outreach/:id behind AdminRoute (frontend/src/App.tsx:868-882) and linked from the admin nav (frontend/src/components/nav.tsx:322,330).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: send_interval_minutes and sender_email are dead fields (Gotchas); status columns carry no database-level constraint; the lead-dedup race is unhandled; the two import entry points validate email format inconsistently; no test exists for any function in this recipe's scope; it is undetermined which of two duplicate DDL sources (deploy_outreach.sql vs the Alembic migration) actually built the current production schema.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe). Expected observations are exact, as measured 2026-09-12.

  1. grep -n "^class OutreachCampaign" models/outreach_model.py prints 44; grep -n "^class OutreachEmail" models/outreach_model.py prints 80.
  2. grep -c "^ def " services/outreach/outreach_service.py prints 15.
  3. grep -c "^@router\." api/routers/outreach_router.py prints 12.
  4. grep -n "outreach_router.router" app_fastapi.py prints 878:mount_router(app, outreach_router.router, prefix="/api/crm", tags=["Outreach"], surfaces=LABS_SURFACES).
  5. grep -rn "send_interval_minutes\|sender_email" services/outreach/*.py api/routers/outreach_router.py prints nothing (confirms both fields are dead, Gotchas).
  6. grep -n "uq_leads_workspace_email" migrations/versions/20260408_add_workspace_id_to_leads.py prints the CREATE UNIQUE INDEX ... ON leads (workspace_id, lower(email)) line (56-59).
  7. python3 -c "import ast; ast.parse(open('services/outreach/outreach_service.py').read()); print('OK')" prints OK (a syntax sanity check standing in for the hermetic test suite this recipe does not have, see Tests).
  8. grep -rl "outreach_service\|OutreachService\|outreach_router\|OutreachCampaign" tests --include="*.py" prints nothing, confirming the Tests section's central claim.

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.