Skip to content

feat(cdp): deliver transactional email attachments from a side table - #127986

Merged
trunk-io[bot] merged 102 commits into
masterfrom
email-v2/14-cdp-code-send-attachments
Nov 5, 2026
Merged

trunk-io[bot] merged 102 commits into
masterfrom
email-v2/14-cdp-code-send-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

An app sending a receipt from code can't attach the PDF, which the send contract allows.

Changes

  • A code send delivers its files through SES. A file with a contentId shows inline.
  • The bytes live in cyclotron_email_attachments (#127995), so retries and Kafka snapshots never carry them.
  • A missing or changed file fails the send. It never sends without its files.
  • The worker reads and deletes only the rows of the job's own team. An email step can't list files itself, so only an accepted code send has them.
  • A database error while loading the files reschedules the send with backoff, like an SES throttle. The 7-day sweep bounds the retries.
  • The worker loads the files after every check that can skip or delay the send, and keeps them out of the job state that a reschedule writes back.
  • The API refuses every file type that SES or Gmail blocks: the union of both lists. Validation errors never repeat a value from the request.
  • The idempotency fingerprint leaves out the file bytes, so the same key with other files counts as a reuse.
  • The worker deletes the rows only after it writes the job's final outcome. A failed enqueue keeps them, because the job may be queued anyway. The janitor sweeps leftovers after 7 days. Metrics track the table size.
  • A worker whose job lock moved to another worker keeps the rows while the job is still available or running, so the new owner can still send the files.
  • Mechanical: the attachment store and the conversion watchers share one LazyPool helper, 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;
Loading
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;
Loading

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:

SELECT count(DISTINCT a.job_id) FROM cyclotron_email_attachments a
JOIN cyclotron_jobs j ON j.id = a.job_id WHERE j.status IN ('available', 'running');

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.

  • Live run: both SDKs delivered byte-identical files to maildev, and a body over 5 MiB got a 400 with nothing queued.
  • Cross-version: old jobs run on new workers.
  • Hogbox preview: the optional preview did not come up at 3bf8a16, though every job of its workflow passed. It failed at cb6469d too, because hogland had no capacity to place it. This PR changes no frontend, so the preview shows nothing it adds.
  • Prod shape: no stored record in production reaches a changed branch other than the no-files path. #127995 created the table in both regions, and this PR adds its only writer, so the table holds no rows yet.
Prod-shape excerpt
1. cyclotron_email_attachments rows: none. #127995 created the table, and this PR adds its only writer.
2. Queued email jobs: all queued by master, so all take the no-files branch.
3. Queued workflow jobs: cleanup deletes only when state.send lists attachments.
4. Conversion watcher rows: writes keep their statement.

The full logs live in private proof storage.

- Shadow diff: master's conversion watcher service and this one ran the same ten scenarios against a test database. The stored rows, metrics and shutdown drain came out byte-identical.

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 chose the Cyclotron side table over S3 or inline state. The table moves to S3 if its metrics show it grows too large.
  • Follow-ups: the alert rule lives in the charts, outside this repo: max(cdp_email_attachments_table_bytes) > 5e9 for 30m.
  • Decisions: the migration moved to feat(cdp): add the email attachments table to cyclotron #127995, because migrations-separation refuses it beside service code.
  • Decisions: an old worker sends a queued attachment job without its files. The flag closes the deploy window, and the rollback drain query closes the rollback window.
  • Read closely: email-attachments.service.ts, the store in cdp-api.ts, and the cleanup in cdp-cyclotron-worker-hogflow.consumer.ts.

🤖 Generated with Claude Code

Silthus and others added 9 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>
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>
@Silthus Silthus added hogbox-preview Spin up a per-PR PostHog preview on a hogland hogbox posthog-review labels Nov 3, 2026
@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 — 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 .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
nodejs/src/cdp/services/conversion-watchers/conversion-watchers.service.ts 8 16

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

❌ Hogbox preview — build failed

The preview didn't come up for commit 3bf8a16. See the build log for the failing step. It'll retry on the next push.

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.

@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 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.

Comment on lines +1338 to +1344
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',

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8095678. Every SES attachment carries ContentTransferEncoding: 'BASE64', and the email service test checks a PDF with bytes outside ASCII.

Comment on lines +552 to +555
`DELETE FROM cyclotron_email_attachments
WHERE id IN (
SELECT id FROM cyclotron_email_attachments WHERE created_at < $1 LIMIT $2
)`,

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 })

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +84 to +88
contentType: z.string().regex(MIME_TYPE_RE, { message: 'contentType must be a MIME type' }).optional(),
contentId: z
.string()
.min(1)
.max(255)

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Silthus and others added 2 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 3 commits November 3, 2026 09:01
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>
@Silthus
Silthus changed the base branch from email-v2/13-cdp-code-send-copies to email-v2/13b-cdp-email-attachments-table November 3, 2026 09:06
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 3 commits November 3, 2026 09:46
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>
@veria-ai

veria-ai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No 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] ?? {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 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:

  1. Call the transactional email send endpoint with a valid send key.
  2. In send.attachments, include a file named invoice.exe. (or invoice.exe ) with base64 PE content.
  3. AttachmentSchema passes: name.lastIndexOf('.') finds the trailing dot and name.slice(dot + 1) is "" (or "exe "), which the blocked set does not contain, so the API stores the file and queues the send.
  4. 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Silthus and others added 3 commits November 4, 2026 14:39
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>
Base automatically changed from email-v2/13b-cdp-email-attachments-table to master November 4, 2026 20:15
@trunk-io

trunk-io Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

Silthus and others added 2 commits November 4, 2026 20:18
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>

@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 dab7651: 1 Must fix.

replyTo: z.array(z.string()).readonly().optional(),
cc: CopyRecipientsSchema,
bcc: CopyRecipientsSchema,
attachments: AttachmentsSchema,

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>

@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 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' }),

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@Silthus

Silthus commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

/trunk merge

@trunk-io
trunk-io Bot merged commit e6c4c38 into master Nov 5, 2026
237 checks passed
@trunk-io
trunk-io Bot deleted the email-v2/14-cdp-code-send-attachments branch November 5, 2026 08:12
@deployment-status-posthog

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

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-05 08:34 UTC Run
prod-us ✅ Deployed 2026-11-05 08:49 UTC Run
prod-eu ✅ Deployed 2026-11-05 08:51 UTC Run

This branch had an error being deployed

1 failed deployment
hogbox-preview — 3bf8a16f Deployed Nov 5, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hogbox-preview Spin up a per-PR PostHog preview on a hogland hogbox

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants