Skip to content

Honor the first email delay in sequences - #168

Merged
clbotdev merged 5 commits into
mainfrom
t3/sequence-send-count-mismatch
Oct 31, 2026
Merged

clbotdev merged 5 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: after a sequence's first email is sent, check that its delay had passed since the contact entered. If not, log a warning and send sequence_email_sent_early (sequence_id, email_id, ongoing_sequence_id, early_by_ms) to PostHog. Later emails aren't checked, because reordering or deleting emails legitimately changes their timing. Skipped when the email or the sequence changed after the contact entered, which also covers contacts enrolled before this fix.

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.

Review fixes (4e9c782)

  • The early-send check covers only the first email and runs after a successful send, so failed or retried sends and older enrollments don't raise false reports. Adding or deleting an email now updates the sequence's updated_at.
  • Contact search matches %, _ and \ literally, on the contacts page too.
  • Short delays read "30 minutes" or "2 hours", not "0.0 days".
  • "N waiting" counts refresh after a reorder, publish, add or delete.
  • Clicking Next right after opening the dialog no longer jumps back to page 1.
  • The MCP output schemas come from the REST contract.

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

Drizzle runs migrations in a transaction, so the build blocks writes to ongoing_sequences while it runs: about 1 s for 500k rows, and instant at today's size. The migration uses IF NOT EXISTS (0e1d31b), so if the table ever gets large enough to matter, a future index like this can be built 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): 111 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.
- Early-send check: only check a sequence's first email, after a
  successful send, and skip it when the email or sequence changed after
  the contact entered. Summing every delay since entry misfired for
  contacts enrolled before the delay fix and after reorders or deletes.
  Adding or deleting an email now bumps sequences.updated_at.
- Search treats %, _ and \ literally; contacts search shares the
  condition (contactSearchCondition).
- Dialog: delays read "30 minutes" / "2 hours" rather than "0.0 days";
  an early Next is no longer undone by the search debounce.
- Sequence page refetches the schedule when the sequence changes, so
  "N waiting" follows reorders, publishes and deletes.
- MCP output schemas come from @sendlit/api-contract; drop the
  getNextPublishedEmail re-export.
@clbotdev
clbotdev merged commit da2efcc into main Oct 31, 2026
5 checks passed
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