Skip to content

feat(node): send cc, bcc and attachments from code - #5514

Merged
Silthus merged 45 commits into
mainfrom
emails-v2/04-code-fields
Nov 13, 2026
Merged

Silthus merged 45 commits into
mainfrom
emails-v2/04-code-fields

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Merge gate: node-store (#77) and django-facade (#78) live in both regions, the allowlist at * in both regions, and a real send through this SDK version with a real project key, delivered and confirmed by Michael. Then the repo's normal release.

Problem

An app moving its email from Resend to PostHog can't send a receipt with a PDF, or copy finance on an invoice. The code form of posthog.emails.send() (#5509) takes rendered html and text only.

Inline Base64 files capped an email at a request body's size. Now the SDK uploads each file to PostHog's file storage by its hash and sends the email with references, so an email carries up to 10 files and about 29 MB. Ticket: PostHog/michaelr-context#82.

Impact: Grow Workflows. It removes a blocker for Resend migrations, seen as code sends with migratedFrom that carry files.

Merge Danger

Caution

🔴 Merge risk: high · one-way door at merge: merging releases posthog-node, and the attachments shape and its error codes become a public contract

  • A send with files that fails to upload or send: the email is not sent and the call returns an EmailError, for teams that send files from code, in US and EU. Sends without files take the same path as before, plus a copy of the options at the call.
  • Switch-off: the server's per-team env allowlist on the upload route. With a team off, the upload route refuses a send with files before any upload. The SDK itself has no switch: it is live on release, and we fix forward.

Rollout

  • Stages: node-store, then django-facade, live in both regions; the allowlist * in both regions; then merge, which releases through the repo's normal release.
  • Decided: headers and tags wait for a later slice.
  • Watch: the upload route's refusals by code on the server, per django-facade (Capture actions if using toolbar #78). No SDK-side alert exists.

Changes

  • cc and bcc: one address or a list. A string becomes a one-item list. An empty list is left out, as feat(node): send email content from code with emails.send #5509 does for replyTo.
  • attachments: { filename, content, contentType?, contentId? }. content is a Buffer, a Uint8Array or a Base64 string, at least one byte. An empty list is left out.
  • Hash, upload, send: the SDK hashes each file with SHA-256 (crypto.subtle, so the edge runtime works), asks POST /api/projects/@current/messaging/attachments/uploads/ which files PostHog lacks, PUTs each missing file once to the URL PostHog signed, with exactly the headers it returned and no API key, then sends the email with { sha256, filename, content_type?, content_id? } per file. The same file twice uploads once.
  • Nothing is sent when an upload fails. A storage refusal returns attachment_upload_failed with the storage's status and reason; a network failure or a timeout returns network_error, and a timeout's fix names emailRequestTimeoutMs. The ask retries like the send does; each PUT also retries a storage 429 or 5xx, since it resends the same bytes and checksum to the same URL.
  • Refused before any request, with the server's codes: more than 10 files (too_many_attachments), an email over 40 MB after Base64 encoding (message_too_large, the server's estimate over the files, both bodies and the subject; without text, the html counts twice, since PostHog derives the text from it), an empty file, and every shape invalid_attachment already covered. The size check runs on each file's length before any copy or decode, so an oversized file costs no extra memory.
  • Uploads only over https. Before any upload, an upload URL that isn't https returns api_error on that attachment, saying to fix the file storage's URL, without the URL, and nothing is sent. A client whose appHost is plain HTTP, which is local development, may upload over plain HTTP too; there is no new option. Uploads don't follow redirects either, so storage can't send a file on to another URL: a 307 or 308 returns attachment_upload_failed on that attachment. The option rides on the email sender's own fetch options, so @posthog/core and the public API don't change; a custom fetch may ignore it.
  • emails/attachments.ts holds validation, the size limit, decoding and hashing. emails.ts keeps HTTP, retries and the upload protocol.
  • A 404 on the upload route comes from a PostHog without the route, or from sending email from code being off for the project. The SDK can't tell them apart, so it returns PostHog's own not_found on attachments, with its reason, and sends nothing.
  • The options are copied at the call, so a caller that changes them while the files upload doesn't change the email.
  • Template form: templateId with any of the three returns invalid_form, and the types refuse it.

There is no sdk-specs spec for the code form or attachments yet (openspec/specs/emails/spec.md covers the template send), so no divergence to call out.

Evidence

Lane gate green at e7b0a0c, 1678 of 1678 tests in each runtime: check-types, lint, format, the posthog-node unit tests and test:edge-compat (every node spec under the edge runtime). The worktree box can't start for this repo, so the gate ran locally under fleet heavy.

  • Test seams: posthog.emails.send() on a real client whose fetch is an in-memory PostHog and file store (__tests__/utils/emails-fake.ts). It checks each request the way the services do: the key, both routes' schemas, the allowlist, the message estimate, signed-URL expiry, length and checksum, attachment_not_uploaded, idempotency, and an older server's refusal of cc or bcc. Tests assert what the caller gets back and what was stored and sent. Faults are injected only as a status, a network error, a hang, a lost reply, or a server that recovers after a set number of refusals. The accepted and refused shapes are type tests.
  • Retries proven by outcome: a refusal the SDK must not retry sends nothing even though the next request would succeed; storage that fails, answers too late, or drops the connection as often as the retries allow still delivers; one failure more sends nothing. Mutating each retry rule, and each retry budget by one either way, fails the test written for it.
  • Red, then green: the new attachment tests fail on the inline Base64 code and pass at ceda104. Each review fix added its test red first: storage retries, the timeout message, error names and caller field names at 2b2c3be, the storage 429 retry at ab5800e, PostHog's own 404 answer at afa528f, an older PostHog's refusal of cc or bcc at 367d337, and a project that can't send files yet, refused on attachments, at b3ee911. Boundary tests pin how the size check counts html and text: 57 ASCII bytes count 78, and 57 two-byte characters count 156, as PostHog's estimate does. The subject counts 75 bytes for each 42 UTF-8 bytes, as node-store's estimate does since feat(cdp): send transactional email files by reference posthog#131944: a 42-byte subject counts 75, 43 bytes 150, 21 two-byte characters 75, 22 of them 150, and an empty subject nothing. Each case sends the largest file that fits beside it and refuses one byte more; the tests failed before the subject term (a01858a). An html-only email that fits with the html counted once but not twice is refused before any request, and sent once it has a short text. An upload URL that isn't https is refused before any upload on the attachment it belongs to, and a plain-HTTP appHost uploads over plain HTTP. An upload that storage redirects returns attachment_upload_failed, and nothing reaches the redirect target. These failed before 00e5bc1, 3e4e0cc and c38ed50.
  • Live run at e7b0a0c: the built SDK with real fetch, against a fake PostHog API that presigns uploads the way the server's store does, and a real S3-compatible bucket that checks each signed PUT.
    • New files upload, arrive byte-identical, and the email goes out by hash, with cc and bcc. A duplicate file uploads once.
    • The same files again: PostHog has them, so the SDK asks and sends, with no upload.
    • Bytes changed in flight: the bucket refuses the checksum, the SDK returns attachment_upload_failed, and nothing is stored or sent.
    • The largest file an email with empty bodies and a short subject carries uploads and sends; one byte more is refused with no request.
    • An html-only email whose html fits once but not twice is refused with no request; the same email with a short text uploads and sends.
    • The run's API and bucket are plain HTTP on localhost, so every upload goes through the plain-HTTP appHost opt-in.
    • A storage that answers the PUT with a 307 to another origin, with real Node fetch: the build before c38ed50 followed it, re-sent the file there and sent the email; e7b0a0c stops at the 307, the other origin receives nothing, and nothing is sent.
  • Shadow diff at e7b0a0c, against the merge base after main came in: 13 send shapes the base already accepts (template and code form, every replyTo shape, template data with a Date, a toJSON object and undefined values, unicode), with and without migratedFrom, post byte-identical requests on the merge base and on this branch. The options copy at the call changes nothing on the wire.
  • Cross-version at e7b0a0c: against today's PostHog Cloud in US and EU, which has no upload route yet, a send with a file makes one request, gets 404, returns not_found on attachments with PostHog's reason, and sends nothing. A send with cc and bcc and no file skips the upload route and posts both as lists to the send route; the run's fake key stops it at authentication, and the SDK reports that answer unchanged.
    • An older PostHog that takes the send but not cc or bcc answers field_not_accepted on that field. A real key is needed to reach that answer live, so a test replays Django's refusal through the public send: the SDK returns it as a validation_error on cc or bcc, with PostHog's reason and fix, without a retry. It fails when the mapping breaks or the SDK retries.
    • A project that can't send files yet gets field_not_accepted from the upload route, naming files. The SDK reports it on attachments, the field the caller passed, and sends nothing. A test replays the server's refusal, and it failed with files before the fix.
  • Gap: an end-to-end run against the real API, including a live older server refusing cc or bcc, needs a real secret key and waits for node-store and django-facade to deploy. That run is the merge gate above, and Michael accepted this gap until it passes. Opening (including the into module v1.4.2 fails to build #79) is the slice that proves it.
Earlier evidence: the inline Base64 cut at 8dd465b

The Python twin is PostHog/posthog-python#1172.

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • All commits are signed (see Commit signing)
  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written by Claude Opus 5.5 (claude-opus-5-5) in Claude Code, for Michael. The rework merged main and replaced inline Base64 with hash, upload, send. A design pass by GPT-6.1 Sol ran before the PR body changed.

Question Answer Who Where
Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice 👍 Accepted default comment
How do files reach PostHog? Hash, upload what PostHog lacks, send by reference; replaces inline Base64 👍 Accepted default PostHog/michaelr-context#82
What does a 404 on the upload route answer? PostHog's own not_found on attachments, nothing sent; replaced the field_not_accepted mapping 👍 Accepted default comment
How do local limits fail? With the server's codes, before any request 🤖 Agent comment
Does the SDK estimate sends without files? No: the server estimates only sends with files, and its request cap covers the rest 🤖 Agent comment
Uploads in parallel or one by one? One by one, each with the client's request timeout 🤖 Agent comment
Which options does the email carry? A copy taken at the call 🤖 Agent comment
Which file storage failures retry? A 429 or any 5xx; a 4xx fails at once 🤖 Agent comment
Which field does an upload-route refusal name? The caller's: files becomes attachments, files__<i> the caller's attachments__<j> 🤖 Agent comment
Which local-development opt-in allows plain-HTTP uploads? A plain-HTTP appHost; no new option 🤖 Agent comment
Does an upload follow a file storage redirect? No: a redirect returns attachment_upload_failed; core's fetch options stay as they are 🤖 Agent comment
How do the tests reach PostHog? An in-memory PostHog and file store that check requests like the services; replaced the queued fetch replies 🤖 Agent comment

Involvement: 2 questions answered on the earlier cut. Reviews: the design pass (GPT-6.1 Sol); a panel of 11 lenses on Opus 5.5 and GPT-6.1 Sol with the client libraries team's perspectives, then two fix rounds and three final reads; a second full panel of 11 lenses on the final code, three fix rounds that moved every test outside-in, final reads four to six, a seventh round on the subject term (correctness, spec conformance and compatibility lenses), an eighth on the derived text and https uploads (adding a security lens), whose redirect finding is fixed, a ninth on that fix, two on a test-fake change that cleared a CodeQL alert (one finding, fixed), one on moving the redirect option off core's shared type after CI's public API check, and one on a docblock the proof judge flagged, all clean at the end; PostHog Review on each push (one finding on c38ed50, on signed URLs expiring during very slow uploads, answered and declined), clean on e7b0a0c; the earlier cut's rounds. CodeRabbit skipped: no slot for this stack. Proof captured: live run, cross-version and shadow diff.

🤖 Generated with Claude Code

Silthus and others added 4 commits October 31, 2026 08:09
A send now takes one of two forms on the same send endpoint: a template made in PostHog by templateId,
or content your code rendered with templateKey, subject, html or text, and an optional from and replyTo.
The client refuses a field outside the chosen form and a second send argument before any request.
Every send carries capture_engagement, true unless the send turns it off, and the migratedFrom client
option adds the X-PostHog-Migrated-From header to every email request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The send endpoint takes capture_engagement only on a send with template_key and refuses it on a
template send. A template made in PostHog follows the project's engagement setting, so the client
now refuses captureEngagement on the template form before any request and leaves it out of the
template body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 ccb14b5: 1 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 thread packages/node/src/emails/emails.ts Resolved
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Coherence

📄 0 spec file errors · ⛔ 0 broken rules · 🚪 2 unchecked inputs · 🐛 0 bugs that came back

What is Coherence?

Coherence compares the code with its spec files (*.spec.md). A spec file lists the rules for one part of the code.

This check read 44 parts of the code and 7 rules.

Note

These findings do not make this check fail.

None of these findings are in files that this pull request changes. You do not need to fix them in this pull request.

🚪 Unchecked inputs (2)

  • public api in packages/web/Web.spec.md. The input comes from: host-app.
  • webpack plugin in packages/webpack-plugin/Webpack-plugin.spec.md. The input comes from: build.
What this means and how to fix it

Input from outside this code comes in at these places. The spec does not say what checks that input. 10 more places were already like this when Coherence was added. This list does not show them.

To close one of these, find the place by its name in the ## entrances section of its spec file. Add one line under it:

  • If all of the input goes through one function that a rule in the spec protects, add guard: <function>.
  • If a rule in the spec already checks input from the same source, add the place's name to the entrances: line of that rule.
  • If the input needs no check, add control: none - <reason>.

To get the line to add, run npx --no -- coherence scaffold control "<name>".

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Node SDK acceptance v2

Commit: f13c1e0
Workflow run and artifacts

Capture v0

✅ 83 passed / ❌ 4 non-passing / ⚠️ 954 not selected

Drill-down: run artifacts → node-v2-v0 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

Capture v1

✅ 148 passed / ❌ 4 non-passing / ⚠️ 889 not selected

Drill-down: run artifacts → node-v2-v1 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

@Silthus
Silthus added this pull request to stack #5516 November 2, 2026 10:23
Silthus and others added 2 commits November 2, 2026 14:43
Take main's flat send body from #5487: the template form sends template_id and distinct_id at the top level, like the code form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code form of posthog.emails.send() takes cc, bcc and attachments.
A single address becomes a one-item list on the wire. Attachment bytes
are Base64 encoded once, before the retry loop. The template form
refuses these fields before any request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the emails-v2/04-code-fields branch from ccb14b5 to a88a83d Compare November 3, 2026 08:12
@Silthus
Silthus removed this pull request from stack #5516 November 3, 2026 08:12
@Silthus
Silthus changed the base branch from emails-v2/03-local-template to emails-v2/01-send-inline-html November 3, 2026 08:12
@Silthus
Silthus added this pull request to stack #5526 November 3, 2026 08:12
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Replay incident risk check

The current diff no longer matches any past incident pattern.

No incident-pattern matches for 12 changed file(s) vs origin/main.

@Silthus

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 writing on behalf of Michael

Decisions for the attachments slice:

  • 👍 Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice.
  • 👍 How does StampHog approve an SDK draft? StampHog doesn't review drafts, so this PR goes ready only for the stamp, then back to draft.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Array.map skips holes, so a sparse list sent null attachments past
the check. A retry test now pins the bytes and key of the first
attempt for a send with attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus changed the title feat(node): send cc, bcc, headers, tags and attachments from code feat(node): send cc, bcc and attachments from code Nov 3, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review November 3, 2026 10:32
@Silthus
Silthus requested a review from a team as a code owner November 3, 2026 10:32

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not approved — this change needs a human reviewer.

Re-add the stamphog label to request another review once you have addressed this.

Independent assurance and STRONG author familiarity cover the risky areas, but @PostHog's substantive inline concern remains unfixed. Address the encoding concern in packages/node/src/emails/emails.ts with native Node encoding and an edge-compatible fallback, or obtain a non-author acceptance in the thread, before re-requesting.

  • Author wrote 100% of the modified lines and has 0 merged PRs in these paths (familiarity STRONG).
  • PostHog Review (Standard) reviewed the current head: no findings.
  • @PostHog, packages/node/src/emails/emails.ts: the open performance concern remains unaddressed. base64Of still spreads byte chunks into JavaScript arguments, builds intermediate strings, and calls btoa rather than using native Node encoding; attachment conversion runs synchronously before the request.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 171L, 4F substantive, 290L/7F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1c-medium (290L, 7F, two-areas, feat)
stamphog 2.7.0 .stamphog/policy.yml @ 5a1a78e · reviewed head 5a1a78e

Silthus and others added 5 commits November 3, 2026 10:39
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>
…nto emails-v2/04-code-fields

# Conflicts:
#	packages/node/src/__tests__/emails.spec.ts
#	packages/node/src/emails/emails.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs PostHog/michaelr-context#82

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 9bcebfc: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

@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 7da8f5b: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

The redirect option sat on @posthog/core's shared PostHogFetchOptions,
which changed the public API of posthog-node and posthog-react-native. The
email sender now types its own fetch options with it, so core and every
public reference stay as they were.

Refs PostHog/michaelr-context#82

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 93df0bc: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The code looks good; no qualifying findings. Verdict: correct.

Refs PostHog/michaelr-context#82

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 e7b0a0c: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

stamphog[bot]

This comment was marked as outdated.

Main added a react option to emails.send (#5634). A send with react and
attachments now renders the element first, checks the email's size again
with the rendered html and its derived text, then uploads its files and
sends, so a rendered body that pushes the files over the limit is refused
before any request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 12, 2026 07:15

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

@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 80dfd49: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

stamphog[bot]

This comment was marked as outdated.

…chments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 12, 2026 07:23

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

@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 a708379: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

stamphog[bot]

This comment was marked as outdated.

Main added emails.setPreferences (#5511), whose reply parser reads
Reply.payload. This branch changed Reply to carry the raw text, so
parsePreferencesReply now parses that text like the other reply parsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 12, 2026 16:03

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

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not approved — escalated to a human reviewer.

Re-add the stamphog label to request another review once you have addressed this.

The risky changes lack independent assurance on the current head; all supplied formal reviews cover older commits, and the discussion summary does not establish current-head coverage. Request a current-head review from Harley Alexander, Vincent (Wen Yu) Ge, or another teammate before re-requesting.

  • Risky territory without independent assurance: Public API contracts — packages/node/src/emails/types.ts and packages/node/src/exports.ts expose attachment options and copy recipients as supported SDK inputs, while packages/node/src/emails/errors.ts adds attachment-specific error classification.; Data ingestion or write paths — packages/node/src/emails/emails.ts:244–279 requests storage uploads, writes missing attachment bytes, and includes file references in email sends.; Auth or security-sensitive surface — packages/node/src/emails/emails.ts:263–285 authenticates upload preparation and controls transport security, redirect handling, and headers for file transfers.; Crypto/secrets — packages/node/src/emails/attachments.ts:202–204 computes SHA-256 identifiers used to deduplicate files and reference uploaded content.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 588L, 7F substantive, 2055L/13F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (2055L, 13F, two-areas, feat)
stamphog 2.8.0 .stamphog/policy.yml @ 7257879 · reviewed head 7257879

Main added tracking and sensitive to a code send (#5630). This branch sends
a code send's form fields through codeFormFields, so they now go out from
there, and capture_engagement is sent only when the caller sets it, as on
main. The fake PostHog takes tracking and sensitive as the send API does,
and a test sends a calendar invite with its file and tracking: { opens: false }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit to PostHog/posthog-rs that referenced this pull request Nov 13, 2026
* feat: send transactional email with client.emails()

Add `client.emails()` with two send forms, `TemplateEmail` for a template
made transactional in PostHog and `CodeEmail` for content the app rendered,
and `get` for an email's delivery status. Both clients share the argument
checks, flat request body, error mapping and retry policy; shutdown refuses
new email requests and waits for those in flight within its timeout.

New client options: app_host, email_request_timeout_seconds,
email_request_max_retries and migrated_from.

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

* refactor(emails): one retry loop per HTTP client, httpmock test server

- Each Emails handle retries inside request(); send and get only parse the
  2xx reply, so a malformed success is never retried.
- The email test server is httpmock's stateful responder instead of a raw
  TCP server.
- New tests pin email_request_timeout_seconds against a slow server, and
  that it replaces a custom HTTP client's longer timeout.
- wait_idle drops an explicit Notified::enable that notify_waiters does not
  need.

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

* test(emails): bound the async shutdown tests' wait for the first request

The wait now fails when the send ends without reaching the server, or
after 10 seconds, instead of hanging.

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

* fix(emails): keep Error unwind safe

Error::Email holds an EmailError, whose source was a boxed dyn Error. That
made posthog_rs::Error lose UnwindSafe and RefUnwindSafe, which it has on
main, so code that moves an SDK error across catch_unwind would stop
compiling. The source now sits in a newtype that keeps both, and a unit test
pins Send, Sync, UnwindSafe and RefUnwindSafe on Error and EmailError.

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

* fix(emails): read at most 1 MiB of a reply

Both clients read an email reply in full, so a hostile or misconfigured app
host could make the application buffer an oversized or gzip-expanded body.
They now read at most 1 MiB after decompression. A larger body is unreadable,
as a failed read was already: an acceptance becomes a retryable network
error, and a refusal still counts by its status and Retry-After.

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

* test(emails): prove reading stops at the reply cap

A raw-socket server claims a 10 MB body, sends 1.1 MiB and holds the
connection open. Both clients must report the oversized reply within
5 seconds, well before their 10 second request timeout. A client that read
the whole body first fails this test.

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

* fix(emails): wait for email requests when shutdown runs off the runtime

`futures::executor::block_on(client.shutdown())`, as the global
`posthog_rs::shutdown()` runs it, returned before a send in its retry
backoff finished. Off a Tokio runtime, shutdown now blocks the calling
thread until the requests end or `shutdown_timeout_ms` passes. From a
transport callback it still waits for nothing.

Also from the qa-swarm round 2 panel:
- `SendEmail` is `#[non_exhaustive]`, so a later send form isn't a
  breaking change.
- `Error::Email` returns the email error's cause from `source()`, so a
  chain report no longer prints the message twice.
- The network-error test proves its retry by the backoff it waits.
- One shared rule turns a read body into a reply for both clients, and
  inline comments became names and docs.

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

* feat(emails): send cc, bcc and attachments with a code send

A `CodeEmail` takes `cc` and `bcc`, one address per call, and
`attachment(EmailAttachment)`. `EmailAttachment::new` encodes the file's
bytes as Base64 once; `from_base64` sends encoded content as is, with an
optional `content_type` and `content_id`. Empty lists stay off the wire,
so an older PostHog still sends a plain email, and a send that does carry
one of these fields to a PostHog without it is refused with an error
naming the field. `TemplateEmail` has none of these: the template sets
its own recipients and content. An attachment's `Debug` output never
shows its content.

Matches posthog-python (PostHog/posthog-python#1172) and posthog-node
(PostHog/posthog-js#5514), against the server contract in
PostHog/posthog#127988.

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

* test(emails): format the attachment Debug assertions for edition 2018

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

* test(client): prove a transport callback's shutdown waits for no email request

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

* test(emails): script the older server's refusal as PostHog sends it

The cross-version live run against PostHog/posthog#127123 answered a send
with attachments with 400 validation_error, code invalid_input and attr
attachments. The test now scripts that reply instead of a guessed one.

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

* docs(emails): say the attachment limit counts file bytes before Base64

PostHog caps attachments at 512 KB of decoded file content. The method doc
and the guide now say so, since Base64 makes the encoded content about a
third larger.

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

* feat(emails): upload attachments by hash, then send their references

A code send's files no longer ride inline as Base64. The client hashes each
file, asks PostHog which files it lacks, PUTs those to the presigned URLs
PostHog returns with only the signed headers, then sends the email naming
each file by its SHA-256. It checks 10 files and the 40 MB message estimate
before any request, and a refused upload stops the send with the file
store's answer.

Refs PostHog/michaelr-context#87

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

* refactor(emails): format the SHA-256 with the digest's own hex

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

* fix(emails): keep the signed upload URL out of errors

A failed upload's error drops the presigned URL, whose query signs the
upload, and a request that can't be built is an unexpected_error. Tests
cover a file attached twice uploading once and a 503 from the file store
being retried. The guide names the upload timeout, and the custom client
docs say default headers reach the file store too.

Refs PostHog/michaelr-context#87

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

* fix(emails): retry the file store's transient server errors

An upload now retries the file store's 500, 502 and 504 as well as 503,
since a PUT of the same bytes is safe to repeat. PostHog's own 500 is
still final. These answers have kind service_unavailable.

Refs PostHog/michaelr-context#87

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

* refactor(emails): tidy the upload error's doc and test

One doc comment for Upload::request_error, and the signed-URL test
reuses closed_url().

Refs PostHog/michaelr-context#87

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

* test(emails): pin the body estimate to the server's

A body counts like a file: its UTF-8 bytes in Base64 with line breaks,
so 57 ASCII bytes count 78 and 57 two-byte characters count 156. The
upload refusal's doc names every retried status, and the retry rule
names its reason in a helper.

Refs PostHog/michaelr-context#87

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

* fix(emails): report an upload-route 404 as not_found on attachments

A 404 from the upload route means PostHog has no upload route or sending
email from code is off for the project. The client can't tell which, so
it reports PostHog's own answer, not_found when the reply named no code,
on field attachments, and sends nothing.

Refs PostHog/michaelr-context#87

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

* docs(emails): name every cause of an upload-route 404

The NotFound kind covers a send with files to a PostHog without the
upload route, and the guide and mapper name a wrong app_host as a third
cause.

Refs PostHog/michaelr-context#87

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

* fix(emails): keep a supplied client's default headers off the file store

When the app supplies http_client or blocking_http_client, uploads go
through a client the SDK builds, so the app's default headers, such as a
credential, never reach the file store or make it refuse the signed PUT.
The SDK's own client still uploads when the app supplies none.

Refs PostHog/michaelr-context#87

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

* fix(emails): keep PostHog's code on an upload-route 404

The sdk-specs emails spec carries a response's code unchanged, or
api_error when it has none, so an upload-route 404 keeps PostHog's
answer and only names attachments as the field.

Refs PostHog/michaelr-context#87

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

* docs(emails): say what the upload client takes from a supplied one

A supplied client's root certificates, TLS options and in-code proxy don't
reach uploads; the SDK-built client trusts the system roots and takes its
proxy from the environment.

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

* fix(emails): count the subject in the message estimate

The server counts the subject as folded encoded words, 75 bytes for each
42 UTF-8 bytes. Without it, a long subject beside a near-limit file passed
the local check, uploaded, and was refused at send. PostHog/michaelr-context#87

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

* test(emails): prove uploads against a fake that behaves like PostHog

The upload tests now run against an in-memory PostHog and file store that
behave like the real ones: the store keeps a file only when its bytes match
the signed checksum and no other credentials ride along, and PostHog accepts
a send only when each file it names is stored. The size, retry and error-kind
cases move from private helpers to public sends.

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

* fix(emails): build the upload client once per PostHog client

With a supplied HTTP client, each send with files built and dropped its own
upload client: a runtime thread per send for the blocking flavour, and no
connection reuse for either. The PostHog client now keeps the one it built.
Tests' closed_url moves to port 1, so a parallel test's server can't take it.

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

* test(emails): let the fake refuse what PostHog and S3 refuse

The fake now takes only POST and JSON on PostHog's routes, refuses a blank
subject or a send with no body, answers field_not_accepted when files are off,
and issues expired grants or grants for other bytes that its store refuses on
its own. Tests read the store's versions and the accepted emails instead of
request counts, and the size boundary sends real emails with HTML, text or
both.

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

* fix(emails): count derived text and upload only over https

Without text, PostHog sends text it derives from the HTML and counts it in
the size estimate, so the client now counts the HTML twice. An HTML-only
send near the limit was passing the local check, uploading its files, and
then being refused.

A file store URL that isn't https now fails the send on that attachment
before any upload, with a fix to make the store's URL https and no URL in
the error. Only an app_host that is plain http itself, the existing way to
reach a local PostHog, allows plain-http uploads. No new option.

Tests reach the fake PostHog over https through a TLS front with a test
certificate. Also adds a test that a refused upload isn't retried, and
simplifies the fake's subject check.

Refs PostHog/michaelr-context#87

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

* fix(emails): never follow a redirect on an upload

An https upload URL could still leak the file: reqwest follows a 307 or 308
on the PUT and re-sends the body, even from https to http. Every upload now
goes through the client the SDK builds, which follows no redirect, whether or
not the app supplied its own. A 3xx fails that attachment with
attachment_upload_failed, names the attachment but not the URL, and sends
nothing. A signed upload never succeeds through a redirect.

The no-retry test now uses the fake's expired grants and shows the refusal
comes back before the first retry could have run, and the https refusal test
pins its api_error code, which the guide now names.

Refs PostHog/michaelr-context#87

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

* test(emails): redirect uploads to a second behaving store

The redirect tests sent the 307 to a scripted server and counted its
requests. They now send it to a second fake file store that would keep a
correctly signed PUT, and assert that it kept nothing, along with the error,
the empty first store and no send.

Refs PostHog/michaelr-context#87

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

* fix(emails): fail a send whose upload grant overflows the header map

HeaderMap::insert panics past 32,768 entries, which a malformed or
compromised upload-route reply can reach below the reply size limit.
Insert with try_insert so such a reply reads as malformed instead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit to PostHog/posthog-go that referenced this pull request Nov 13, 2026
* feat(emails): send transactional email and read its status

Add Client.Emails(), a port of posthog.emails from posthog-node and
posthog-python. Send takes a TemplateEmail (a template made in PostHog,
by ID) or a CodeEmail (content the app rendered, named by a key), posts
the flat body to /api/projects/@current/messaging/send/email/ on the app
host with the project secret key, and retries with one idempotency key.
Get reads /api/projects/@current/messaging/emails/{id}/. Close waits for
email requests in flight.

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

* refactor(emails): retry one request path, settle the Close test

One request method sends, retries and classifies a reply that isn't a 2xx,
so Send and Get parse a successful reply once. The Close test checks the
retry reached the server before Close returned, instead of a race on the
result channel.

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

* fix(emails): refuse nil emails, keep the context cause, reject trailing bytes

- Send refuses a nil *TemplateEmail or *CodeEmail with invalid_form instead
  of panicking.
- When the context ends during a retry wait, the error wraps ctx.Err(), so
  errors.Is finds context.DeadlineExceeded.
- A reply that isn't JSON as a whole carries no fields, so a 2xx with
  trailing bytes is an api_error.

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

* test(emails): assert the shutdown cause on an interrupted send

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

* fix(emails): encode request bodies with encoding/json

goccy/go-json's MarshalNoEscape hides its argument from escape analysis, so
a body built on the stack can move while it encodes. The Go 1.21 race tests
caught it reading the template body as garbage and panicking. The email
bodies now use the standard library encoder with HTML escaping off, and a
test pins the HTML as written on the wire.

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

* test(emails): close the client in the request timeout test

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

* fix(emails): retry an answer cut off mid-body, whatever its status

A dropped connection after a 409's headers lost idempotency_key_in_flight
and became a non-retryable api_error. A failed body read now is a network
error for every status, so the client retries it with the same
idempotency key.

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

* fix(emails): keep Retry-After on an answer cut off mid-body

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

* feat(emails): send cc, bcc and attachments on a code send

A CodeEmail takes Cc, Bcc and Attachments, matching posthog-node
(PostHog/posthog-js#5514) and posthog-python (PostHog/posthog-python#1172)
and the server contract in PostHog/posthog#127988. An EmailAttachment
carries the file's bytes, which encoding/json sends as standard Base64.
An attachment without a Filename or Content fails with invalid_attachment
before any request, and empty lists stay out of the body, so a server that
doesn't take a field yet only sees it when the send uses it.

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

* fix(emails): address the QA swarm panel on the emails API

- Cap EmailRequestMaxRetries and a send's MaxRetries at 9, as MaxRetries
  and FeatureFlagRequestMaxRetries are, so one send can't retry for hours
  and hold Close.
- Match a PostHog Cloud endpoint written with upper case or the default
  :443 port to its app host.
- Keep Retry-After on a 2xx answer that lacks its message ID or status.
- Check UUIDs with google/uuid instead of a second pattern.
- Merge the caller's context and the shutdown context once per request,
  which removes the separate shutdown branch of the retry wait.
- Move the reasons in function bodies into named helpers, and correct the
  AppHost doc: SecretKey also feeds local flag evaluation on Endpoint.

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

* fix(emails): one attachment type, and the reply an older PostHog sends

EmailAttachment carries its own JSON names, so the code send encodes it
directly. A blank Filename is refused like a blank address. The test for
a field PostHog doesn't take yet replays the reply PostHog/posthog#127123
gives for it.

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

* feat(emails): upload attachments to file storage, then send references

A CodeEmail's attachments no longer ride inline as Base64. Send checks the
10-file and 40 MB message limits locally, hashes each file with SHA-256, asks
PostHog which files it lacks, PUTs those to the presigned URLs without the
secret key, and sends the email naming each file by its hash. A failed upload
stops the send and surfaces the storage's answer.

Refs PostHog/michaelr-context#80

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

* refactor(emails): one attachment type, and refuse an upload slot without a method

Folds the reference type into the attachment the send encodes, refuses an
upload answer that names no method instead of guessing PUT, and tests that the
message limit counts every file together.

Refs PostHog/michaelr-context#80

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

* fix(emails): word file storage's refusal and a broken-off upload answer cleanly

A live run against SeaweedFS showed the storage's message ending in two
periods. A broken-off answer to an upload now names file storage, not
PostHog, and is retried like any network error.

Refs PostHog/michaelr-context#80

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

* fix(emails): ask again for an expired upload URL, upload a file once, retry storage 5xx

Review round 1 on the upload flow:

- An upload URL that expired while earlier files uploaded is asked for again
  before its PUT starts.
- A file attached twice uploads once.
- A storage 500, 502, 503, 504 or 429 is retried within MaxRetries; the daily
  upload quota is not.
- The EmailRequestTimeout doc and the changeset say how long an upload may take
  and what a PostHog without the upload route answers.

Refs PostHog/michaelr-context#80

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

* fix(emails): expire upload URLs by the local clock, name the daily upload quota

An upload URL now expires 15 minutes after PostHog's answer by the client's
own clock, or earlier when PostHog says so, so a clock behind PostHog's
can't keep an expired URL. The daily upload quota gets its own name,
EmailErrorDailyQuotaExceeded. Field names an attachment only when its upload
failed, not when asking PostHog again for its URL failed, and a file PostHog
has by the time it asks again isn't uploaded.

Refs PostHog/michaelr-context#80

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

* test(emails): an upload URL expires 15 minutes after the answer by the local clock

Refs PostHog/michaelr-context#80

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

* fix(emails): honor file storage's Retry-After and retry a stalled upload

A storage refusal keeps its Retry-After, so a throttled upload waits as long
as storage asks, or goes back to the caller when that is longer than the
client retries. S3's RequestTimeout, a 400 for a stalled upload, is retried
like a 5xx. The expiry test now pins the 15-minute lifetime with its own
durations instead of the constant it checks.

Refs PostHog/michaelr-context#80

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

* fix(emails): report the upload route's 404 on Attachments as PostHog answered

A 404 from the upload route comes from a PostHog without the route or a
project that can't send email from code. The client can't tell which, so it
keeps PostHog's not_found and its reason, names Attachments, and sends
nothing.

Refs PostHog/michaelr-context#80

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

* test(emails): keep storage's Retry-After when its answer breaks off

Both broken-off upload tests now flush before aborting, so the client reads
a status and headers and then loses the body, the path they name.

Refs PostHog/michaelr-context#80

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

* fix(emails): keep a storage redirect's signed Location out of upload errors

Go reads a redirect's Location before any redirect policy runs, and quotes a
Location it can't parse in its error. A storage redirect keeps the request's
signature, so an upload error whose cause names a URL now drops that text,
keeping only whether it was a cancellation or a timeout.

Refs PostHog/michaelr-context#80

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

* fix(emails): name storage throttles and upload refusals as the caller knows them

A storage 429 is now a rate limit and S3's RequestTimeout is service
unavailable, so a caller that waits on Name sees the same retry the client
made. An upload route refusal names Attachments or Attachments[i] in Field,
not the route's own files field, which the caller never sent. The duplicate
503 retry test folds into the table test.

Refs PostHog/michaelr-context#80

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

* fix(emails): name the right attachment when asking again for its URL is refused

Asking again for an expired URL lists one file, so the route's files__0__
names whichever attachment that was. The refusal now names it by its place
in Attachments.

Refs PostHog/michaelr-context#80

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

* fix(emails): count the subject in the message size as PostHog does

PostHog now counts a subject as encoded words, 75 bytes per 42 bytes of
UTF-8, so a long subject could push a near-limit email past what PostHog
takes after the SDK let it through. The docs for the storage-backed error
names now say file storage can be the one that answered.

Refs PostHog/michaelr-context#80

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

* test(emails): time an upload URL's life through Send with the client's clock

The local-clock expiry test called private helpers. The email sender now
reads the client's clock, so the test drives Send with a clock that moves
14 minutes during the first upload and sees the second file's URL asked
for again.

Refs PostHog/michaelr-context#80

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

* test(emails): prove attachments by what storage holds and PostHog delivers

The attachment tests asserted signed paths and header strings against a
bucket that took anything. They now run against a fake bucket that, like
S3, stores a PUT only when it matches what PostHog signed, arrives before
the URL expires and carries the signed checksum, and a fake PostHog that
answers uploads from what storage holds, refuses fields and files it
doesn't take, and records the email it would deliver. Each test asserts
the delivered email or the error, and was mutation-checked.

A storage refusal is retried by its name instead of a separate flag, an
upload's network error says no email was sent, the CodeEmail doc no longer
promises PostHog stores nothing, and a file attached twice is uploaded per
attachment, since only bandwidth told the two apart.

Refs PostHog/michaelr-context#80

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

* docs(emails): say each attachment PostHog lacks is uploaded

The changeset still said each missing file uploads once, which held only
while a file attached twice was uploaded once.

Refs PostHog/michaelr-context#80

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

* fix(emails): count the text PostHog derives and upload only over https

Without Text, PostHog derives the plain text from the HTML and counts it
in the message size, so an HTML-only email near the limit uploaded its
files and was then refused. The client now counts the HTML twice, since
the derived text is never longer.

An http upload URL from misconfigured storage would carry the file and
its signed URL unencrypted. The client now stops before any upload
unless AllowInsecureAppHost allows plain HTTP for local development.

Refs PostHog/michaelr-context#80

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

* docs(emails): say a storage wait past 30 seconds goes back to the caller

The changeset said retries wait as long as storage's Retry-After asks;
the client waits up to 30 seconds and hands a longer wait back in
EmailError.RetryAfter.

Refs PostHog/michaelr-context#80

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

* fix(emails): upload a file attached twice once again

A second PUT of bytes storage already holds only added traffic, and a
refusal of it, such as a throttle, failed a send whose file had landed.
The client uploads each hash once again; the test has storage throttle
the redundant upload and the email still goes out.

Refs PostHog/michaelr-context#80

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

* test(emails): prove an upload never follows a storage redirect

A 307 from file storage to another host would re-send the file there,
even over http, and report success. The email client already answers a
redirect with the redirect itself; this test pins that from the outside:
the other host receives nothing, the attachment fails without its URL,
and no email is sent. The changeset says uploads never go through a
redirect.

Refs PostHog/michaelr-context#80

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

* docs(emails): say retries bound uploads and not_found covers attachments

EmailRequestMaxRetries also bounds each attachment upload's retries, so
Ptr(0) turns those off too. EmailErrorNotFound on Attachments means this
PostHog can't take attachments yet.

Refs PostHog/michaelr-context#80

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

* docs(emails): say not_found on Attachments has two causes

A 404 from the upload route means this PostHog can't take attachments
yet or the project can't send email from code, and the client can't tell
which.

Refs PostHog/michaelr-context#80

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit 4a2cbe7 Nov 13, 2026
79 checks passed
@Silthus
Silthus deleted the emails-v2/04-code-fields branch November 13, 2026 07:23
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.

3 participants