Skip to content

feat(emails): send cc, bcc and files by hash with a code send - #326

Open
Silthus wants to merge 36 commits into
mainfrom
posthog/emails-cc-bcc-attachments
Open

Silthus wants to merge 36 commits into
mainfrom
posthog/emails-cc-bcc-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 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.

Server side: PostHog/posthog#131664, PostHog/posthog#131763 and the cdp-api attachment store. Tracked in PostHog/michaelr-context#87.

💡 Motivation and Context

A Rust backend that sends an invoice, a statement or a report through PostHog can't attach the file or copy anyone in: CodeEmail takes one to and the content. PostHog is moving code-send files out of the request: the SDK uploads each file straight to PostHog's file store, then sends the email naming each file by its SHA-256. That lifts the limit from 10 files and 512 KB to 10 files and 40 MB per message after Base64 encoding, about 29 MB of files. This PR gives posthog-rs cc, bcc and attachments on that flow, as the other seven SDKs get it.

use posthog_rs::{CodeEmail, EmailAttachment};

posthog
    .emails()
    .send(
        CodeEmail::new("jane@example.com", "invoice", "Your invoice")
            .html(r#"<img src="cid:logo"><p>Your invoice is attached.</p>"#)
            .cc("finance@example.com")
            .bcc("archive@example.com")
            .attachment(EmailAttachment::new("invoice.pdf", invoice_pdf).content_type("application/pdf"))
            .attachment(EmailAttachment::new("logo.png", logo_png).content_id("logo")),
    )
    .await?;

Warning

🟡 Merge risk: medium · one-way door once released: cc, bcc, attachment, EmailAttachment::new(filename, bytes) and the error codes too_many_attachments, message_too_large and attachment_upload_failed become a public contract.

  • A Rust code send with files fails before it is sent, if the client and the server disagree on the upload flow. A send never goes out without its files: the client sends only after every upload succeeded, and the server checks each file is there. Sends without files take the same path as before.
  • Switch-off: the server's attachments allowlist. A project off it gets field_not_accepted from the upload request, before anything is uploaded or sent.

Rollout

  • Stages: merge only after the merge gate above; then the release, approved in Slack.
  • Decided: ready for review now; merge and release wait for the merge gate.
  • Watch: refusals on the transactional email attachments upload event, by error_code.

Changes:

  • Hash, upload, send. EmailAttachment::new(filename, bytes) hashes the bytes once with SHA-256. On send, the client posts {files: [{sha256, size}]} to /api/projects/@current/messaging/attachments/uploads/. For each file PostHog answers upload, it sends a plain PUT to the returned URL with exactly the returned headers and without the secret key, over https; a file answered present isn't uploaded again, and the same file twice in one email is uploaded once. Once every upload succeeded, it sends the email with attachments: [{sha256, filename, content_type?, content_id?}].
  • Local checks before any request. More than 10 files: too_many_attachments. An email whose estimate is over 40 MB: message_too_large. The estimate follows the server's: each file and each body (its UTF-8 bytes) in Base64 with line breaks, 75 bytes for each 42 bytes of subject, plus 64 KiB for the other headers. Without text, PostHog sends text it derives from the HTML and counts it too, and the derived text is never longer than the HTML, so the client counts the HTML twice. For HTML the check counts raw bytes and is a floor, as spec PostHog/michaelr-context#71 decides: the server sizes rendered HTML, so a rare HTML-heavy send near the limit can pass here and be refused by PostHog, synchronously and with nothing sent. Both are validation_errors naming attachments, with a fix.
  • A refused upload stops the send. The client sends no email and returns attachment_upload_failed with the file store's status, field() attachments__<index>, and the store's answer in why, such as The file store answered BadDigest: .... Network errors and the store's 500, 502, 503 and 504 are retried like PostHog's 503; a PUT of the same bytes is safe to repeat. A failed upload's error never holds the signed URL.
  • Uploads only over https, never through a redirect. Before any upload, an upload URL that isn't https fails the send with api_error on that attachment, a fix to make the file store's URL https, and no URL in the error. The client has no insecure option; its existing way to reach a local PostHog over plain http is a plain-http app_host, and only that lets files upload over plain http. Uploads follow no redirect: a 3xx from the store fails that attachment with attachment_upload_failed and sends nothing, so a redirect can't move the file to a plain-http host. A signed upload never succeeds through a redirect, so nothing that works today breaks.
  • A PostHog without the upload route. A 404 from the upload route has three causes the client can't tell apart: a PostHog without the route, sending email from code being off for the project, and an app_host that isn't PostHog's. The client reports PostHog's own answer on attachments, such as Django's not_found with its reason, or api_error for a 404 that isn't JSON, and sends nothing.
  • Upload timeout. Each PUT gets email_request_timeout_seconds plus one second per 128 KiB of the file, about 4 minutes for a 29 MB file. The guide says so.
  • The upload client. Uploads go through a client the SDK builds once per PostHog client and that follows no redirect, even when the app supplies http_client or blocking_http_client, so the app client's default headers, such as another service's credentials, never reach the file store. The upload client trusts the system's root certificates and takes its proxy from the environment; a root certificate, TLS option or proxy set in code on the app's client doesn't apply to uploads. The guide and both option docs say so.
  • cc and bcc. CodeEmail::cc(address) and CodeEmail::bcc(address) each add one bare address; call them again to add more. The body omits a list with no addresses.
  • No content in logs. EmailAttachment's Debug prints the file name, size, hash, type and ID, never the bytes.
  • Template form unchanged. TemplateEmail takes none of these, because a template sets its own recipients and content.
  • Adds sha2 and bytes (already in the lockfile through reqwest). Docs: the Emails guide's "Copies and attachments" section names the limit and the two steps; docstrings on CodeEmail::attachment and EmailAttachment too. Changeset: minor.

💚 How did you test it?

Test seam: Client::emails().send() on a real async and a real blocking client, against FakePostHog (tests/email_support), an in-memory PostHog and file store that behave like the real ones:

  • Both PostHog routes take only POST, JSON and the secret key.
  • The upload route answers present for a stored file, a presigned PUT otherwise, and field_not_accepted when files are off.
  • The store keeps a file only when the PUT is in time, carries the checksum the grant signed, holds matching bytes and brings no other credentials, as S3 requires.
  • A send needs a subject and a body, and every file it names must be stored.

Tests assert what a caller sees: the bytes and versions in the store, the emails PostHog accepted, and the error.

  • code_send_uploads_the_files_posthog_lacks_then_sends_their_hashes (async; blocking twin): the new file is stored once, the file PostHog had isn't uploaded again, and PostHog accepts the email with cc, bcc and the references.
  • a_refused_upload_sends_nothing_and_shows_the_buckets_answer: a grant for other bytes, which the store refuses as BadDigest; blocking: an expired grant, refused as AccessDenied. The error carries the store's status and answer, and no email is sent.
  • an_upload_url_that_isnt_https_sends_nothing: PostHog reached over https, through a TLS front with a test certificate, names a plain-http store; the send fails with api_error on attachments__0, the error names the file and holds no URL, and nothing is stored or sent. a_plain_http_app_host_lets_files_upload_over_plain_http: the local-development opt-in.
  • a_file_store_redirect_is_never_followed (async, with the SDK's client and a supplied one; blocking twin): the store answers the PUT with a 307 to a second plain-http fake, which receives nothing; the error names the file without either URL, and no send reaches PostHog.
  • a_custom_clients_default_credentials_never_reach_the_file_store (async; blocking twin): a supplied client with a default Authorization. The store, which refuses that beside a signature, keeps the files, and the email is sent. a_custom_clients_uploads_reuse_one_connection_across_sends (async; blocking twin): two sends open one connection to the store.
  • the_same_file_attached_twice_is_uploaded_once, a_file_store_server_error_is_retried_before_the_send (500, 502, 503 and 504, each injected once), a_file_store_server_error_past_the_retries_is_service_unavailable, a_refused_upload_is_not_retried (expired grants: refused in under 5 seconds with four retries left, which would wait at least 12) and a_failed_upload_never_shows_the_signed_url (a refused connection and an unbuildable URL).
  • a_posthog_without_the_upload_route_sends_nothing_with_files (async; blocking twin): Django's 404 comes back as not_found on attachments, and a send without files still goes out. an_app_host_that_isnt_posthog_sends_nothing_with_files: a 404 that isn't JSON is api_error on attachments. a_project_that_cant_send_files_yet_sends_nothing: field_not_accepted with PostHog's field.
  • more_than_ten_files_are_refused_before_any_request and an_email_over_40_mb_after_base64_is_refused_before_any_request. The second sends real emails at the limit:
    • 42 ASCII bytes of subject cost 75, 43 cost 150, and 21 × é cost 75.
    • An empty subject passes the size check, and PostHog then refuses the blank subject.
    • A 57-byte text body costs 78, and 57 two-byte characters cost 156.
    • HTML without text counts twice; HTML and text count together.
  • an_html_only_email_counts_its_html_twice: a 57-byte body fits once beside the file: as text it is sent, as HTML alone it is refused with message_too_large before any upload.
  • Red first: each behaviour test this PR adds failed before its fix, or under a mutation of the behaviour it guards (configured client for uploads, no PUT, no subject term, HTML not counted, HTML counted once without text, no https check, https always required, redirects followed, every upload error retried, the upload request as PUT or without its JSON content type, a 5xx refusal's kind).

Proof at the head, beyond the tests (rerun at 6ad7f20):

  • Live run against a real S3-compatible store. SeaweedFS 4.29, with upload URLs signed exactly as PostHog's attachment store signs them (@aws-sdk/s3-request-presigner, content-length and x-amz-checksum-sha256 as signed headers, 15 minutes), behind a stub of the upload and send routes that reads each referenced file back from the bucket on send. The SDK's real async client:
    • Two new files (300 KB PDF, 2 KB inline PNG) with cc and bcc: both uploaded, then sent by hash; the bucket held both with the SHA-256 the SDK sent.
    • The same two files again: both present, nothing uploaded, sent.
    • A 20 MB file: uploaded in one PUT and read back with a matching hash.
    • A file whose URL was signed for another checksum: SeaweedFS answered 400 BadDigest, the SDK returned attachment_upload_failed with that answer, and sent nothing.
    • 11 files, one byte over the estimate beside a 9-byte subject, the largest file beside a 43-byte subject, and a 57-byte HTML body without text beside a file that fits it once but not twice: refused locally, and the stub got no request. The same body as text: uploaded and sent.
    • The app_host was plain http (127.0.0.1), so SeaweedFS's plain-http upload URLs passed under the local-development rule; the https refusal and the redirect refusal are shown by the tests above.
    • A supplied client with a default Authorization: uploaded through the SDK-built client, read back from the bucket, then sent with the secret key.
  • Older server. The same client against the stub with the upload route switched off, answering Django REST's 404: a send without files is accepted, and a send with a file stops at the upload request with not_found on attachments and PostHog's reason, and nothing is sent.
  • Sends without files unchanged. main's own email tests, which assert each send's exact body and headers, run unchanged against this head's code: 20 of 20 async and 10 of 10 blocking pass.
  • End to end against PostHog itself isn't possible until the server side is live; the opening of files to the first projects proves it with a real send.
  • Lane gate on the head: cargo fmt --check, cargo clippy --tests (default and mcp), lib and doc tests, the async email tests, and the blocking email tests under --no-default-features, with Rust 1.91. CI runs the MSRV 1.78 checks and the public API snapshot.

Divergence from sdk-specs. The published emails spec covers template sends, and its code-form draft (PostHog/sdk-specs#191) leaves cc, bcc and attachments out of scope, so the map's attachments spec (PostHog/michaelr-context#71) is the contract here. The https rule follows #71: Go and JS gate plain-http uploads on an insecure option; posthog-rs has none and adds none, so a plain-http app_host, its existing way to reach a local PostHog, is the opt-in, as in Ruby. Two behaviours differ from the published spec's general rules: an upload to the file store also retries the store's 500, 502 and 504, which the spec's retry rule doesn't list because it covers PostHog's answers only; and a 404 from the upload route keeps PostHog's own code, as the spec's error rule says, but names attachments as the field.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written by Claude Code, Claude Opus 5.5 (claude-opus-5-5), as the posthog-rs stack of PostHog/michaelr-context#87, directed by Michael.
Question Answer Who Where
Held until files are open to everyone? Yes: draft until the server side is live and open in both regions 👤 Human decisions
cc and bcc in this PR? Yes, with the attachments 👤 Human decisions
When to hash? Once, in EmailAttachment::new 🤖 Agent decisions
Keep from_base64? No: the server takes only uploaded files 🤖 Agent decisions
Upload timeout? Email timeout plus one second per 128 KiB 🤖 Agent decisions
Which HTTP client PUTs? A client the SDK builds, following no redirect, so the app client's default headers never reach the store and a redirect can't move the file. This reverses round 1's choice after a review found an app's credential reached the store; the cost, the app client's in-code TLS and proxy, is documented 🤖 Agent decisions
Size check on a send without files? None, as on the server 🤖 Agent decisions
A refused upload? attachment_upload_failed with the store's status and answer 🤖 Agent decisions
Same file twice in one email? Uploaded once 🤖 Agent decisions
Plain-http uploads allowed? Only with a plain-http app_host; no new option 🤖 Agent decisions
Upload route field names? Passed through, not renamed 🤖 Agent decisions

Involvement: one design pass (Design and Anti-Overengineering, GPT-6.1 Sol); eleven cross-model review rounds and two final reads (Opus 5.5 and GPT-6.1 Sol), rounds 6 to 9 and 11 the full QA swarm with the Team lane perspectives (Michael's and SDK spec conformance), round 11 on the head before its last test fix; PostHog Review; live run, older-server run and base-test run captured at the head (6ad7f20).

🤖 Generated with Claude Code

Silthus and others added 12 commits November 2, 2026 15:52
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>
- 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>
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>
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>
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>
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>
`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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 2 commits November 4, 2026 07:25
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>
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>
@Silthus
Silthus marked this pull request as ready for review November 4, 2026 08:14
@Silthus
Silthus requested a review from a team as a code owner November 4, 2026 08:14

@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-adding the stamphog label gives the same result unless the changed files change.

Gates denied automated approval for a manifest/toolchain classification and mandatory manual routing, although the manifest contains no changed executable hooks. The risky areas have no independent assurance; request review from a teammate, Codex, or Claude and have a maintainer assess the gate denial before re-requesting.

  • Trusted gate results deny automated approval because the manifest was classified as a toolchain change and the change requires manual routing. The actual Cargo.toml hunks change dependency availability and feature membership, not scripts or lifecycle hooks; request human review and maintainer assessment of the gate classification.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: deps_toolchain (scripts/hooks changed in Cargo.toml)
size ✓ 156L, 5F substantive, 315L/11F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (315L, 11F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ b910bcd · reviewed head b910bcd

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

…-attachments

# Conflicts:
#	README.md
#	api/public-api.txt
#	examples/README.md
#	src/client/async_client.rs
#	src/client/blocking.rs
#	src/client/mod.rs
#	src/emails/async_emails.rs
#	src/emails/blocking_emails.rs
#	src/emails/guide.md
#	src/emails/mod.rs
#	src/emails/shared.rs
#	src/emails/types.rs
#	src/lib.rs
#	tests/test_emails.rs
#	tests/test_emails_blocking.rs
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

posthog-rs-v0 Compliance Report

Date: 2026-11-12 07:19:43 UTC
Duration: 15473ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 106ms
Format Validation.Event Has Uuid ✅ 113ms
Format Validation.Event Has Lib Properties ✅ 112ms
Format Validation.Distinct Id Is String ✅ 104ms
Format Validation.Token Is Present ✅ 105ms
Format Validation.Custom Properties Preserved ✅ 103ms
Format Validation.Event Has Timestamp ✅ 103ms
Retry Behavior.Retries On 503 ✅ 5113ms
Retry Behavior.Does Not Retry On 400 ✅ 2112ms
Retry Behavior.Does Not Retry On 401 ✅ 2103ms
Retry Behavior.Respects Retry After Header ✅ 5098ms
Retry Behavior.Implements Backoff ✅ 15101ms
Retry Behavior.Retries On 500 ✅ 5089ms
Retry Behavior.Retries On 502 ✅ 5097ms
Retry Behavior.Retries On 504 ✅ 5096ms
Retry Behavior.Max Retries Respected ✅ 15093ms
Deduplication.Generates Unique Uuids ✅ 94ms
Deduplication.Preserves Uuid On Retry ✅ 5016ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10025ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5022ms
Deduplication.No Duplicate Events In Batch ✅ 20ms
Deduplication.Different Events Have Different Uuids ✅ 16ms
Compression.Sends Gzip When Enabled ✅ 17ms
Batch Format.Uses Proper Batch Structure ✅ 17ms
Batch Format.Flush With No Events Sends Nothing ✅ 16ms
Batch Format.Multiple Events Batched Together ✅ 72ms
Error Handling.Does Not Retry On 403 ✅ 2044ms
Error Handling.Does Not Retry On 413 ✅ 2047ms
Error Handling.Retries On 408 ✅ 5056ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 42ms
Request Payload.Flags Request Uses V2 Query Param ✅ 35ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 28ms
Request Payload.Flags Request Omits Authorization Header ✅ 22ms
Request Payload.Token In Flags Body Matches Init ✅ 32ms
Request Payload.Groups Round Trip ✅ 23ms
Request Payload.Groups Default To Empty Object ✅ 45ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 34ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 27ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 23ms
Request Lifecycle.No Flags Request On Init Alone ✅ 18ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 48ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 32ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 27ms
Retry Behavior.Retries Flags On 502 ✅ 236ms
Retry Behavior.Retries Flags On 504 ✅ 235ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 35ms

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

posthog-rs-v1 Compliance Report

Date: 2026-11-12 07:19:57 UTC
Duration: 23047ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

✅ 94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 154ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 147ms
Required Headers.Has Authorization Bearer Header ✅ 151ms
Required Headers.Has Content Type Json ✅ 153ms
Required Headers.Has Posthog Sdk Info Format ✅ 155ms
Required Headers.Has Posthog Attempt Header ✅ 154ms
Required Headers.Has Posthog Request Id ✅ 138ms
Required Headers.Has Posthog Request Timestamp ✅ 151ms
Required Headers.Has User Agent ✅ 154ms
Body Format.Body Has Created At And Batch ✅ 153ms
Body Format.No Api Key In Body ✅ 149ms
Body Format.No Sent At In Body ✅ 142ms
Event Format.Event Has Required Root Fields ✅ 137ms
Event Format.Event Uuid Is Valid ✅ 134ms
Event Format.Event Timestamp Is Rfc3339 ✅ 137ms
Event Format.Distinct Id Is String ✅ 134ms
Event Format.Distinct Id At Root Not Properties ✅ 134ms
Event Format.Custom Properties Preserved ✅ 135ms
Event Format.Set Properties Preserved ✅ 134ms
Event Format.Set Once Properties Preserved ✅ 134ms
Event Format.Groups Properties Preserved ✅ 136ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 138ms
Event Format.Event Has Required Root Fields Batch ✅ 180ms
Event Format.Event Uuid Is Valid Batch ✅ 170ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 167ms
Event Format.Distinct Id Is String Batch ✅ 169ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 169ms
Event Format.Custom Properties Preserved Batch ✅ 165ms
Event Format.Set Properties Preserved Batch ✅ 168ms
Event Format.Set Once Properties Preserved Batch ✅ 178ms
Event Format.Groups Properties Preserved Batch ✅ 160ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 156ms
Batch Behavior.Multiple Events In Single Batch ✅ 195ms
Batch Behavior.Batch Envelope Smoke ✅ 185ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 116ms
Batch Behavior.Flush At Triggers Batch ✅ 1133ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 127ms
Deduplication.Generates Unique Uuids ✅ 218ms
Deduplication.Different Events Same Content Different Uuids ✅ 129ms
Deduplication.Preserves Uuid On Retry ✅ 5131ms
Deduplication.Preserves Timestamp On Retry ✅ 5083ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5100ms
Deduplication.No Duplicate Events In Batch ✅ 140ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 77ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 10109ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 5070ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 2077ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 5066ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 59ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 38ms
Response Format Validation.Success No Retry After When All Ok ✅ 36ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 39ms
Response Format Validation.Success No Retry After When Drop Only ✅ 36ms
Response Format Validation.Response Echoes Request Id ✅ 28ms
Retry Behavior.Retries On 408 ✅ 5036ms
Retry Behavior.Retries On 500 ✅ 5029ms
Retry Behavior.Retries On 503 ✅ 5028ms
Retry Behavior.Retries On 504 ✅ 5026ms
Retry Behavior.Retryable Errors Have Retry After ✅ 2027ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 8025ms
Retry Behavior.Does Not Retry On 400 ✅ 2032ms
Retry Behavior.Does Not Retry On 401 ✅ 2029ms
Retry Behavior.Does Not Retry On 402 ✅ 2025ms
Retry Behavior.Does Not Retry On 413 ✅ 2028ms
Retry Behavior.Does Not Retry On 415 ✅ 2026ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2026ms
Retry Behavior.Implements Backoff ✅ 15033ms
Retry Behavior.Max Retries Respected ✅ 15026ms
Partial Batch Handling.Handles 200 Full Success ✅ 2027ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3039ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3041ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3037ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 5032ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 5029ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 5028ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 5027ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 5032ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 5035ms
Partial Batch Handling.Respects Retry After On Partial ✅ 5028ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3026ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3033ms
Compression.Sends Gzip Content Encoding ✅ 31ms
Compression.No Content Encoding When Disabled ✅ 24ms
Compression.Compressed Body Is Decompressible ✅ 24ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2024ms
Event Options.Cookieless Mode Override ✅ 25ms
Event Options.Disable Skew Correction Override ✅ 25ms
Event Options.Process Person Profile Override ✅ 25ms
Event Options.Product Tour Id Override ✅ 25ms
Event Options.Unset Options Omitted ✅ 25ms
Event Options.Options Override In Batch ✅ 25ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 23ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 24ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 24ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 16ms
Request Payload.Flags Request Uses V2 Query Param ✅ 16ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 15ms
Request Payload.Flags Request Omits Authorization Header ✅ 19ms
Request Payload.Token In Flags Body Matches Init ✅ 15ms
Request Payload.Groups Round Trip ✅ 16ms
Request Payload.Groups Default To Empty Object ✅ 16ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 20ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 16ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 18ms
Request Lifecycle.No Flags Request On Init Alone ✅ 14ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 24ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 18ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 17ms
Retry Behavior.Retries Flags On 502 ✅ 221ms
Retry Behavior.Retries Flags On 504 ✅ 218ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 25ms

@Silthus
Silthus marked this pull request as draft November 9, 2026 03:25
@Silthus

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

On hold, and back to draft. The attachments API moves to upload first, then send: the SDK hashes each file, uploads it, and sends a reference instead of Base64 content. This PR gets reworked to that shape, cc and bcc included, and nothing merges until the server side is live.

Silthus and others added 2 commits November 9, 2026 22:01
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>
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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 20b98d3: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

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

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed fba7570: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

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

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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.

Comment thread src/emails/async_emails.rs Resolved
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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 10b6ce1: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

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

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 6ad7f20: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

@Silthus
Silthus marked this pull request as ready for review November 10, 2026 10:25
@veria-ai

veria-ai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

PR overview

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

Fixed/addressed: 2 · PR risk: 0/10

Reviews

@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-adding the stamphog label gives the same result unless the changed files change.

stamphog can't auto-approve this pull request, and it will need a human reviewer. Two gates refused it. The deny-list gate flagged the deps_toolchain category, because Cargo.toml and Cargo.lock add new dependencies (sha2, bytes, and tokio-rustls). The tier gate classified the change as T2-never: a cross-cutting feature touching 22 files and 2173 lines, including a new public API and new TLS test fixtures.

The size gate passed, so splitting is optional. If you want a smaller review, you could separate the dependency changes from the feature code. Otherwise, ask a human maintainer to review it.

  • posthog[bot] reviewed the current head.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: deps_toolchain
size ✓ 803L, 11F substantive, 3 binary; 2173L/22F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (2173L, 22F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head 6ad7f20

Comment thread src/emails/shared.rs Resolved
Silthus and others added 2 commits November 12, 2026 07:11
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>

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

@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-adding the stamphog label gives the same result unless the changed files change.

stamphog can't auto-review this pull request because two gates refused it. The deny-list gate flagged the dependency changes in Cargo.toml and Cargo.lock (new sha2, bytes and tokio-rustls entries) as a toolchain/dependency match. The tier gate classified the change as T2-never: it is a cross-cutting feature touching 22 files and about 2,220 lines, including the email client, public API surface and TLS test fixtures.

Please ask a human reviewer to look at this pull request. Splitting it up, for example by separating the dependency changes from the feature work, may make human review easier, but it won't change this refusal.

  • posthog[bot] reviewed the current head.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: deps_toolchain
size ✓ 807L, 11F substantive, 3 binary; 2220L/22F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (2220L, 22F, cross-cutting, feat)
stamphog 2.8.0 .stamphog/policy.yml @ unknown · reviewed head 1bcb657

Comment thread src/emails/shared.rs Resolved

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants