Plan: Sales CRM Phase B, Gmail ingest + auto last-touch + cadence follow-ups¶
URL: https://mkdocs.justinsforge.com/memory/plans/sales-crm-phase-b-gmail-ingest-2026-07-06/
Date: 2026-07-06
Approved spec: Business-Gmail-only ingest job that matches messages against crm.contacts.emails[], upserts them as crm.activities rows (kind=email, subject + snippet stored, full body loaded live from Gmail on demand, never stored), advances last_touch_at on any matched email in either direction (never backwards). Trigger = token-gated jobs route (finance-jobs pattern, SALES_JOBS_TOKEN) + user-scope systemd timer every 30 min (written, enabled only at merge) + in-app "Sync now". Reminders in-app only: Follow-ups overdue strip (now - last_touch_at > cadence_days) with quick log-touch. Watermark state in crm.sync_state. Errors surface in job result, per-chunk failures don't kill the run.
Out of scope: - Personal Gmail account - Telegram/morning-report digests - Auto-creating contacts from unknown senders - Contracts, tasks linkage, ERPNext invoice link (Phase C) - Outreach draft agent, LinkedIn anything (Phase D) - Enabling the systemd timer (merge-time step)
Conventions: worktree <WT> = /home/justinwieb/forge-suite-wt/feat/sales-crm, branch feat/sales-crm, dev slot :3064, DB clone ct_feat_sales_crm (on forge-data; reach via app API or forge_workspace_migrate.py). Token bridge reuses lib/gmail.ts getAccessToken("business") seam; NEVER hand-roll auth.
Task list¶
Task 1: Migration, email-activity columns + sync watermark¶
- Files:
<WT>/db/migrations/<stamp>_sales_crm_email_ingest.sql(viaforge_workspace_new_migration.py) - What:
alter crm.activities add gmail_message_id text, gmail_thread_id text, direction text check (direction in ('in','out')), source_account text; partial unique index on(workspace_id, gmail_message_id) where gmail_message_id is not null; new tablecrm.sync_state(workspace_id, job_key, last_synced_at, detail jsonb; unique(workspace_id, job_key)) with RLS + grants matching Phase A tables. - Verification:
(cd <WT> && PGDATABASE=ct_feat_sales_crm WORKSPACE_MIGRATIONS_DIR=db/migrations python3 ~/forge/scripts/forge_workspace_migrate.py)then\d crm.activitiesshows the 4 columns and\dt crm.sync_stateexists - Commit:
feat(sales-crm): email-activity columns + crm.sync_state watermark
Task 2: Gmail ingest job¶
- Files:
<WT>/lib/sales-crm-jobs/gmail.ts, small export additions to<WT>/lib/sales-crm.ts - What:
runGmailSync(workspaceId): load contact emails, chunk ~15 addresses per Gmail query (from:ORto:each addr,newer_than/after:from watermark minus 1d overlap), business account only via thelib/gmail.tstoken seam, classify direction by whether from_addr is a contact email, upsert activities on gmail_message_id conflict do nothing, advancelast_touch_at = greatest(last_touch_at, msg date), write watermark. Per-chunk try/catch collects errors into the result; throws only if every chunk fails. - Verification:
(cd <WT> && npx tsc --noEmit) - Commit:
feat(sales-crm): business-Gmail ingest job (activities + auto last-touch)
Task 3: Jobs route + in-app sync op¶
- Files:
<WT>/app/api/apps/sales/jobs/route.ts,<WT>/app/api/apps/sales/route.ts(addop: "gmail_sync"),<WT>/.env.local(devSALES_JOBS_TOKEN) - What: Bearer-token route (compare to
SALES_JOBS_TOKEN, 401 otherwise; finance-jobs pattern) that runsrunGmailSyncfor all workspaces with the sales app installed; session-authedop: "gmail_sync"in the main route for the in-app button (single workspace). - Verification: token curl returns job summary JSON; no-token curl returns 401;
npx tsc --noEmit - Commit:
feat(sales-crm): token-gated gmail-sync jobs route + in-app sync op
Task 4: Timeline email rendering + on-demand full body¶
- Files:
<WT>/app/api/apps/sales/route.ts(GET?messageBody=<id>),<WT>/lib/gmail.tsor job lib (body fetch by message id, business account),<WT>/components/apps/sales/ContactsView.tsx,<WT>/components/apps/sales/sales.css - What: Email activities render subject + snippet + direction arrow + account; "Load full message" expander fetches body live by gmail_message_id (text/plain part, fallback stripped HTML); body never persisted.
- Verification: dev-slot click-through: contact timeline shows synced email, expander loads body;
npx next build --no-linttail clean - Commit:
feat(sales-crm): email activities in timeline with on-demand full body
Task 5: Follow-ups overdue strip + Sync now¶
- Files:
<WT>/components/apps/sales/SalesClient.tsx,<WT>/components/apps/sales/FollowUpsStrip.tsx(new),<WT>/components/apps/sales/sales.css - What: Strip above tabs listing contacts where
now - last_touch_at > cadence_days(or never touched with cadence set), sorted most-overdue-first, each with "Log touch" (posts atouchactivity, which advances last_touch) and click-through to contact panel. "Sync email" button + last-sync stamp fromcrm.sync_state. - Verification: dev slot: set a contact cadence_days=1, backdate last_touch via update op, strip shows it; Log touch clears it
- Commit:
feat(sales-crm): follow-ups overdue strip + in-app email sync
Task 6: systemd units (written, not enabled)¶
- Files:
/home/justinwieb/forge/infra/systemd/forge-creatortrack-sales-gmail-sync.service,.timer(30 min, user scope), note in topic file - What: Curl-the-jobs-route units mirroring
forge-creatortrack-campaigns-sync.*; NOT installed/enabled until merge (prod token + prod URL at that point). - Verification:
systemd-analyze verifyon both unit files passes (paths only; not enabled) - Commit:
feat(sales-crm): gmail-sync systemd units (enable at merge)(forge repo)
Task 7: Register and document¶
- Files:
/home/justinwieb/forge/memory/general/reference_creatortrack_sales_crm.md(Phase B section), MEMORY.md index line update, this plan's outcome section - What: Document ingest architecture, token env var, timer enable step for merge day, what Phases C-D still owe. Run
bash /home/justinwieb/forge/scripts/forge_eval_run.sh. - Verification: eval run passes
- Commit:
docs(sales-crm): register Phase B in memory + topic file(forge repo)
Outcome (2026-07-06)¶
All 7 tasks complete, same session as approval. CreatorTrack branch feat/sales-crm gained 5 commits (007e9ab migration, f7fa56c ingest job, 9648d69 routes, 871bd38 timeline+body, 66fdbd3 follow-ups strip); forge repo gained the systemd units + trigger script (6b67207). Verified live on :3064 against real business Gmail: probe contact ([email protected]) matched 127 messages, 124 activities inserted, idempotent re-run inserted 0, on-demand body loads, 400/401/404 guards all tested, trigger-script dry-run 200. SALES_JOBS_TOKEN registered in ~/.forge-secrets/creatortrack-auth.env so merge day is just: merge, restart prod with the env, systemctl --user enable --now forge-creatortrack-sales-gmail-sync.timer. Deviations: none of substance; strip list got a 6-row max-height after visual QA. Probe contact ZZTest (id 22) left in the dev clone for click-through.