Repository navigation
Conversation
🤖 CI report
|
| File | Comment lines | Added lines |
|---|---|---|
nodejs/src/cdp/services/messaging/email-status.service.ts |
13 | 136 |
nodejs/src/cdp/services/messaging/email-recipient-hash.ts |
4 | 22 |
nodejs/src/cdp/services/messaging/helpers/ses.ts |
4 | 75 |
nodejs/src/cdp/config.ts |
2 | 7 |
nodejs/src/cdp/services/cyclotron-v2/janitor.ts |
2 | 74 |
nodejs/src/cdp/services/messaging/email-tracking.service.ts |
2 | 11 |
nodejs/src/cdp/services/messaging/email.service.ts |
2 | 47 |
nodejs/src/cdp/cdp-api.serial.test.ts |
1 | 50 |
This check does not block merging. It updates on every push and clears when the share drops.
Silthus
force-pushed
the
feat/email-status-record
branch
from
October 23, 2026 21:24
603871c to
2a7d3e2
Compare
Silthus
force-pushed
the
feat/email-status-table
branch
from
October 23, 2026 21:24
27c528f to
9d06e81
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EmailStatusService produces one email_status row per status change, keyed by the message id. The row version puts the status rank above the status time, and created_at comes from the message id, so every row of a message lands in one partition. emailRecipientHash is the keyed hash Django shares for the lookup by recipient. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers each place a transactional email status is written: queued by the send route, sent, failed and skipped by the email worker, delivered and bounced from the SES webhook, and unknown and gave_up from the janitor. Also covers that a failed status write never resends an email and that the SES webhook answers with a 5xx before counting anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transactional email route hashes the recipient, keeps the hash on the queued run and writes `queued` once the run is queued. The email worker writes `sent`, `failed` and `skipped`, and tags the SES message with the hash. The SES webhook reads that tag to write `delivered`, `bounced` and `complained`, and the janitor writes `unknown` and `failed` for stalled and abandoned email jobs. A run queued without the hash skips its status writes and is counted. A failed status write never fails or resends an email. In the SES webhook it answers with a 5xx so SNS retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers a transactional send while no salt key is configured: it still queues and answers 202, writes no status and is counted. Also covers a job queued before runs carried the recipient hash: the worker sends it once, writes no status and counts it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recipient hash needs the first ENCRYPTION_SALT_KEYS entry. Without one, the route now queues the send as before and skips only its status writes, counted under the missing recipient hash metric with the source `route`, instead of answering 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A transactional email that the email action skips (suppressed, opted out, invalid recipient, frequency cap) or whose workflow stopped before it ran now records a skipped status, so it no longer reads as queued forever. A rerun keeps the recipient hash so its status rows still get written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus
force-pushed
the
feat/email-status-table
branch
from
October 24, 2026 17:28
9d06e81 to
357d0d9
Compare
Silthus
force-pushed
the
feat/email-status-record
branch
from
October 24, 2026 17:28
2a7d3e2 to
3b04c61
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moved here from #121897, closed on 2026-10-25 so this fork stack is the only copy. It opens upstream again after review.
Problem
202: queued, sent, delivered, bounced or skipped. Contract #120921 promises a status read bymessage_id.Impact
Pays into Grow Workflows through billed
workflows_emailsactions per paying org (not measured yet as a monthly count). In scope: Messaging, email API sends. Expected move: small, up: a caller can follow an email to delivery, as other providers' APIs allow. Run cost: one Kafka produce per status change of a transactional email.Merge Danger
Warning
🟡 Merge risk: medium · two-way door: the new run field and SES tag are optional, so a revert leaves old jobs and tags harmless (merge-risk rubric)
5xx, so SNS retries.emailRecipientHash, a queued format. Readers on both sides tolerate its absence and its presence, below.workflows-transactional-email-apiflag. Rollout precondition: the flag stays off until tracking code v3 minting is on and every stack PR has deployed on Django, cdp-api, the hogflow worker and the email worker (#121272 has the reason).Changes
What each part writes
reasonqueuedsent,failed,skippedprovider_error, or the send check's code such astemplate_pauseddelivered,bounced,complained,failedhard,softorunknownfor a bounce; the feedback type for a complaint;rejectedfor an SES Reject5xxto SNS on failureunknownwhen it resets a stalled email job,failedwhen it gives up on onegave_upskippedrecipient_suppressed,recipient_opted_out,invalid_recipientorfrequency_cappedskippedworkflow_inactive,run_canceled,workflow_missingHow it works
EmailStatusService(email-status.service.ts) implementsEmailStatusWriterwithrecord(awaited) andrecordInBackground. It produces toclickhouse_email_status, keyed by the message id.queued,unknown,skipped,sent,failed,delivered,bounced,complained.failedranks abovesentbecause SES can reject an email it accepted.failedwithprovider_errororgave_up) ranks withskipped. A rerun keeps the message id, so its outcome replaces the attempt it reruns: between a skip and a failed attempt the later one wins, andsentreplaces both.created_atcomes from its time, so every row of a message carries the samecreated_at.toonce (emailRecipientHash: HMAC-SHA256 with a key derived from the firstENCRYPTION_SALT_KEYSentry, over the trimmed, lowercased address) and stores it on the run asemailRecipientHash.ph_recipient_hash. The webhook writes a status only for a record with that tag and a valid tracking code, so other workflow emails write nothing.toalone.$transactional_email.emailRecipientHash, so its status rows are still written.202. Only the status write is skipped, andcdp_email_status_missing_recipient_hash{source="route"}counts it, once per email the route queues.unknownrows in parallel per chunk.cdp_email_status_rows_produced{status},cdp_email_status_write_failed{status},cdp_email_status_missing_recipient_hash{source}.How did you test this code?
Test rationale: each case below failed before its change and passes after it.
Test seams:
EmailStatusWriterat the Kafka boundary, with the outputs faked: one row per status, its columns, and a later-ranked status at an earlier time keeps the higherversion.00beef0000beef0000beef0000beef00," Jane.Doe@Example.COM "and"jane.doe@example.com"both givee7b76d6c58afac8ef397725c6b81a97de6e27926a51b37ee79b281032c3f2aa7.EmailStatusServiceand read the rows at the Kafka producer, with UUIDT message ids the writer accepts:queued; a replay and a failed queue write none; with no salt key, a send answers202, queues once and writes no status.sent,failedwithprovider_error,skippedwith the send check's code; a failing status write still sends once; a run without the hash sends once, writes nothing and is counted.delivered,bouncedandcomplainedrows, no row for a not-spam correction of a complaint, and a5xxwhen the write fails.sentreadsfailedwithrejected, and a bounce of a bcc copy after delivery totokeepsdelivered.skippedwithworkflow_inactive,run_canceledorworkflow_missing.unknownandgave_up, and more stalled email jobs than one chunk each get oneunknownrow.provider_errororgave_upby one that issentorskipped, and the other way round.Local gate at the stack top
987b927630e, which holds this head unchanged, all green: cdp-api serial 288, CDP jest 3690 (1 skipped), nodejstsc, and the rest of the gate. The pre-push preflight ran in strict mode with 0 failures.Cross-version: owed, because the run state gains
emailRecipientHashand the send gains a tag. A backward-compatible reader covers both directions:emailRecipientHashparseJSONand copies it whole (job-queue-postgres-v2.ts:346,:361;job-queue-kafka.ts:187). No schema validates it.source="worker". A test pins it.ph_recipient_hashLive run at
987b927630e(Django, cdp-api and the email worker on a migrated clone of the local test database, with ClickHouse and maildev), case 07: a code send readsentby its id two seconds after the send. A read by recipient listed the three accepted sends, eachsent, and none of the refused ones (cases 04 and 05).Earlier live run at
1e18413cd1c, before the review fixes. It shows the rows in ClickHouse, which the final run did not read:Gaps:
Release status
Docs update
None. The transactional email API docs page is written after the stack lands.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5)ENCRYPTION_SALT_KEYSentry, which Node and Django both read, over a new secretcreated_atcome from?provider_error,gave_up) ranks withskipped, so the later of the two wins, andsentreplaces both. Side effects: a failed attempt never replacessent, and an SES Reject (failedwithrejected) still ranks abovesent.No open questions in this layer. The rollout decision is on #121272.
Review (GPT-6.1 Sol, round 1 over the whole stack, then a final read of the fixes):
queuedskippedwith a reason. Tests: each skip in the action handler, and the worker's cancel.emailRecipientHashThe stack's QA swarm then reviewed this layer. Round 1 fixed seven items, among them an SES Reject after
sent(nowfailedwithrejected) and a bounced cc or bcc copy that moved the status ofto. Round 2 found that a rerun'ssentreadfailed, and the fixer ranked failed attempts low. Final read 1 found they then ranked belowskipped, and the fix ranks them withskipped. Final read 2 found nothing.Involvement: strand conductor relays answered, one choice from Michael on replay scope, no grilling rounds. Reviews: GPT-6.1 Sol round 1 over the whole stack and a final read of the fixes. Stack QA swarm: design pass (GPT-6.1 Sol high); round 1 and round 2 (review and team lanes GPT-6.1 Sol high, fresh eyes Claude Opus 5.5 high); final reads 1 and 2 (GPT-6.1 Sol high); fixers Claude Opus 5.5 high; live run at the stack top.
/tdd,/writing-tests,/writing-code-comments,/writing-pr-descriptions.example.comdomains.🤖 Generated with Claude Code