Repository navigation
feat(cdp): deliver transactional email attachments from a side table - #127986
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>
A transactional code send can carry up to 10 files, 512 KB in total after decoding. The API validates them, stores one row per file in cyclotron_email_attachments after the checks and the reservation, and queues a job whose state keeps only each file's metadata and SHA-256. A failed queue insert deletes the rows, and a replay stores none. The email worker loads the rows by job id and checks the count and the checksums before it sends. A missing or mismatched row fails the send as attachments_missing, so no email goes out without its files. A terminal outcome deletes the rows. A throttle reschedule keeps them. The janitor sweeps rows older than seven days and samples the table's size and row count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 — 1% of added code lines are comments (8 of 980)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 Files with the most added comment lines:
This check does not block merging. It updates on every push and clears when the share drops. ❌ Hogbox preview — build failedThe preview didn't come up for commit Previews are optional and never block merging. A failure here is often a hogland or tailnet hiccup rather than anything in your PR, so the check stays green and this section is the status. |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed f79130a: 1 Must fix, 4 Should fix.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
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.
| sendEmailParams.Content.Simple.Attachments = files.map((file) => ({ | ||
| RawContent: file.content, | ||
| FileName: file.filename, | ||
| ContentType: file.contentType, | ||
| ContentId: file.contentId, | ||
| // A file with a content id is an image the html shows through `cid:`. | ||
| ContentDisposition: file.contentId ? 'INLINE' : 'ATTACHMENT', |
There was a problem hiding this comment.
P1 · Set BASE64 transfer encoding for SES attachments
For binary PDFs or inline images, this attachment object omits ContentTransferEncoding, so SES defaults to SEVEN_BIT, which cannot safely represent their bytes in the delivered MIME message. The SDK's base64 encoding of RawContent only handles transport to SES. Set ContentTransferEncoding: 'BASE64' on every attachment to preserve binary content.
There was a problem hiding this comment.
Fixed in 8095678. Every SES attachment carries ContentTransferEncoding: 'BASE64', and the email service test checks a PDF with bytes outside ASCII.
| `DELETE FROM cyclotron_email_attachments | ||
| WHERE id IN ( | ||
| SELECT id FROM cyclotron_email_attachments WHERE created_at < $1 LIMIT $2 | ||
| )`, |
There was a problem hiding this comment.
P2 · Skip locked attachment rows during expiry cleanup
When an attachment sweep takes longer than the cleanup interval, overlapping runOnce() calls select the same expired rows and wait on their locks. These waits consume the shared pool and block stalled-job recovery, which runs after the sweep. The catch cannot recover while queries remain blocked; select batches with FOR UPDATE SKIP LOCKED and bound their execution time.
There was a problem hiding this comment.
Fixed in 6f1c414. The sweep selects its batch with FOR UPDATE SKIP LOCKED, like the janitor's other cleanups, so an overlapping run skips rows another run holds.
|
|
||
| // Loaded last, after every gate that can skip or delay the send, and kept out of `params`, | ||
| // which a reschedule writes back into the job state. | ||
| const files = await this.loadAttachments(invocation.id, params.attachments) |
There was a problem hiding this comment.
P2 · Reschedule sends when attachment reads fail transiently
A transient database error from loadAttachments enters the generic catch, which marks the send finished with provider_error. executeSendEmail then deletes its attachment rows, permanently dropping an accepted email even though SES was never called. Distinguish database availability errors from missing or corrupt files, and reschedule those sends while retaining their rows.
There was a problem hiding this comment.
Fixed in 4a75f30. A database error while loading the files now raises EmailAttachmentsUnavailableError, which reschedules the send with backoff and keeps its rows. Only missing or changed files fail the send.
| if (this.pool || this.stopped || !this.databaseUrl) { | ||
| return this.pool | ||
| } | ||
| this.pool = new Pool({ connectionString: this.databaseUrl, max: this.maxConnections }) |
There was a problem hiding this comment.
P2 · Bound attachment database operations with deadlines
When PostgreSQL stops responding or an attachment delete waits on a lock, this pool has no connection or query timeout. The awaited operation never finishes, blocking the worker's entire Promise.all batch while periodic heartbeats prevent stalled-job recovery; even cleanup after SES acceptance blocks completion. Configure bounded connection and statement deadlines.
There was a problem hiding this comment.
Not changing this here. The attachment pool connects to the Cyclotron database and matches its other pools: the worker, manager and subscription matcher pools set no connection or statement deadline either. If Postgres stops responding, the worker's heartbeats to that same database stall too, so stalled-job recovery still applies. The only other lock holder on these rows is the janitor sweep, a single statement that now skips locked rows. Deadlines for all Cyclotron pools would be a separate change.
| contentType: z.string().regex(MIME_TYPE_RE, { message: 'contentType must be a MIME type' }).optional(), | ||
| contentId: z | ||
| .string() | ||
| .min(1) | ||
| .max(255) |
There was a problem hiding this comment.
P2 · Match attachment metadata length limits to SES
An attachment with a 79-character contentId passes AttachmentSchema, as does a MIME contentType longer than 78 characters. SES caps both ContentId and ContentType at 78, so the API returns 202 and queues files that the provider permanently rejects instead of reporting invalid input. Enforce the SES limits on both fields before accepting the send.
There was a problem hiding this comment.
Fixed in 6f1c414. contentId and contentType are capped at 78 characters, the SES limit, so the API refuses a longer value with a 400 before it queues the send.
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/14-cdp-code-send-attachments
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>
Send attachments to SES with Base64 transfer encoding so binary files survive delivery, and delete the stored files from the workflow worker, which also sees sends that end before the email step runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| registerAsyncFunction('sendEmail', { | ||
| execute: (args, context, result) => { | ||
| const { attachments: _onlyAnAcceptedSendHasFiles, ...stepEmail } = args[0] ?? {} |
There was a problem hiding this comment.
Blocked attachment extensions are bypassed with a trailing dot or space in the filename
A sender with a code-send key can attach an executable that the blocked-extension list is supposed to refuse. The strip added here (const { attachments: ... } = args[0]) makes the AttachmentSchema filename check in nodejs/src/cdp/services/hogflows/transactional-email.ts:151-157 the sole gate on every delivered attachment's filename, and that check derives the extension from the text after the last dot with no trimming: "invoice.exe." yields an empty extension and "invoice.exe " yields "exe ", neither of which is in BLOCKED_ATTACHMENT_EXTENSIONS (transactional-email.ts:153-154). Windows strips trailing dots and spaces when the recipient saves the file, so the delivered attachment becomes invoice.exe. The filename regexes at transactional-email.ts:148 and :140 block path separators and control characters but not a trailing space or dot.
How:
- Call the transactional email send endpoint with a valid send key.
- In
send.attachments, include a file namedinvoice.exe.(orinvoice.exe) with base64 PE content. AttachmentSchemapasses:name.lastIndexOf('.')finds the trailing dot andname.slice(dot + 1)is""(or"exe "), which the blocked set does not contain, so the API stores the file and queues the send.- SES delivers the executable; a recipient saving it on Windows gets
invoice.exe.
Fix: In the filename refine at transactional-email.ts:151-157, trim trailing dots and whitespace before deriving the extension (and reject the name if the trimmed result is empty).
React with 👍 if useful or 👎 if not
There was a problem hiding this comment.
Valid as a gap in this check, but no file gets past it. The public entry point is Django's code send, and #127988 refuses invoice.exe. and invoice.exe with blocked_file_type before it calls cdp-api: it strips trailing dots and spaces the way Windows does when it saves the file. cdp-api's /send is internal, and since 2a930a2 a workflow email step can't list files. Making the cdp check agree with Django's is a follow-up for #127985's schema, so this stack doesn't need another round of proofs for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tachments-table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode-send-attachments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
😎 Merged successfully - details. |
Master now keys a code send on the sender the caller chose, typed on the accepted send. This branch keys the queued send, whose attachments hold metadata and a sha256 in place of the Base64 content, so the sender helper takes the queued send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker whose job lock was reassigned acknowledges it without updating the row, then deleted the job's attachment files. The worker that now holds the job could then fail the send with attachments_missing. Delete the files only when no available or running job row remains for the job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| replyTo: z.array(z.string()).readonly().optional(), | ||
| cc: CopyRecipientsSchema, | ||
| bcc: CopyRecipientsSchema, | ||
| attachments: AttachmentsSchema, |
There was a problem hiding this comment.
P1 · Accept and forward attachments through the public send endpoint
A public code send containing attachments still fails validation: Django's SendEmailSerializer rejects the unknown field, and _send_code_email and _accepted_send never forward files. Consequently, SDK callers cannot reach this new attachment delivery path. Add attachment validation and forwarding to the public send pipeline.
There was a problem hiding this comment.
Out of scope for this PR. The Django side is #127988, which adds cc, bcc and attachments to SendEmailSerializer and forwards them to cdp-api. It merges after this PR, so cdp-api accepts the files before Django sends them.
… rows SES caps an attachment's ContentId and ContentType at 78 characters. A longer value passed the schema, so the API accepted the send and SES then refused it. Cap both at 78 so the API refuses the send instead. Overlapping janitor sweeps selected the same expired attachment rows and waited on each other's locks. Skip locked rows, like the other janitor cleanups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 6f1c414: 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.
| content: z | ||
| .string() | ||
| .min(1, { message: 'content must not be empty' }) | ||
| .refine((content) => BASE64_RE.test(content), { message: 'content must be base64' }), |
There was a problem hiding this comment.
P2 · Bound attachment content before running the base64 regex
An attachment containing 4.5 million base64 characters makes BASE64_RE.test throw RangeError before the aggregate size check runs. This fits within the router's body limit, and postTransactionalEmail returns HTTP 500, encouraging retries of permanently invalid input. Guard the encoded length before executing the regex or use constant-stack validation.
There was a problem hiding this comment.
Fixed in 3bf8a16. Content longer than the base64 of 512 KB is refused with a 400 before the regex runs. The refusal test now sends a 4.5M-character file, which answered 500 before the fix.
Hold one expired attachment row locked on another connection, and check that the sweep deletes the other expired row without waiting, then deletes the locked row once its lock is released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file of several million base64 characters made the base64 regex throw RangeError, so the API answered 500. Refuse a file longer than the base64 of 512 KB before the regex runs, which answers 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/trunk merge |
Problem
An app sending a receipt from code can't attach the PDF, which the send contract allows.
Changes
contentIdshows inline.cyclotron_email_attachments(#127995), so retries and Kafka snapshots never carry them.availableorrunning, so the new owner can still send the files.LazyPoolhelper, which opens the Cyclotron pool on first use. Conversion watchers keep their shutdown order: no new pool, then drain in-flight writes, then close.flowchart LR A[cdp-api]:::phRed --> J[(cyclotron_jobs.state<br/>send content)]:::phGray --> W{{email worker}}:::phBlue --> S[SES]:::phRed classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff; classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;flowchart LR A[cdp-api]:::phRed --> J[(cyclotron_jobs.state<br/>file metadata only)]:::phGray --> W{{email worker}}:::phBlue --> S[SES]:::phRed A --> F[(cyclotron_email_attachments)]:::phGray --> W W -. delete on terminal outcome .-> F JN{{janitor}}:::phBlue -. sweep after 7 days .-> F classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff; classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;Warning
Merge danger: medium. Deploy #127995 first. To roll back, turn the flag off and wait for 0 here, because an older worker drops queued files:
How did you test this code?
Test rationale: tests cover binary files to SES, a retry after an SES throttle, a missing file, cleanup per outcome, and no bytes in the state.
3bf8a16, though every job of its workflow passed. It failed atcb6469dtoo, because hogland had no capacity to place it. This PR changes no frontend, so the preview shows nothing it adds.Prod-shape excerpt
The full logs live in private proof storage.
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
max(cdp_email_attachments_table_bytes) > 5e9for 30m.migrations-separationrefuses it beside service code.email-attachments.service.ts, the store incdp-api.ts, and the cleanup incdp-cyclotron-worker-hogflow.consumer.ts.🤖 Generated with Claude Code