Skip to content

feat(cdp): key a code send on the sender the caller chose - #580

Closed
Silthus wants to merge 3 commits into
email-v2/01-cdp-code-sendfrom
email-v2/09-route-idempotency-cdp
Closed

Silthus wants to merge 3 commits into
email-v2/01-cdp-code-sendfrom
email-v2/09-route-idempotency-cdp

Conversation

@Silthus

@Silthus Silthus commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Part of the code-first rebuild of the email send stack; replaces parts of PostHog#121867 (one send per idempotency key on send/email/).

Problem

  • A developer who retries a keyed code send on send/email/ can get 422 idempotency_key_reused instead of a replay, after the project's default sender changed in between.
  • The same happens to a send that names an address no sender owns exactly, because Django resolves it through the default sender. The first send already went out, so the retry should replay it.
  • feat(messaging): mark a code send that leaves the sender to the project #578 is the Django half, which forwards the key and sets isDefault. This is the cdp-api half.

Impact

Pays into Grow Workflows through billed workflows_emails actions per paying org: callers can retry without double sends. In scope: CDP email sends.

Merge Danger

Warning

🔴 Merge risk: high · two-way door: a revert puts the resolved sender back in the key

  • Deduplication on the CDP email delivery path changes, where a wrong replay drops an email in silence, so the grade is high even behind the flag.
  • Only keyed code sends on transactional_email/send change, and only send/email/ makes them, behind workflows-email-from-code. The template route is unchanged.
  • Rollout window: a keyed send queued by the older cdp-api keeps the sender's integration in its key. A retry within 24 hours after the deploy answers 422 instead of a replay, and a revert does the reverse.
  • Contract: send.from.isDefault is new and optional. An older Django omits it, and the key then binds the integration as before.

Changes

Kind Files Lines
Production 2 +20 −1
Tests 1 +42 −0

How did you test this code?

Test seams: cdp-api's accepted-send route over HTTP (cdp-api.serial.test.ts).

Test rationale: returns the first send for a retry of %s and queues nothing new, one table for every replay, run red first: a retry after the default sender or the integration behind a named address changed that answers 422, and a retry that leaves out isDefault: false. The two new rejects the key for a request with %s rows pin that another named sender, or a named sender instead of the default, still answers 422.

Proofs at 64d8940:

Proofs at the pre-rebase head 1a43bdd, with Django at #578 79e619b
Case Both new Django before #578
Keyed send 202, delivered 202, delivered
Same request, same key 202 replayed: true, one email in total 202 replayed: false, two emails
Same key, another recipient 422 idempotency_key_reused, nothing sent 202, delivered: no key reaches cdp-api
Keyed send without from, then its retry 202 replayed: true, one email in total two emails
That retry after the default sender changed 202 replayed: true, one email in total three emails in total
Named from, retry after the default sender changed 202 replayed: true, one email in total two emails in total
Template by id (control) 202, delivered 202, delivered

The paused-key rows of that table moved with the active check to #565.

CI: the fork has no Depot runners, so those jobs never start, and no secret for the project-board app, so flags-project-board fails. The lane check passes: against master the PR changes only nodejs/ (cross_lane=false).

Release status

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

Docs update

None in this PR. The send API docs follow when the flag opens.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

Skills: tdd, writing-tests, stacking-prs, modernize-pr, pr-description, writing-pr-descriptions

Question Answer Who Where
Does a reused key with a changed template.hash replay? Yes, and the key's help text says what it binds 👤 Human map #461, lane brief of 2026-10-25
May the code carry comments? No; names carry the reasons 👤 Human map #461, 2026-10-25
Shrink onto the upstream refusal code? Yes; code that duplicates master goes 👤 Human map #461, 2026-10-28
Keep the inactive refusal's own why and fix? No; Django on master maps not_sendable to its own answer 🤖 Agent map #461 lane node3, restack of 2026-10-31
Should a named address marked isDefault key on the address? No; Django sets isDefault only when the caller named no address, so the address there is the default's and must stay out of the key 🤖 Agent qa-swarm round 3, 2026-11-01
Do the cdp-api and Django halves land as one PR? No: two PRs in either order, each safe against the other side's older version 🤖 Agent pending approval

Involvement: rounds 1 and 2 and two final reads before the restack; after it, one GPT-6.1 Sol review, a full two-family panel on 2026-11-01 (8 lanes: 2 fixed, 1 rejected, shadow diff owed and run) and a narrow round (2 lanes: description fixes, 2 rejected); pre-rebase live and cross-version proof kept above.

More context for AI agents

🤖 Generated with Claude Code

A keyed retry of a code send replays after the project's default sender
changed, and after Django resolved a named address to another sender. The key
leaves out a default sender (`from.isDefault`) and keys a named address on the
address and name the caller sent, not on the integration Django picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the email-v2/09-route-idempotency-cdp branch from 1a43bdd to 38613ba Compare October 31, 2026 08:41
@Silthus
Silthus changed the base branch from email-v2/08-admission-checks to email-v2/01-cdp-code-send October 31, 2026 08:41
@Silthus Silthus changed the title feat(cdp): replay a keyed send after its workflow or default sender changes feat(cdp): key a code send on the sender the caller chose Oct 31, 2026
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Owner Author

Moved to PostHog#127129.

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.

1 participant