Skip to content

feat(cdp): send transactional email files by reference - #131944

Merged
trunk-io[bot] merged 107 commits into
feat/node-store-render-deadlinefrom
feat/node-store-send-references
Nov 11, 2026
Merged

trunk-io[bot] merged 107 commits into
feat/node-store-render-deadlinefrom
feat/node-store-send-references

Conversation

@Silthus

@Silthus Silthus commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Merge gate: passed. fleet proof gate passes on 6be27df (live run, prod shape, cross-version, shadow diff, review at HEAD, CI green, judge fail accepted by standing rule). Layer 4 of 4 in the node-store attachment stack: #133482, #131833, #133495, then this PR. The stack merges from its top PR; #131567 is live in both regions and the env settings (PostHog/michaelr-context#76) are merged.

Problem

Note

🟡 Merge risk: medium · two-way door

  • The code-send route in cdp-api; every send with files is refused with field_not_accepted while the allowlist stays empty. Sends without files queue the same state as before.
  • A keyed send also stores attachment_count and attachment_bytes on its idempotency record, which expires after 24 hours; an older pod ignores both on replay.
  • Switch-off: empty CDP_EMAIL_ATTACHMENTS_TEAMS; fix forward.

Changes

  • A code send takes attachments as references (sha256, filename, optional content_type and content_id) to files the team uploaded.
  • A team off the allowlist gets 400 field_not_accepted before its idempotency key is reserved.
  • A send with files must use a workflow of trigger, one transactional email step, then exit. Any other shape gets 400 workflow_shape_not_supported; an email step the frequency cap can hold, or a workflow under a sending rate limit, gets 400 workflow_can_wait. Both name the step and the fix.
  • A reference SES would refuse gets 400 invalid_request naming the field: a filename over 255 characters, a content_type or content_id outside 1 to 78, or a file type on the SES unsupported list.
  • Each file is verified under the caller's team and kept for 7 days. A missing one gets 400 attachment_not_uploaded with its sha256.
  • The 40 MB estimate counts the files, the bodies and the subject: the send's content, or the email step rendered with the recipient's person and the event's groups, the way the worker renders it. Over the limit: 400 message_too_large with the byte totals. The email worker checks the same estimate before SES.
  • An email step with a custom UTM value over 1,000 characters gets 400 utm_value_too_long. One with more than 10,000 Liquid tags across its templated fields gets 400 email_body_too_many_tags. Both are refused before anything parses the value, because LiquidJS parse time grows with the square of the tag count. Sends without files are not checked.
  • A template that goes over the render limits gets 400 email_render_limit_exceeded. A store outage, or a failed person or template lookup, answers 503 delivery_unavailable.
  • File checks and sizing share one deadline, half the Django facade's 10 s timeout. Past it: 503 and nothing queued. No Hog or Liquid input renders after the answer, even one waiting for its turn on the event loop; the render deadline itself comes from feat(cdp): let a caller bound hog and liquid rendering with a deadline #133495.
  • A keyed retry of a send that queued replays its 202 before the workflow's shape is checked, so a workflow changed since does not report an accepted email as failed.
  • Every refusal after the reservation releases the key, so a retry with the same key can send.
  • The queued run carries each file's metadata, no bytes. The 202 of both send routes answers attachment_count and attachment_bytes.

How it works

---
title: Upload, then send
---
sequenceDiagram
    participant C as caller
    participant A as cdp-api
    participant B as bucket
    participant W as email worker
    rect rgba(9, 105, 218, 0.15)
    Note over C,B: upload #35;131833
    C->>A: sends the sha256 and size of each file
    A->>B: HEADs team/<id>/<sha256>
    A-->>C: answers present, or a PUT URL signed for that size and hash
    C->>B: PUTs the bytes
    end
    rect rgba(26, 127, 55, 0.15)
    Note over C,B: send #35;131944
    C->>A: sends, naming each file by its sha256
    A->>B: HEADs each file, copies any with under 7 days left
    A-->>C: answers 202 and queues a job without bytes
    end
    W->>B: GETs each file by sha256, at most 6 days later
Loading
  • This PR acts on cdp-api's code-send handler in cdp-api.ts and on AttachmentUploads.acceptForSend, in the send span of the flow above.
  • Before, the code-send schema had no attachments field, so a send that named files got 400 invalid_request.
  • Now, a send names uploaded files by sha256. cdp-api HEADs each file, sizes the message, and queues a job that holds file metadata and no bytes.
  • acceptForSend copies any file with under 7 days left, so a file's expiry only moves later.
  • The worker gives up on a send after 6 days of waiting for room, so the files outlive the job.
  • File checks and sizing share one 5 s deadline. A refusal after the idempotency key is reserved releases the key.

How did you test this code?

  • The tests run outside-in over real HTTP against the dev bucket and Redis, and cover every refusal, key release, a replay after the workflow changed, the rendered and person-dependent estimate, and the deadline, including a person lookup that outlasts it and a Hog render that waits past it. Each mutation check fails a test.
  • End to end at the stack tip, Django → cdp-api → email queue → email worker → maildev, with Django's facade from feat(messaging): attach uploaded files to key sends by their hash #131763 forwarding content_type and content_id: sends with files deliver with their files intact, an inline image keeps its cid: reference and Content-ID, and each refusal answers synchronously through Django's facade with nothing queued, a rate-limited workflow included.
  • Shadow diff against feat(cdp): add the upload route for transactional email files #131833 for sends without files: queued rows identical; the only differences are the two new 202 fields and attachments: [] now accepted.
  • Cross-version: an older email worker fails a job with files as attachments_missing before SES, so no email goes out without its files. An older cdp-api refuses attachments with 400 invalid_request, even on a retry of a key this head stored, so the allowlist stays empty until every pod runs this head. Idempotency keys replay across versions for sends without files.
  • Live run over real HTTP: a 700,000-character UTM value and a body or subject of 100,000 tags are refused in milliseconds without blocking the event loop. A send without files with the same body queues, and tags in the editor's design state are not counted.
  • Production shape, US and EU: read-only counts of the workflows and person references this head reads at accept. No stored email step comes near either new cap.
  • The proof captures stay in a private proof store, because they hold production counts. This section summarizes them.

Release status

  • No feature flag controls this change

Docs update

  • None.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5

Skills: /writing-tests, /writing-code-comments, /writing-pr-descriptions, /stacking-prs

Question Answer Who Where
Which workflows may carry files? Trigger, one transactional email step, exit; every other shape, a frequency cap that can hold the email, and a sending rate limit are refused at accept, so nothing can delay or reshape the email 🤖 Agent PostHog/michaelr-context#77
Whose data renders the body for sizing? The recipient's person and the event's groups, resolved at accept the way the worker resolves them 🤖 Agent PostHog/michaelr-context#77
Where does the allowlist gate run? Right after validation, before the team load and the key reservation 🤖 Agent PostHog/michaelr-context#77
When is the workflow's shape checked? After the idempotency replay, so a retry of a queued send replays even when the workflow changed since; a refusal releases the key 🤖 Agent PostHog/michaelr-context#77
An email step that fails to render at accept? Over a template limit: 400 email_render_limit_exceeded. Any other render error counts as no body and queues; the worker fails it the same way 🤖 Agent PostHog/michaelr-context#77
How long may accept check files? Half the Django facade's 10 s timeout, then 503 with nothing queued and no render started after the answer 🤖 Agent PostHog/michaelr-context#77
Does message_too_large name a file? No, the whole send is too large; it carries the byte totals 🤖 Agent PostHog/michaelr-context#77
The same file referenced twice? Counted twice, because the message carries it twice 🤖 Agent PostHog/michaelr-context#77

Involvement: 4 cross-model review rounds and 24 final reads (GPT-6.1 Sol, Opus fresh eyes), one full qa-swarm plus the stack-tip pass, PostHog Review on each head; live run, end-to-end, shadow-diff, cross-version and production-shape proofs captured. CodeRabbit is retired and skipped.

More context for AI agents

🤖 Generated with Claude Code

Silthus and others added 5 commits November 9, 2026 14:41
The code-send route takes `attachments` as references to files uploaded
through the upload route. A team off the attachments allowlist is refused
before its idempotency key is reserved. After the existing checks, cdp-api
verifies each file under the caller's team, keeps it for 7 days, and
estimates the message from the file sizes and the bodies: the send's
content, or the workflow's email step rendered the way the worker renders
it. Refusals release the key. The queued run carries each file's metadata
and no bytes, and the 202 answers the file count and bytes.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The send estimate takes the renamed header constant, and acceptSend sits beside chargeDailyUploadBytes.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The send check reads the system clock like the upload route now does, and
the layer's code comments move here:
- Each refusal after the reservation releases the key, because nothing was
  queued and the caller's retry with the same key must be able to send.
- The bodies counted are the send's own content, or else its email step
  rendered the way the worker renders it, from the same run. A step that
  fails to render at accept fails in the worker too, so it counts as empty
  rather than refusing a send that would fail anyway.
- The queued send carries each file's metadata; the worker loads the bytes
  from the attachment store.
- Bodies are read only after every file is verified, because rendering can
  cost more than the checks that refuse first.
- The upload and send routes share one allowlist, so a team uses files on
  both or on neither.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send with files and no content whose email step fails to build at accept
counts as having no body, answers 202 with its file totals and is queued.
The accept-time render has no person or groups, so refusing on a render
error could block sends the worker would deliver; a rare oversized message
is refused by SES and recorded as failed instead.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus added this pull request to stack #131663 November 9, 2026 15:53
@Silthus

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@github-actions

github-actions Bot commented Nov 9, 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.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 941f437: 2 Should fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread nodejs/src/cdp/cdp-api.ts Resolved
Accept counted the bodies at their raw size and the html without the
preheader, and the worker checked only the files, so a send could get a
202 and then be refused by SES. The estimate now counts each body at its
base64 transfer size, one function for both sides. Accept counts the html
with the preheader the worker adds. The worker estimates the final
message, with the bodies after the person render, the tracking rewrite and
the preheader, and fails the step as message_too_large before the SES
request is signed. Accept refuses what it can see; a send that grows past
the limit at render fails at the worker and never reaches SES.

A replay with the same key now answers the totals of the first 202. They
are stored with the key; a key recorded by an older pod has none and
replays as a send without files, which is the only kind older pods queue.

The render-failure test drives a stored template with an unclosed liquid
tag through the route instead of a spy, and the size tables split into
accepted and refused tests with their sizes derived from the estimate.

Refs PostHog/michaelr-context#77

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

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 9048e0e: 2 Should fix.

Comment thread nodejs/src/cdp/cdp-api.ts Resolved
Comment thread nodejs/src/cdp/cdp-api.ts Resolved
Silthus and others added 4 commits November 9, 2026 16:57
… gives up

Accept now refuses an attachment filename over 255 characters and a
content_type or content_id outside 1 to 78 characters, the limits of the
SES v2 Attachment type. The refusal is the usual invalid_request 400 and
names the field and its limit, so no such send gets a 202 and then fails
at SES after its key is marked queued.

Only a template that fails to render or parse counts as no body when a
send with files is sized. Any other failure while loading the email
step, such as the template lookup, answers 503 delivery_unavailable with
the outage warning and releases the key.

Checking the files of a send and sizing its message now share one
deadline, half of the 10 s the Django send facade waits for cdp-api.
Past it the send answers 503 delivery_unavailable, releases the key and
queues nothing, so Django never reports a send unavailable that cdp-api
then queues.

The refusal table that switched on a 503 status is split into a 400
table and a 503 table that always checks the outage warning.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SES refuses attachments with any of 90 file extensions, and a send with
one of them got a 202 and then failed at SES after its key was marked
queued. Accept now compares the last extension of each filename,
ignoring case, with the list on the SES page on unsupported attachment
types (dg/attachments.html#mime-types), and refuses a match with the
existing invalid_request 400. The reason names the file and the type,
and the key stays free.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
email.service.test.ts: the missing-file test and the inline-reload table
stay exactly as layer 5 has them, including the check that the other
download is aborted. This layer's preheader case is its own test, which
sends nothing and fails as message_too_large, so no table switches on a
reason.

attachment-uploads.ts: layer 5 renamed settleBefore to
settleBeforeAbortEvenWhileCredentialsStall with the same behavior, so
the deadline on verifying a send's files uses the new name.

The worker's final-size check still runs after the files load and
before the SES command is built, so a too-large message is never
tracked as an attempt SES may have accepted.

Refs PostHog/michaelr-context#77

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

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 08e2006: 1 Should fix.

This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

@Silthus

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@Silthus

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

Silthus and others added 2 commits November 9, 2026 18:34
The send route answers 503 once file verification passes its deadline,
but the store kept checking and keeping files, and the route went on to
render the email step. A retry then overlapped that abandoned work.

The deadline now reaches every stage: each HEAD and COPY aborts on its
own timeout or the deadline, no COPY starts after it, and verification
stops before rendering the bodies and before the size estimate.

Refs PostHog/michaelr-context#77

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

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

…end-references

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus changed the base branch from feat/node-store-upload-route to feat/node-store-render-deadline November 11, 2026 11:18
@Silthus
Silthus added this pull request to stack #133496 November 11, 2026 11:18
Silthus and others added 2 commits November 11, 2026 11:25
…end-references

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…end-references

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stamphog[bot]

This comment was marked as outdated.

@stamphog
stamphog Bot dismissed their stale review November 11, 2026 12:08

New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.

Silthus and others added 5 commits November 11, 2026 12:12
…below

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs PostHog/michaelr-context#77

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

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 6be27df: 1 Must fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is review round 18 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Comment thread nodejs/src/cdp/utils/liquid.ts Resolved
Silthus and others added 2 commits November 11, 2026 13:18
A send with files now refuses an email step whose single {% liquid %}
block holds more statements than the tag limit allows, the same way it
refuses that many separate tags.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread nodejs/src/cdp/utils/liquid.ts Resolved
The tag count now reads the decoded template, so a block opened with an
encoded space counts its statements, and it keeps counting every tag
after a block opener, so a quoted opener no longer hides the tags after
it.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread nodejs/src/cdp/utils/liquid.ts Resolved

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 65c95f5: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is review round 19 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Comment thread nodejs/src/cdp/utils/liquid.ts Resolved
Silthus and others added 3 commits November 11, 2026 13:33
LiquidJS treats U+180E as a blank before a tag name, so the tag count
now does too, and a block opened after one counts its statements.

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…end-references

Refs PostHog/michaelr-context#77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…end-references

Refs PostHog/michaelr-context#77

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

Silthus commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

/trunk merge

@trunk-io

trunk-io Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

😎 Stack merged successfully - details.

@trunk-io
trunk-io Bot merged commit 687023c into master Nov 11, 2026
208 checks passed
@trunk-io
trunk-io Bot deleted the feat/node-store-send-references branch November 11, 2026 15:57
@deployment-status-posthog

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

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-11 16:27 UTC Run
prod-us ✅ Deployed 2026-11-11 18:14 UTC Run
prod-eu ✅ Deployed 2026-11-11 17:23 UTC Run

This table shows the posthog release only. Hogstation shows every service that this PR reaches, Kargo services included.

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