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 |
|
| 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.
| Route | Auth | Tenancy | Body | Codes | Notes |
|---|---|---|---|---|---|
POST /outreach/campaigns (outreach_router.py:26-29) | AdminUser | WorkspaceID | CampaignCreate (outreach_schemas.py:13-21) | 201; 422 on schema validation | outreach_service.create_campaign |
GET /outreach/campaigns (32-35) | AdminUser | WorkspaceID | none | 200, List[CampaignResponse] | list_campaigns, ordered created_at DESC (outreach_service.py:60) |
GET /outreach/campaigns/{campaign_id} (38-41) | AdminUser | WorkspaceID | none | 200; 404 "Campaign not found" (outreach_service.py:53) if missing or in another workspace | get_campaign, the workspace-scoped lookup every other method in this recipe calls first |
PUT /outreach/campaigns/{campaign_id} (44-47) | AdminUser | WorkspaceID | CampaignUpdate, exclude_unset=True (outreach_schemas.py:24-33) | 200; 404 as above | update_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) | AdminUser | WorkspaceID | LeadImportRequest = {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 runs | add_leads_to_campaign |
POST /outreach/campaigns/{campaign_id}/import (59-77) | AdminUser | WorkspaceID | multipart/form-data, one file | 200, 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:
- Resolve the campaign via
get_campaign(79); its ownworkspace_id, not the caller's raw input, is used for every lead lookup from here on (91). - For each item in
leads_data: normalizeemail = 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. - Find-or-create the
Lead, scoped to(email, workspace_id)(92-96). On create:first_name/last_nameare split fromitem["name"]by whitespace, first token isfirst_name, the remaining tokens joined arelast_name(only when there's more than one token, 101-102);company_name/websitecopied verbatim;source=LeadSource.cold_outreach,status=LeadStatus.new(105-106);db.flush()immediately solead.idexists for step 5 without ending the transaction (109). - If the lead already existed and had no
website, backfill it from this item (113-114); an existing website is never overwritten. - Check for an existing
OutreachEmailat(campaign_id, lead.id)(117-122); if found, skip and count it, no update, no second row (124-126). - Otherwise create the
OutreachEmailatstatus=OutreachEmailStatus.pending_scrape(129-135). - After the loop,
campaign.total_leadsis recomputed asCOUNT(*)overoutreach_emailsfor this campaign (138-140), a live recount rather than an increment. - 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
Leadper(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
OutreachEmailper(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 theskippedcounter. - Every
OutreachEmailthis recipe creates starts atpending_scrapeand only atpending_scrape(model default and explicit constructor argument agree:outreach_model.py:93,outreach_service.py:132). - One
add_leads_to_campaigncall is one transaction: leads areflush()-ed mid-loop only so theiridis available for the following insert, the whole batch shares onedb.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_leadscan 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.pyreturns nothing): bothoutreach_campaigns.send_interval_minutesandoutreach_campaigns.sender_emailare real, writable, returned schema fields (CampaignCreate/CampaignUpdate/CampaignResponse) that no code anywhere in the service or router layer ever reads.sender_emailis meant to override the Gmail "from" display, butgmail_service.send_email's signature (gmail_service.py:29-38) has nosender_emailparameter at all, onlysender_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,93vsmigrations/versions/20260225_add_outreach_tables.py): both status columns are plainvarcharat the database level with no CHECK constraint and no native Postgres enum, only SQLAlchemy's client-sideEnum()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 inadd_leads_to_campaignis a plain SELECT-then-INSERT with no row lock and notry/exceptaround theflush()/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'suq_leads_workspace_emailunique index, and a violation of it is not caught anywhere in this file: it would surface as an uncaughtIntegrityError, 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 .../leadspath validatesemail: EmailStrbefore 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 fromscrape_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_modulesboundary, 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/outreachand/admin/outreach/:idbehindAdminRoute(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.
grep -n "^class OutreachCampaign" models/outreach_model.pyprints44;grep -n "^class OutreachEmail" models/outreach_model.pyprints80.grep -c "^ def " services/outreach/outreach_service.pyprints15.grep -c "^@router\." api/routers/outreach_router.pyprints12.grep -n "outreach_router.router" app_fastapi.pyprints878:mount_router(app, outreach_router.router, prefix="/api/crm", tags=["Outreach"], surfaces=LABS_SURFACES).grep -rn "send_interval_minutes\|sender_email" services/outreach/*.py api/routers/outreach_router.pyprints nothing (confirms both fields are dead, Gotchas).grep -n "uq_leads_workspace_email" migrations/versions/20260408_add_workspace_id_to_leads.pyprints theCREATE UNIQUE INDEX ... ON leads (workspace_id, lower(email))line (56-59).python3 -c "import ast; ast.parse(open('services/outreach/outreach_service.py').read()); print('OK')"printsOK(a syntax sanity check standing in for the hermetic test suite this recipe does not have, see Tests).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.