Skip to content

Honor the first email delay in sequences - #168

Open
clbotdev wants to merge 3 commits into
mainfrom
t3/sequence-send-count-mismatch
Open

clbotdev wants to merge 3 commits into
mainfrom
t3/sequence-send-count-mismatch

Conversation

@clbotdev

@clbotdev clbotdev commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A sequence whose only email was set to 13 days showed "Sent 3" the day after it was activated. Enrollment set the first send to "now", and the first email's delay was never read: only follow-up emails waited their delay.

Fix (e1e09a1)

  • When a trigger enrolls a contact in a sequence, the first published email is scheduled at entry time plus its delay. Broadcasts are unchanged, because their first delay is their send time.
  • New sequences start with a first-email delay of 0, so existing setups behave as before. A sequence whose first email has a delay (edited, or reordered so a 1-day email is first) now waits that long.
  • Docs and the MCP delayInMillis description say where delays count from.

Guardrails (d5948e5)

  • Multi-day journey test: enroll, poll and send under a faked clock (setSystemTime). It fails if the fix is reverted.
  • Report-only early-send check: before a sequence email goes out, verify that entry time plus the delays so far has passed. If not, log a warning and send sequence_email_sent_early (sequence_id, email_id, ongoing_sequence_id, early_by_ms) to PostHog. The email still goes out. Skipped when an email involved changed after the contact entered.

Visibility (b45c23d)

  • Sequence stats gain Waiting. Each email row shows "N waiting", so a delayed email reads as waiting, not sent.
  • Clicking the count opens a dialog listing the waiting contacts with their own due times, soonest first, with search by email or name and paging. Each contact's delay counts from their own entry, so the list doesn't show one shared date.
  • API: GET /sequences/:sequenceId/schedule and GET /sequences/:sequenceId/emails/:emailId/waiting (q, after, limit; a cursor that can't be read returns 400 invalid_cursor).
  • MCP: get_sequence_schedule, list_sequence_email_waiting_contacts.
  • SequenceEmailList gains renderEmailDetail.

Migration

0030_waiting_contacts_index adds ongoing_sequences (sequence_id, next_email_scheduled_time, id). Without it, a sequence's list walks every other sequence's rows in the global due-time index.

100,005 waiting, 400,000 in another sequence Before After
First page, worst case 427 ms 0.4 ms
Last page 184 ms 0.2 ms

It's a plain CREATE INDEX, because drizzle runs migrations in a transaction, and it blocks writes to ongoing_sequences while it builds. That took about 1 s for 500k rows. Check the production row count before deploying. If it's in the millions, build the index by hand with CONCURRENTLY first.

Testing

  • bun test --parallel src (api): 959 of 960 pass. The one failure is the pacing test "(Scope new-account pacing to the risk: don't throttle a customer's own ESP by account age #122)", which times out under parallel load and does the same on main.
  • bun run test (web): 110 pass.
  • Typecheck, lint, test:conformance and check:drift pass.
  • E2E smoke against a local stack with Mailpit:
    • A 2.5-minute first-email delay arrived at +165 s, and a 0-delay email at +54 s.
    • Browser check of the Waiting stat and the dialog with 5 contacts and then 100,005: due order, search, and next and previous paging.

Follow-up

  • Add a PostHog alert on sequence_email_sent_early.

- Schedule sequence enrollment from the first published email’s delay
- Clarify sequence and broadcast delay semantics in MCP and docs
Adds a multi-day journey test that drives enroll -> poll -> send with a
faked clock, so a first email that ignores its delay fails a test.

Before sending a sequence email, check that the contact's entry time
plus the delays so far has passed; if not, log a warning and send
`sequence_email_sent_early` to PostHog. Report only: the email still
goes out. Skipped when an email involved changed after the contact
entered. Enrollment now stamps created_at from the app clock the check
uses.
A sequence that has sent nothing can still have contacts waiting for a
delayed email, which "Sent" alone hid.

- Stats gain a Waiting figure; each email row shows "N waiting".
- Clicking the count opens a dialog listing the waiting contacts with
  their own due times, soonest first, with search by email or name and
  cursor paging.
- API: GET /sequences/:id/schedule and
  GET /sequences/:id/emails/:emailId/waiting (q, after, limit; 400
  invalid_cursor). MCP: get_sequence_schedule and
  list_sequence_email_waiting_contacts.
- Index ongoing_sequences (sequence_id, next_email_scheduled_time, id)
  so a sequence's list doesn't walk other sequences' rows (427 ms ->
  0.4 ms worst case at 100k waiting).
- SequenceEmailList gains renderEmailDetail.
- Docs for sequences, MCP and the component.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants