Repository navigation
feat(cdp): send cc and bcc on a transactional code send - #127985
Conversation
…il route A code send posts its accepted content to transactional_email/send. The route runs the same reserve, replay, refusal and enqueue steps as a template send. The accepted send binds the idempotency fingerprint, and a miss on a new workflow reads it from the primary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…flow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode-send Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode-send Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send with captureEngagement false still put the recipient's distinct_id in its SES header and pixel/redirect tracking codes. Minting still emits the v2 payload, which has no capture choice, so the SES delivery, bounce, open and click callbacks for that send captured engagement events. Every reader skips engagement capture for a code without a distinct_id, so leaving it out honors the choice on every deployed reader now. The v3 payload and its minting switch stay as they are. The send-time capture and the message assets row keep their distinct_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The capture-off fix also changed the pixel and redirect codes that dev and test mint, but only the SES header path had a test. The tracking URL test now runs per capture choice and fails if those codes keep the distinct_id of a capture-off send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code send accepts up to 10 cc and 10 bcc addresses. The route checks every recipient before the 202: a malformed, suppressed or unreachable copy refuses the whole send with the code `to` would get. The accepted send now refuses unknown fields, so a newer caller never loses one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes. ✅ Duplication (Python) — cleanNew Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. ✅ Duplication (TypeScript) — cleanNew TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. ✅ Comment density — 0% of added code lines are comments (0 of 110)This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out. Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See This check does not block merging. It updates on every push and clears when the share drops. |
A capture-off send already mints its v2 codes without a distinct_id, and every reader skips engagement capture without one. The v3 capture-choice payload was never minted, so remove its marker, parser branch, rollout constant and the captureEngagement fields that only fed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A queued job for a deleted workflow read the primary on every batch, one writer query per missing id. Remember ids the primary confirmed missing for 60 seconds (bounded to 10,000 ids, cleared by a reload), and read a batch's replica misses from the primary in one query while keeping the in-flight dedupe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workflow the replica did not have yet was read from both databases on every send and worker batch until replication caught up. Keep what the primary returned, hit or miss, for 60 seconds (bounded to 10,000 ids, cleared by a reload naming the id), so later lookups read neither database; after expiry the replica is read first again. The send-route and worker tests now hide the row from the replica at the query boundary, so they cover a replica miss followed by a primary hit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A primary read that started before a reload could resolve after it and store the old state for the next 60 seconds, so a workflow disabled or paused during that read kept sending. A reload now drops the in-flight read, and a read that a reload overtook returns its rows to its own callers but stores nothing. 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>
# Conflicts: # nodejs/src/cdp/services/hogflows/actions/hog_function.ts
…/13-cdp-code-send-copies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A newer Django can send a field this cdp-api does not deliver, such as cc, bcc or attachments. Before this, the field was dropped, the send went out without it, and the caller saw a 202. Now the send answers 400 invalid_request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and tracking The outer send object refused unknown fields, but content, from and tracking still dropped theirs, so a newer caller could get a 202 for an email sent without them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # nodejs/src/cdp/services/hogflows/transactional-email.ts
The PR's code carries no comments; the schema's .strict() and its tests say why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both transactional email routes verified the same audience with the same team and workflow claims, so a token Django minted for a stored-template send also worked on /transactional_email/send, where it could carry any content. The code send route now also requires the claim kind=code_send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cc and bcc now take the same verdict as `to`, so an address like jane=tag@example.com is queued instead of refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recipient pattern let a dot lead, trail or repeat in the local part, so .jane@example.com reached the provider instead of a 422. Dots now only separate the characters, as Django's pattern does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…HEAD # Conflicts: # nodejs/src/cdp/services/hogflows/transactional-email.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code-key and capture tests sent real email through the shared maildev, which races email.service.test.ts clearing the same inbox when both files run in one CI shard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The template route now requires a token without a kind claim, and the code send route keeps requiring kind code_send, so neither token works on the other route. 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>
|
Risk: No findings Since the last review, the only PR-authored change is a latency refactor in the transactional-send endpoint: the team/workflow lookups and the five pre-202 refusal checks now start concurrently but are awaited in the original precedence order, with unhandled-rejection guards added. Everything else in the range arrived via a master merge and is unchanged from master. The refactor preserves the refusal ordering, the team/workflow ownership checks, and the scoped-JWT gate, and the eagerly-run lookups are all read-only, so I found no new security risk. Sentinel reviewed |
|
😎 Merged successfully - details. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/trunk merge |
Keeps both sides' rows in the rejected-key table: this PR's named and address-less sender rows and master's changed cc row (#127985). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
An app that sends an invoice from code can't copy finance on it. The code send on #127122 takes
toonly. The send contract addsccandbcc.Changes
ccandbcc(up to 10 each) through the existing workflow email path. Each copy address uses one unit of the sending cap.422asto. A workflow step's own cc or bcc is not checked before the 202. cc and bcc take the same address pattern asto,RECIPIENT_ADDRESS_PATTERNfrom feat(cdp): deliver a code send's content on its own transactional email route #127122.tothat becomes suppressed after the 202 doesn't lose the email. The first cc becomes To and the email goes out. With only a bcc left, the step skips the send.ccandbccwith a 400, because feat(cdp): deliver a code send's content on its own transactional email route #127122'sAcceptedSendSchemarefuses unknown keys.Warning
Merge danger: medium, two-way door. Delivery code, but only code sends behind
workflows-email-from-code. Deploy cdp-api before any Django change that sends new fields: the strict schema refuses them. Merge #127988 only after this PR is fully deployed. A worker older than this PR delivers a queued job without its copies and still marks it completed, so do not roll the workers back past this PR while #127988 is live.Rollback runbook
ccorbcc, because only a code send stores them, and every code send job matches"send":{.How did you test this code?
Test rationale:
cdp-api.serial.test.tsgains a suppressed-bcc refusal and a fingerprint-reuse case with a changed cc. The executor test covers cc reaching the email queue params. The existingemail.service.test.tscc case covers the hop toCcAddresses.tosuppressed after the 202: the first cc becomes To and the email still goes out. With only a bcc left, the step skips the send.Live run excerpt: maildev message for the Node SDK cc/bcc send
The full logs live in private proof storage, because they hold local tokens and job ids.
Release status
Docs update
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5)Skills:
/writing-tests,/writing-code-comments,/writing-pr-descriptionsMore context for AI agents
tobehavior: the first cc becomes To, and a bcc alone skips the send.workflows-email-from-codeand the drain query above as the guard.email-v2/14-cdp-code-send-attachments.transactional-email.ts, and the recipient checks incdp-api.ts.🤖 Generated with Claude Code