OdontoX Onboarding Email Campaign — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Build a behavioral onboarding email drip (“Letters from Sarmad”) that sends one personal, professionally-designed email per day to trialing and newly-paid OdontoX clinics, mapped to activation signals already in the DB, with every CTA linking to a q.odontox.io help article and a hard 7-day shadow-allowlist gate before production rollout.
Architecture: A daily Cloudflare Workers cron (09:00 PKT) queries eligible clinics, computes activation signals per clinic, picks the highest-priority unsent lesson whose trigger fires, renders a React Email template, sends via ZeptoMail, and logs to a new clinic_campaign_log table with a UNIQUE(clinic_id, campaign_key) dedupe. Tracking is via 1×1 pixel, 302-redirect click wrapper, and ZeptoMail inbound webhook for reply detection. A shadow-allowlist env var restricts sends to two test inboxes during the 7-day soak.
Tech Stack: TypeScript, Hono, Drizzle ORM (Neon Postgres), React Email + ZeptoMail, Cloudflare Workers (cron + KV), Vitest.
Spec: docs/superpowers/specs/2026-05-15-onboarding-email-campaign-design.md
File structure
New files
| Path | Responsibility |
|---|---|
server/src/schema/campaign-log.ts | Drizzle schema for clinic_campaign_log table |
server/src/campaigns/types.ts | Lesson, Phase, Signals type definitions |
server/src/campaigns/signals.ts | getActivationSignals(clinicId) batched query |
server/src/campaigns/triggers.ts | All 17 lesson trigger functions, pure & unit-testable |
server/src/campaigns/lessons.ts | Lesson registry array (key, priority, phase, trigger, articleUrl, templateFn) |
server/src/campaigns/runner.ts | Cron handler: eligibility → signals → picker → guardrails → send → log |
server/src/campaigns/guardrails.ts | Pure guardrail functions (day-of-week, active-session, transactional-overlap, allowlist) |
server/src/emails/campaign/CampaignEmail.tsx | Shared React Email layout (header logo, footer logo, accent color, P.S. styling) |
server/src/emails/campaign/welcome.tsx | Email #1 — Welcome to OdontoX |
server/src/emails/campaign/firstPatientAdded.tsx | Email #2 |
server/src/emails/campaign/firstAppointment.tsx | Email #3 |
server/src/emails/campaign/whatsappOff.tsx | Email #4 |
server/src/emails/campaign/dentalCharting.tsx | Email #5 |
server/src/emails/campaign/clinicalNotes.tsx | Email #6 |
server/src/emails/campaign/inviteStaff.tsx | Email #7 |
server/src/emails/campaign/mobileApp.tsx | Email #8 |
server/src/emails/campaign/firstInvoice.tsx | Email #9 |
server/src/emails/campaign/firstPrescription.tsx | Email #10 |
server/src/emails/campaign/trialThankYou.tsx | Email #11 |
server/src/emails/campaign/paidWelcome.tsx | Email #12 |
server/src/emails/campaign/labTracking.tsx | Email #13 |
server/src/emails/campaign/insuranceClaims.tsx | Email #14 |
server/src/emails/campaign/aiInsights.tsx | Email #15 (uses ruby.png header) |
server/src/emails/campaign/scaleMilestone.tsx | Email #16 |
server/src/emails/campaign/ipdModule.tsx | Email #17 |
server/src/routes/email-tracking.ts | GET /api/email/pixel, GET /api/email/click, POST /api/email/inbound |
server/src/routes/superadmin/campaign.ts | Per-clinic toggle + analytics endpoints |
server/scripts/seed-shadow-test-clinics.ts | Seed two test clinics for shadow week |
ui/src/pages/superadmin/clinics/[id]/CampaignSection.tsx | Per-clinic toggle UI block |
ui/src/pages/superadmin/campaigns/index.tsx | Campaign funnel + lift dashboard |
server/src/campaigns/__tests__/signals.test.ts | Tests for getActivationSignals |
server/src/campaigns/__tests__/triggers.test.ts | Tests for all 17 trigger functions |
server/src/campaigns/__tests__/guardrails.test.ts | Tests for guardrails |
server/src/campaigns/__tests__/runner.test.ts | Integration test for runner |
server/src/emails/campaign/__tests__/templates.test.tsx | Smoke test all 17 templates render |
Modified files
| Path | Change |
|---|---|
server/src/schema/clinics.ts | Add 5 columns (marketing_campaign_enabled, marketing_unsubscribed, marketing_campaign_disabled_by/_at/_reason). Add activated_at if absent. |
server/src/schema/users.ts (or wherever users schema lives) | Add email_marketing_opt_out boolean column |
server/src/scheduled.ts | Wire runOnboardingCampaign() into existing cron alongside trial-expiry job |
server/src/server.ts (or wherever routes mount) | Mount email-tracking + superadmin/campaign route groups |
ui/src/pages/superadmin/clinics/[id]/index.tsx | Mount <CampaignSection /> |
Task 1 — Drizzle schema: clinics + users columns + clinic_campaign_log table
Files:-
Modify:
server/src/schema/clinics.ts -
Modify:
server/src/schema/users.ts(locate first — see step 1) -
Create:
server/src/schema/campaign-log.ts -
Test:
server/src/campaigns/__tests__/schema.test.ts - Step 1: Locate the users schema
grep -rn "export const users" server/src/schema/ | head -3
Expected: a single file (likely server/src/schema/users.ts or server/src/schema/auth.ts). Note the exact path for step 3.
- Step 2: Add columns to
clinicsschema
server/src/schema/clinics.ts. Add inside the pgTable("clinics", { ... }) definition (alongside existing columns):
activated_at already exists on the table, omit it. Verify with grep "activated_at" server/src/schema/clinics.ts before adding.
- Step 3: Add column to
usersschema
- Step 4: Create
clinic_campaign_logschema
server/src/schema/campaign-log.ts:
- Step 5: Re-export from schema index
server/src/schema/index.ts that re-exports schemas, add: export * from './campaign-log';. Verify with cat server/src/schema/index.ts 2>/dev/null | head -20.
- Step 6: Push the migration
cd server && npm run db:push
Expected: drizzle-kit shows the new columns and table, asks for confirmation, applies to the dev Neon DB. Confirm yes on each prompt.
- Step 7: Write a schema smoke test
server/src/campaigns/__tests__/schema.test.ts:
- Step 8: Run the test
cd server && npx vitest run src/campaigns/__tests__/schema.test.ts
Expected: 4 passing.
- Step 9: Commit
Task 2 — Lesson types & signal shape
Files:-
Create:
server/src/campaigns/types.ts - Step 1: Define the shared types
server/src/campaigns/types.ts:
- Step 2: Verify types compile
cd server && npx tsc --noEmit
Expected: no errors related to campaigns/types.ts.
- Step 3: Commit
Task 3 — getActivationSignals(clinicId) helper
Files:
-
Create:
server/src/campaigns/signals.ts -
Test:
server/src/campaigns/__tests__/signals.test.ts - Step 1: Write the failing test
server/src/campaigns/__tests__/signals.test.ts:
db test fixture pattern, the implementing agent should locate the project’s preferred test-DB approach — likely either a transactional test wrapper or a dedicated test DB in vitest.config.ts. Match what existing tests under server/src/ do.)
- Step 2: Run the test to verify it fails
cd server && npx vitest run src/campaigns/__tests__/signals.test.ts
Expected: FAIL — getActivationSignals not defined.
- Step 3: Implement
getActivationSignals
server/src/campaigns/signals.ts:
dental_chart_entries, mobile_sessions, bulk_operations, app_activity, ai_report_views) are best-guesses from the spec survey — if a referenced table doesn’t exist, substitute 0 or false and leave a one-line comment indicating where the data will come from once that subsystem ships.
- Step 4: Run the test, fix issues, re-run until passing
cd server && npx vitest run src/campaigns/__tests__/signals.test.ts
Expected: 3 passing.
- Step 5: Commit
Task 4 — Lesson trigger functions (all 17)
Files:-
Create:
server/src/campaigns/triggers.ts -
Test:
server/src/campaigns/__tests__/triggers.test.ts - Step 1: Write failing tests
server/src/campaigns/__tests__/triggers.test.ts:
- Step 2: Run the test and verify it fails
cd server && npx vitest run src/campaigns/__tests__/triggers.test.ts
Expected: FAIL — module not found.
- Step 3: Implement the triggers
server/src/campaigns/triggers.ts:
- Step 4: Run the test and verify all pass
cd server && npx vitest run src/campaigns/__tests__/triggers.test.ts
Expected: 17 passing.
- Step 5: Commit
Task 5 — Shared React Email layout (CampaignEmail component)
Files:
-
Create:
server/src/emails/campaign/CampaignEmail.tsx -
Test:
server/src/emails/campaign/__tests__/CampaignEmail.test.tsx - Step 1: Locate the production CDN base URL for static assets
grep -rn "go.odontox.io\|odontox.io\|PUBLIC_URL\|ASSETS_URL" server/src/lib/email.ts | head -5
Expected: an existing convention for how email templates reference static assets. Use the same convention.
Assume the canonical asset base is https://go.odontox.io — if the codebase reveals a different host (e.g. https://app.odontox.io or a CDN), substitute it in all references below.
- Step 2: Write the failing test
server/src/emails/campaign/__tests__/CampaignEmail.test.tsx:
- Step 3: Run it to verify failure
cd server && npx vitest run src/emails/campaign/__tests__/CampaignEmail.test.tsx
Expected: FAIL — module not found.
- Step 4: Implement the layout
server/src/emails/campaign/CampaignEmail.tsx:
- Step 5: Run the test, verify it passes
cd server && npx vitest run src/emails/campaign/__tests__/CampaignEmail.test.tsx
Expected: 2 passing.
- Step 6: Commit
Task 6 — All 17 campaign email templates
Each template is a thin wrapper aroundCampaignEmail with subject, body, CTA, and P.S. from the spec.
Files:
-
Create:
server/src/emails/campaign/<name>.tsx× 17 -
Test:
server/src/emails/campaign/__tests__/templates.test.tsx - Step 1: Write the smoke test
server/src/emails/campaign/__tests__/templates.test.tsx:
- Step 2: Run and verify failure
cd server && npx vitest run src/emails/campaign/__tests__/templates.test.tsx
Expected: FAIL — templates index not found.
- Step 3: Create the 17 template files
docs/superpowers/specs/2026-05-15-onboarding-email-campaign-design.md lines 130–340 — paste the body lines verbatim, replacing the variables.
server/src/emails/campaign/welcome.tsx:
server/src/emails/campaign/firstPatientAdded.tsx:
server/src/emails/campaign/firstAppointment.tsx:
server/src/emails/campaign/whatsappOff.tsx:
server/src/emails/campaign/dentalCharting.tsx:
server/src/emails/campaign/clinicalNotes.tsx:
server/src/emails/campaign/inviteStaff.tsx:
server/src/emails/campaign/mobileApp.tsx:
server/src/emails/campaign/firstInvoice.tsx:
server/src/emails/campaign/firstPrescription.tsx:
server/src/emails/campaign/trialThankYou.tsx:
server/src/emails/campaign/paidWelcome.tsx:
server/src/emails/campaign/labTracking.tsx:
server/src/emails/campaign/insuranceClaims.tsx:
server/src/emails/campaign/aiInsights.tsx:
server/src/emails/campaign/scaleMilestone.tsx:
server/src/emails/campaign/ipdModule.tsx:
- Step 4: Create the index barrel
server/src/emails/campaign/index.ts:
- Step 5: Run the templates test
cd server && npx vitest run src/emails/campaign/__tests__/templates.test.tsx
Expected: 18 passing (17 templates + the ruby.png assertion).
- Step 6: Commit
Task 7 — Lesson registry
Files:-
Create:
server/src/campaigns/lessons.ts -
Test:
server/src/campaigns/__tests__/lessons.test.ts - Step 1: Write the registry test
server/src/campaigns/__tests__/lessons.test.ts:
- Step 2: Verify it fails
cd server && npx vitest run src/campaigns/__tests__/lessons.test.ts
Expected: FAIL.
- Step 3: Implement the registry
server/src/campaigns/lessons.ts:
- Step 4: Run, verify passing
cd server && npx vitest run src/campaigns/__tests__/lessons.test.ts
Expected: 5 passing.
- Step 5: Commit
Task 8 — Guardrails (day-of-week, active-session, transactional-overlap, allowlist)
Files:-
Create:
server/src/campaigns/guardrails.ts -
Test:
server/src/campaigns/__tests__/guardrails.test.ts - Step 1: Write the failing tests
server/src/campaigns/__tests__/guardrails.test.ts:
- Step 2: Verify failure
cd server && npx vitest run src/campaigns/__tests__/guardrails.test.ts
Expected: FAIL.
- Step 3: Implement guardrails
server/src/campaigns/guardrails.ts:
server/src/scheduled.ts and server/src/lib/email.ts:sendTrialExpiringEmail and update transactionalEmailToday to match what the existing job actually does. The point is “no double-send on the same day”; the trigger conditions must mirror reality.)
- Step 4: Run tests, verify passing
cd server && npx vitest run src/campaigns/__tests__/guardrails.test.ts
Expected: 10 passing.
- Step 5: Commit
Task 9 — Campaign runner (eligibility → picker → send → log)
Files:-
Create:
server/src/campaigns/runner.ts -
Test:
server/src/campaigns/__tests__/runner.test.ts - Step 1: Write the integration test
server/src/campaigns/__tests__/runner.test.ts:
- Step 2: Verify it fails
cd server && npx vitest run src/campaigns/__tests__/runner.test.ts
Expected: FAIL — runner not defined.
- Step 3: Implement the runner
server/src/campaigns/runner.ts:
sendEmailViaZepto in server/src/lib/email.ts — adjust the call site if it takes a different argument shape. Returning a zepto_message_id is required for reply tracking.)
- Step 4: Run tests, verify passing
cd server && npx vitest run src/campaigns/__tests__/runner.test.ts
Expected: 4 passing.
- Step 5: Commit
Task 10 — Wire cron into scheduled.ts
Files:
-
Modify:
server/src/scheduled.ts - Step 1: Read the existing scheduled.ts
cat server/src/scheduled.ts | head -120
Note the cron pattern, env access pattern, logging pattern used by the existing trial-expiry job.
- Step 2: Add the campaign run alongside existing scheduled work
scheduled or default export), add:
- Step 3: Verify the cron schedule includes 09:00 PKT (04:00 UTC) trigger
cat server/wrangler.toml | grep -A 4 crons (path may be wrangler.jsonc; check both).
If the existing cron doesn’t fire at 04:00 UTC (= 09:00 PKT), add one:
event.cron and can branch by cron string if needed.
- Step 4: Type-check
cd server && npx tsc --noEmit
Expected: clean.
- Step 5: Commit
Task 11 — Email tracking endpoints (pixel + click)
Files:-
Create:
server/src/routes/email-tracking.ts -
Modify: wherever routes are mounted (probably
server/src/server.tsorserver/src/app.ts) -
Test:
server/src/routes/__tests__/email-tracking.test.ts - Step 1: Locate the route mount point
grep -rn "app.route\|app.mount\|new Hono" server/src/ | head -10
Note the file and pattern used to mount sub-routers.
- Step 2: Write the tests
server/src/routes/__tests__/email-tracking.test.ts:
- Step 3: Verify failure
cd server && npx vitest run src/routes/__tests__/email-tracking.test.ts
Expected: FAIL.
- Step 4: Implement the routes
server/src/routes/email-tracking.ts:
- Step 5: Mount the routes
- Step 6: Wire the tracking into the email send
welcome email currently CTAs straight to https://q.odontox.io/.... Now wrap CTAs through the click endpoint. In server/src/emails/campaign/CampaignEmail.tsx, modify the CTA <Link> to wrap through the tracker. Since the wrapper needs a logId, the wrapping happens in runner.ts after render. But we cannot know the log id before the insert. Two options:
(a) Generate the log.id (UUID) on the server before render, pass into the template props, do INSERT ... VALUES (id, ...) with the pre-generated id.
(b) Skip click-wrapping for now and use raw q.odontox.io URLs; click tracking lands in a later phase.
Go with (a). Update runner.ts step 3 of Task 9:
Replace the render+send block with:
- Extend
TemplatePropswith optionaltrackedCtaHref?: stringandpixelUrl?: string. - Update each of the 17 templates to pass
trackedCtaHref ?? articleUrlandpixelUrlto CampaignEmail. - Update CampaignEmail to render a 1×1
<Img>at the bottom whenpixelUrlis set. - Update runner to pre-generate the log id, render with tracked URLs, insert log with the same id.
- Re-run all template tests — they should still pass because they don’t assert tracking URL absence.
- Step 7: Run all campaign-related tests
cd server && npx vitest run src/campaigns src/emails/campaign src/routes/__tests__/email-tracking.test.ts
Expected: all passing.
- Step 8: Commit
Task 12 — ZeptoMail inbound webhook for reply tracking
Files:-
Create:
server/src/routes/email-inbound.ts - Modify: route mount file
-
Test:
server/src/routes/__tests__/email-inbound.test.ts - Step 1: Read ZeptoMail inbound webhook docs / existing handlers
grep -rn "zepto\|inbound" server/src/ | head -20
Identify whether any inbound handler exists and the payload shape ZeptoMail sends. If unknown, structure the handler around the standard ZeptoMail “inbound parse” JSON: { headers: { 'in-reply-to': '<msg-id>' }, from: '...', to: '...', text: '...', html: '...' }.
- Step 2: Write the test
server/src/routes/__tests__/email-inbound.test.ts:
- Step 3: Implement
server/src/routes/email-inbound.ts:
- Step 4: Mount + verify
app.route('/api/email', emailInboundRoutes);. If conflict with emailTrackingRoutes mount path, combine them: extend emailTrackingRoutes to include the inbound route, or mount each at a distinct subpath.
Run: cd server && npx vitest run src/routes/__tests__/email-inbound.test.ts
Expected: 2 passing.
- Step 5: Configure ZeptoMail webhook
https://go.odontox.io/api/email/inbound. Document the configuration step in server/scripts/README.md or similar.
- Step 6: Commit
Task 13 — Superadmin per-clinic toggle (backend + UI)
Files:-
Create:
server/src/routes/superadmin/campaign.ts -
Create:
ui/src/pages/superadmin/clinics/[id]/CampaignSection.tsx - Modify: existing superadmin clinic detail page
- Step 1: Locate the existing superadmin routes
find server/src/routes/superadmin -type f 2>/dev/null; grep -rn "superadmin" server/src/server.ts | head -5
- Step 2: Backend — toggle + log endpoints
server/src/routes/superadmin/campaign.ts:
app.route('/api/superadmin', superadminCampaignRoutes);
- Step 3: UI component
ui/src/pages/superadmin/clinics/[id]/CampaignSection.tsx:
- Step 4: Mount in the clinic detail page
ui/src/pages/superadmin/clinics/[id]/index.tsx). At the bottom of the page, add:
- Step 5: Type check + manual smoke test
cd server && npx tsc --noEmit && cd ../ui && npx tsc --noEmit
Expected: clean.
Manually start dev server, open superadmin clinic page, toggle on/off, confirm reason modal works and audit fields populate.
- Step 6: Commit
Task 14 — Superadmin analytics dashboard
Files:-
Modify:
server/src/routes/superadmin/campaign.ts(add analytics endpoint) -
Create:
ui/src/pages/superadmin/campaigns/index.tsx - Step 1: Add analytics endpoint
server/src/routes/superadmin/campaign.ts:
- Step 2: Build the dashboard page
ui/src/pages/superadmin/campaigns/index.tsx:
- Step 3: Add route to superadmin nav
/superadmin/campaigns.
- Step 4: Type check + manual verify
cd server && npx tsc --noEmit && cd ../ui && npx tsc --noEmit. Visit /superadmin/campaigns in dev to confirm the page renders.
- Step 5: Commit
Task 15 — Shadow-week setup: env var + test clinic seed
Files:-
Modify:
server/wrangler.toml(orwrangler.jsonc) — document the env var -
Create:
server/scripts/seed-shadow-test-clinics.ts -
Modify:
server/src/scheduled.ts— wireenv.CAMPAIGN_ALLOWLIST_EMAILSthrough to the runner (already done in Task 10 step 2; double-check) - Step 1: Document the env var
wrangler.toml, add a commented-out reference so the team knows about the secret:
- Step 2: Set the Cloudflare secret
- Step 3: Write the test clinic seed script
server/scripts/seed-shadow-test-clinics.ts:
server/scripts/cleanup-shadow-test-clinics.ts:
server/package.json:
- Step 4: Manually run the seed
- Step 5: Manual end-to-end dry-run
sent array contains the two shadow clinics. Check both inboxes for the welcome email within 60 seconds.
- Step 6: Commit
Task 16 — Shadow-week verification checklist & sign-off
This is a manual gate, not a code task. Before deploying to production, work through the checklist indocs/superpowers/specs/2026-05-15-onboarding-email-campaign-design.md section 10.
- Deploy current branch to staging.
-
Set
CAMPAIGN_ALLOWLIST_EMAILSsecret on staging (wrangler secret put CAMPAIGN_ALLOWLIST_EMAILS). - Seed shadow test clinics.
- Trigger the cron manually once per day for 7 days (or use Cloudflare’s “trigger scheduled” dashboard button).
- Walk through every checkbox in spec section 10 (“Shadow week verification checklist”).
- Once all checkboxes pass, Sarmad signs off in writing (Slack/email is fine, capture in PR comments).
-
Promote to production: delete the
CAMPAIGN_ALLOWLIST_EMAILSsecret on production:
- Commit the sign-off record (optional):
Self-review
Spec coverage- §2 Audience & gating → Task 9 (runner eligibility query). ✅
- §3 Voice & design → Task 5 (
CampaignEmaillayout) + Task 6 (templates). ✅ - §4 Lesson library (17 lessons) → Task 4 (triggers) + Task 6 (templates) + Task 7 (registry). ✅
- §5 Sending machine (cron, picker, guardrails) → Tasks 8, 9, 10. ✅
- §6 Data model → Task 1. ✅
- §7 Superadmin toggle → Task 13. ✅
- §8 Tracking → Tasks 11, 12. ✅
- §9 Build order → reflected in the 16-task sequence. ✅
- §10 Shadow-week gate → Tasks 15, 16. ✅
- §11 Out of scope → no implementation needed. ✅
- “Implementing agent must verify” notes appear in Tasks 3, 8, 9, 11, 12 — these are explicit verification steps, not vague placeholders. The exact action is named and the consequence of getting it wrong is clear. Acceptable.
- No “TBD”, “implement later”, or “similar to Task N” without code.
Lesson,Phase,Signals,ClinicCtx,TemplateProps,AdminRecipient,LessonKeydefined once intypes.ts(Task 2), referenced consistently throughout.- Template function names match registry keys: each
<key>template is named after the lesson key in camelCase (e.g.welcome,firstPatientAdded,aiInsights). The registry in Task 7 uses these exact names. passesAllowlistsignature consistent between Task 8 (definition) and Task 9 (usage).
Execution handoff
Plan complete and committed atdocs/superpowers/plans/2026-05-15-onboarding-email-campaign.md. Two execution options:
- Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration. Uses
superpowers:subagent-driven-development. - Inline execution — execute tasks in the current session with checkpoints. Uses
superpowers:executing-plans.

