19 KiB
Email-Sequence Engine (Marketing-Funnel Phase 3)
Status: Phase 05 Task #80 — the sequence data model, pure engine foundation, admin UI/CLI, local E2E readiness seed fixtures, unsubscribe route, open/click tracking routes, worker bootstrap contracts, delayed-step enqueueing foundation, bundled template renderer, @toolctrl/mail Mailjet adapter, signed Mailjet bounce webhook handling, unit-tested stale-lead/trial-expiring cron runner functions, and live admin statistics views are in place. This is shipped foundation/admin-preview work, not an end-to-end production email-delivery claim for marketing sequences. The worker entrypoints are default-safe: MARKETING_SEQUENCES_ENABLED must be explicitly set to true before sequence subscribers do work; entrypoint tests prove the sequence workers and readiness probes stay disabled while the flag is unset. Keep it unset or false in production until production/demo seed rollout evidence, statistics aggregation cron, Playwright E2E coverage, delivery webhook production evidence, and production scheduled-job registration/deploy evidence are complete.
What ships
| Component | Package | Notes |
|---|---|---|
| Data model | prisma/schema/emailSequences.prisma |
EmailSequence, EmailSequenceRun, EmailSequenceStepDelivery |
| Engine | @toolctrl/email-sequences |
pure-logic — types, conditions, engine, step-runner |
| Template definitions | @toolctrl/email-templates |
welcome / trial-reminder / re-activation / generic-step renderer used by the worker runtime |
| Trigger worker contract | apps/worker/src/jobs/email-sequence-trigger.ts |
consumes matching outbox events, persists trigger context, and starts runs idempotently |
| Scheduler worker contract | apps/worker/src/jobs/email-sequence-scheduler.ts |
step-runner foundation; runtime enqueues idempotent delayed jobs and sends through @toolctrl/mail when Mailjet env is configured |
| Unsubscribe route | apps/web/app/routes/unsubscribe.$token.tsx |
RFC-8058 one-click (GET + POST) |
| Tracking — open | apps/web/app/routes/email/track-open.$deliveryId[.gif].tsx |
1×1 transparent GIF |
| Tracking — click | apps/web/app/routes/email/track-click.$deliveryId.tsx |
302-redirect + first-touch update |
| Provider bounce webhook | POST /v1/email-sequences/mailjet/webhooks |
Signed Mailjet ingress marks correlated sequence deliveries as bounced |
| Local readiness seed | scripts/seed-e2e-readiness.ts |
deterministic welcome / trial-expiring / stale-lead / customer-welcome definitions for local E2E fixtures |
This page claims active ownership of the email-sequence subscriber on
outbox.marketing.
It does not classify Email Sequences as the email-marketing CRM-contact
campaign plugin candidate from
docs/feature-decisions/email-marketing-decision-report.md or
docs/feature-manifests/plugin-candidates.json. Treat Email Sequences as
foundation/admin-preview work until the production follow-ups below land.
The generic marketing integration handler/adapters for Slack, Discord,
Mailchimp, HubSpot, Brevo, and custom webhooks are documented in
docs/features/marketing-funnel.md; as of this status reconciliation that
handler is pure foundation and the general subscriber is not bootstrapped from apps/worker/src/index.ts.
Architecture
The diagram below shows the sequence flow. The delayed-job producer, bundled template renderer, and @toolctrl/mail Mailjet adapter exist, but missing Mailjet configuration still fails closed and the remaining production follow-ups below must be completed before this path should be treated as approved production delivery.
┌──────────────────────────────┐
lead.captured / tenant.signup outbox event
│
▼
┌──────────────────────────────┐
│ email-sequence-trigger │ apps/worker/src/jobs
│ - lookup matching seqs │
│ - createRun (idempotent) │
│ - records pending step │
└──────────────┬───────────────┘
│ delayed enqueue
▼
┌──────────────────────────────┐
│ email-sequence-scheduler │
│ - runStep │
│ ├── conditions │
│ ├── renderTemplate │ bundled sequence templates
│ ├── sendMail │ @toolctrl/mail Mailjet adapter
│ ├── markDelivery │
│ └── advanceSequenceRun │
└──────────────────────────────┘
Trigger events (per spec)
lead.captured—marketing.lead.capturedoutbox prefix is normalisedlead.qualifiedlead.converted_to_trial/lead.converted_to_customerlead.stale_60d— emitted by the stale-lead cron runner once scheduled in the worker runtimetenant.signuptenant.trial_expiringtenant.cancelledcustom— operator-defined
Step-condition DSL
{
"path": "lead.status",
"op": "eq | neq | in | not_in | exists | not_exists",
"value": "qualified", // string | number | boolean | array (for in / not_in)
}
Per-step conditions are ANDed; sequence-level conditions gate the whole sequence at trigger-time.
Unsubscribe + List-Unsubscribe
When production delivery is wired, every sequence email must inject:
List-Unsubscribe: <https://app.example.com/unsubscribe/<token>>, <mailto:unsubscribe+<token>@example.com>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
The token is u1.<base64url(payload)>.<base64url(hmac-sha256)> signed with MARKETING_TOKEN_SECRET (falls back to LEAD_CONVERSION_SECRET). Tokens never expire — secret rotation is the kill-switch.
Open / click tracking
- Open: HTML
<img src="https://app.example.com/email/track-open/<deliveryId>.gif">— setsopenedAton first fetch. - Click: Every anchor URL is rewritten to
https://app.example.com/email/track-click/<deliveryId>?u=<encoded>— setsclickedAton first hit, 302s to the original target. Onlyhttp:/https:schemes accepted (nojavascript:,data:etc.).
Caveats
- Apple Mail Privacy Protection pre-fetches images → false-positive opens.
- Image-blocking mail clients (Outlook default) suppress the pixel → false-negative opens.
- Click-tracking introduces an extra hop — bots and security scanners may pre-resolve URLs, inflating clicks.
The admin stats dashboard documents these as approximations. It currently computes live per-sequence statistics from delivery rows; a persisted aggregation cron remains a follow-up for larger datasets.
Provider bounce webhooks
POST /v1/email-sequences/mailjet/webhooks accepts signed Mailjet provider events using MAILJET_WEBHOOK_SECRET. Bounce and rejection events correlate against EmailSequenceStepDelivery.providerMessageId, set status=bounced, record bouncedAt / errorMessage, and fail the related EmailSequenceRun. Unknown provider message ids and non-bounce events return 202 without echoing provider payloads to avoid retry storms.
Templates
Minimal React-Email-style template definitions exist. The worker runtime turns these definitions into a @toolctrl/mail send payload, but the remaining production follow-ups below still need to land before marketing sequences should be cited as approved production mail end to end.
| Template | Trigger example | Variables |
|---|---|---|
WelcomeEmail |
lead.captured day-0 |
recipientName, productName, ctaUrl, ctaLabel?, unsubscribeUrl, senderName? |
TrialReminderEmail |
tenant.trial_expiring |
recipientName?, productName, daysRemaining, upgradeUrl, unsubscribeUrl |
ReActivationEmail |
lead.stale_60d |
recipientName?, productName, returnUrl, highlight?, unsubscribeUrl |
GenericStepEmail |
onboarding-drip / operator-defined | headline, bodyParagraphs[], ctaUrl?, ctaLabel?, unsubscribeUrl |
The full React-Email migration tracked under #75 is no longer the blocker here; the worker runtime renders the bundled sequence templates and sends through @toolctrl/mail when MAILJET_API_KEY and MAILJET_FROM_EMAIL are configured together. If either key is absent, step delivery fails closed with email_sequence_mail_sender_unavailable.
Admin UI
/admin/email-sequences (permission admin.marketing.leads.view):
- List view —
index.tsx. Name, trigger event, enabled badge, step count, per-status run counts (active / completed / unsubscribed) aggregated fromEmailSequenceRun.groupByagainstdbRead. - Detail / edit —
$id.tsx. Editname,description,triggerEvent,stepsJson(validated byparseSequenceStepsbefore save),conditionsJson. Inline toggle forisEnabled. - Create —
new.tsx. Same form pre-seeded with a single-step template; new sequences are created withisEnabled=falseso the operator can review the JSON before turning the trigger on. - Stats —
$id/stats.tsx. Per-stepscheduled/sent/opened/clicked/bounced/skippedcounters with computed open-rate and click-rate (denominator = sent count, "—" when sent=0). Sequence-level enrollment + completion + unsubscribe + failure totals.
No drag-drop step builder — operators paste step + condition JSON directly into the form.
CLI
toolctrl-cli sequences <subcommand> exposes operator commands:
| Subcommand | Purpose | --json |
|---|---|---|
list |
Print every sequence (id, name, trigger, on/off, step count) | yes |
enable <id> |
Flip isEnabled to true |
yes |
disable <id> |
Flip isEnabled to false |
yes |
start-run <id> --leadId / --tenantId |
Manually enrol a target (skips outbox trigger) | yes |
stats |
Per-sequence active / completed / unsubscribed / failed counts | yes |
All commands respect MARKETING_SEQUENCES_ENABLED=false indirectly where the worker contracts are wired — when false, worker-mail does not subscribe to the sequence trigger/scheduler queues, so outbox or BullMQ work can accumulate and replay after re-enable. The CLI itself never refuses to inspect DB state. In production the recommended value remains false until the remaining production follow-ups land and the Mailjet env pair is configured; otherwise runs, delivery rows, and delayed jobs can be created without an operator-approved delivery path.
Cron triggers
Two unit-tested cron runner functions exist for producing outbox events that flow into the sequence trigger handler. Production daily scheduler registration is still follow-up unless the deployment runbook for that environment proves it.
apps/worker/src/jobs/lead-stale-trigger.ts— findsLeadrows withfirstSeenAt < now - 60d AND status IN (new, contacted)and emitslead.stale_60d.apps/worker/src/jobs/trial-expiring-trigger.ts— finds tenant subscriptions whose trial ends within 3 days and emitstenant.trial_expiringwithdaysRemainingin the payload.
Both use date-keyed dispatch keys (lead.stale_60d.<id>.<YYYY-MM-DD> /
tenant.trial_expiring.<id>.<YYYY-MM-DD>) so re-runs on the same day are idempotent. The next code blocker for production scheduler registration is the trial source contract: runTrialExpiringTriggerCron needs a canonical trialEndsAt, but the current subscription schema does not expose one directly. Treat deploy-specific cron evidence, alerting, and production schedule verification as remaining operator/runtime follow-up work unless the deployment runbook for that environment proves otherwise.
Env
| Var | Default | Effect |
|---|---|---|
MARKETING_TOKEN_SECRET |
(falls back to LEAD_CONVERSION_SECRET) |
HMAC key for unsubscribe tokens |
MARKETING_SEQUENCES_ENABLED |
unset / false in production examples |
Opt-in switch — trigger, scheduler, and sequence cron producers only do work when this is exactly true. Keep this unset or false in production until the remaining production follow-ups are approved. |
MAILJET_API_KEY |
unset | Required with MAILJET_FROM_EMAIL for sequence step delivery through @toolctrl/mail; missing configuration fails closed. |
MAILJET_FROM_EMAIL |
unset | Required with MAILJET_API_KEY for sequence step delivery through @toolctrl/mail; missing configuration fails closed. |
MAILJET_FROM_NAME |
unset | Optional display name passed to @toolctrl/mail. |
MAILJET_WEBHOOK_SECRET |
unset | Required on the API runtime for signed sequence bounce webhook ingestion at /v1/email-sequences/mailjet/webhooks. |
Troubleshooting
- Unsubscribe link 404s → check
MARKETING_TOKEN_SECRETmatches between mail send time and verify time. - Sequence does not start → confirm outbox dispatcher is running +
EmailSequence.isEnabled = true+triggerEventmatches the (normalised) outbox event-type. - Steps stuck pending → check the scheduler worker logs, the
email-sequence.stepqueue depth, Redis connectivity, and idempotent job ids (email-sequence-step:<runId>:<stepIndex>). - Open-rate looks impossible (>100%) → Apple Mail Privacy Protection pre-fetch; combine with click-rate for a more reliable engagement signal.
- Hard bounce → run transitions to
failedand no further steps fire. The Lead row'sunsubscribedAtis NOT touched (Phase 1 lead-model owns that semantic; bounce is per-run only). - Sequence isn't sending — use this quick diagnostic checklist:
toolctrl-cli sequences list --json→ confirmisEnabled=trueon the matching row.triggerEventmatches the normalised outbox event-type. Outbox emitsmarketing.lead.capturedbut sequences storelead.captured— the trigger handler normalises both directions. Manually configured sequences with amarketing.-prefixed trigger will never match.- The permanent
worker-mailservice is running withWORKER_QUEUE_GROUP=mail; the bootstrap helper short-circuits on other groups. MAILJET_API_KEYandMAILJET_FROM_EMAILare both configured onworker-mail. If either is missing, step delivery fails closed withemail_sequence_mail_sender_unavailable.outbox.marketingBullMQ queue depth > 0 means the dispatcher has already handed sequence trigger work to Redis; checkworker-mail,MARKETING_SEQUENCES_ENABLED, and sequence subscriber errors next. Pendingmarketing.*rows in the database outbox instead mean the dispatcher still needs to be running on thegeneralgroup.
- Trial-expiring cron emits nothing — verify the subscription store actually has rows with a
trialEndsAtinside the horizon (default 3 days). Bare Stripe-managed trials may not have a cachedtrialEndsAtcolumn; the cron skips them silently. - Stale-lead cron over-eager — bump
MARKETING_STALE_LEAD_DAYS(or pass a non-defaultthresholdDaysto the cron when wiring) and re-run.
Status
Detail spec: docs/improvement-plan/05-business-features/marketing-funnel-phase-3.md
Open follow-ups:
- Promote the four local readiness sequence definitions into deployable demo/production seed data, with environment-specific owner approval.
- Add a statistics aggregation cron and have the dashboard read persisted aggregates instead of live groupings.
- Add provider delivery/open/click webhook reconciliation beyond the in-repo open/click tracking routes if operators choose Mailjet-native engagement events.
- Add Playwright E2E coverage for sequence create/edit, enrollment visibility, tracking, and unsubscribe flows.
- Wire/verify stale-lead/trial-expiring scheduled-job registration after choosing the canonical trial-end data source, then add deployment evidence, alerting, and runbook verification.