Skip to content

feat(cdp): send cc and bcc on a transactional code send - #127985

Merged
trunk-io[bot] merged 48 commits into
masterfrom
email-v2/13-cdp-code-send-copies
Nov 4, 2026
Merged

trunk-io[bot] merged 48 commits into
masterfrom
email-v2/13-cdp-code-send-copies

Conversation

@Silthus

@Silthus Silthus commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

An app that sends an invoice from code can't copy finance on it. The code send on #127122 takes to only. The send contract adds cc and bcc.

Changes

  • A code send now delivers to cc and bcc (up to 10 each) through the existing workflow email path. Each copy address uses one unit of the sending cap.
  • The pre-202 checks cover every cc and bcc the caller passes. A suppressed, invalid or unreachable one refuses the whole send with the same 422 as to. A workflow step's own cc or bcc is not checked before the 202. cc and bcc take the same address pattern as to, RECIPIENT_ADDRESS_PATTERN from feat(cdp): deliver a code send's content on its own transactional email route #127122.
  • A to that 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.
  • The idempotency fingerprint covers cc and bcc. A send without them fingerprints as before.
  • An older cdp-api refuses cc and bcc with a 400, because feat(cdp): deliver a code send's content on its own transactional email route #127122's AcceptedSendSchema refuses 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

How did you test this code?

Test rationale: cdp-api.serial.test.ts gains a suppressed-bcc refusal and a fingerprint-reuse case with a changed cc. The executor test covers cc reaching the email queue params. The existing email.service.test.ts cc case covers the hop to CcAddresses.

  • Live send to maildev through the Node and Python SDKs: To and Cc headers arrive, and Bcc is in the envelope only.
  • Live send with to suppressed 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
headers.to:   recipient@example.com
headers.cc:   copy-one@example.com, copy-two@example.com
envelope.to:  recipient@example.com, copy-one@example.com, copy-two@example.com, hidden@example.com
calculatedBcc: hidden@example.com   (no Bcc header in the message)

The full logs live in private proof storage, because they hold local tokens and job ids.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

  • None.

🤖 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-descriptions

More context for AI agents
  • Session: T3 Code slice thread "Attachments" of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, Claude Code.
  • Decisions: Michael scoped this slice to attachments, cc and bcc. Headers and tags wait.
  • Decisions: Michael kept the suppressed-to behavior: the first cc becomes To, and a bcc alone skips the send.
  • Decisions: Michael accepted the mixed-deploy cc/bcc loss, with merge order, workflows-email-from-code and the drain query above as the guard.
  • Follow-ups: attachments stack on this PR in email-v2/14-cdp-code-send-attachments.
  • Read closely: the strict schema in transactional-email.ts, and the recipient checks in cdp-api.ts.

🤖 Generated with Claude Code

Silthus and others added 7 commits October 31, 2026 08:21
…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>
@Silthus Silthus self-assigned this Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This 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) — clean

New 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) — clean

New 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 .agents/skills/writing-code-comments/SKILL.md for the house rules.

This check does not block merging. It updates on every push and clears when the share drops.

Silthus and others added 19 commits November 3, 2026 08:53
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
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>
Silthus and others added 10 commits November 3, 2026 20:35
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>
Silthus added a commit that referenced this pull request Nov 4, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review November 4, 2026 10:12
@parameterai

parameterai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

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 53d6c26 · Review settings

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team November 4, 2026 10:13
Base automatically changed from email-v2/01-cdp-code-send to master November 4, 2026 11:46
@trunk-io

trunk-io Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

/trunk merge

@trunk-io
trunk-io Bot merged commit 7601997 into master Nov 4, 2026
208 checks passed
@trunk-io
trunk-io Bot deleted the email-v2/13-cdp-code-send-copies branch November 4, 2026 17:23
Silthus added a commit that referenced this pull request Nov 4, 2026
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>
@deployment-status-posthog

deployment-status-posthog Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-04 18:09 UTC Run
prod-us ✅ Deployed 2026-11-04 18:45 UTC Run
prod-eu ✅ Deployed 2026-11-04 18:48 UTC Run

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