Repository navigation
Conversation
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>
…g/emails-cc-bcc-attachments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l request Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g/emails-cc-bcc-attachments
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>
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
posthog-rs-v0 Compliance ReportDate: 2026-11-12 07:19:43 UTC ✅ All Tests Passed!46/46 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
posthog-rs-v1 Compliance ReportDate: 2026-11-12 07:19:57 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
|
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. |
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
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.
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
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 |
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>
There was a problem hiding this comment.
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 |
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:
CodeEmailtakes onetoand 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-rscc,bccand attachments on that flow, as the other seven SDKs get it.Warning
🟡 Merge risk: medium · one-way door once released:
cc,bcc,attachment,EmailAttachment::new(filename, bytes)and the error codestoo_many_attachments,message_too_largeandattachment_upload_failedbecome a public contract.field_not_acceptedfrom the upload request, before anything is uploaded or sent.Rollout
transactional email attachments uploadevent, byerror_code.Changes:
EmailAttachment::new(filename, bytes)hashes the bytes once with SHA-256. Onsend, the client posts{files: [{sha256, size}]}to/api/projects/@current/messaging/attachments/uploads/. For each file PostHog answersupload, it sends a plainPUTto the returned URL with exactly the returned headers and without the secret key, over https; a file answeredpresentisn't uploaded again, and the same file twice in one email is uploaded once. Once every upload succeeded, it sends the email withattachments: [{sha256, filename, content_type?, content_id?}].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 arevalidation_errors namingattachments, with afix.attachment_upload_failedwith the file store's status,field()attachments__<index>, and the store's answer inwhy, such asThe file store answered BadDigest: .... Network errors and the store's500,502,503and504are retried like PostHog's503; a PUT of the same bytes is safe to repeat. A failed upload's error never holds the signed URL.api_erroron 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-httpapp_host, and only that lets files upload over plain http. Uploads follow no redirect: a3xxfrom the store fails that attachment withattachment_upload_failedand 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.404from 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 anapp_hostthat isn't PostHog's. The client reports PostHog's own answer onattachments, such as Django'snot_foundwith its reason, orapi_errorfor a404that isn't JSON, and sends nothing.email_request_timeout_secondsplus one second per 128 KiB of the file, about 4 minutes for a 29 MB file. The guide says so.http_clientorblocking_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.ccandbcc.CodeEmail::cc(address)andCodeEmail::bcc(address)each add one bare address; call them again to add more. The body omits a list with no addresses.EmailAttachment'sDebugprints the file name, size, hash, type and ID, never the bytes.TemplateEmailtakes none of these, because a template sets its own recipients and content.sha2andbytes(already in the lockfile throughreqwest). Docs: theEmailsguide's "Copies and attachments" section names the limit and the two steps; docstrings onCodeEmail::attachmentandEmailAttachmenttoo. Changeset: minor.💚 How did you test it?
Test seam:
Client::emails().send()on a real async and a real blocking client, againstFakePostHog(tests/email_support), an in-memory PostHog and file store that behave like the real ones:POST, JSON and the secret key.presentfor a stored file, a presigned PUT otherwise, andfield_not_acceptedwhen files are off.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 withcc,bccand the references.a_refused_upload_sends_nothing_and_shows_the_buckets_answer: a grant for other bytes, which the store refuses asBadDigest; blocking: an expired grant, refused asAccessDenied. 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 withapi_erroronattachments__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 a307to 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 defaultAuthorization. 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) anda_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 asnot_foundonattachments, and a send without files still goes out.an_app_host_that_isnt_posthog_sends_nothing_with_files: a 404 that isn't JSON isapi_erroronattachments.a_project_that_cant_send_files_yet_sends_nothing:field_not_acceptedwith PostHog's field.more_than_ten_files_are_refused_before_any_requestandan_email_over_40_mb_after_base64_is_refused_before_any_request. The second sends real emails at the limit:écost 75.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 withmessage_too_largebefore any upload.Proof at the head, beyond the tests (rerun at 6ad7f20):
@aws-sdk/s3-request-presigner,content-lengthandx-amz-checksum-sha256as 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:ccandbcc: both uploaded, then sent by hash; the bucket held both with the SHA-256 the SDK sent.present, nothing uploaded, sent.400 BadDigest, the SDK returnedattachment_upload_failedwith that answer, and sent nothing.app_hostwas 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.Authorization: uploaded through the SDK-built client, read back from the bucket, then sent with the secret key.404: a send without files is accepted, and a send with a file stops at the upload request withnot_foundonattachmentsand PostHog's reason, and nothing is sent.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.cargo fmt --check,cargo clippy --tests(default andmcp), 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
emailsspec 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-httpapp_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's500,502and504, which the spec's retry rule doesn't list because it covers PostHog's answers only; and a404from the upload route keeps PostHog's own code, as the spec's error rule says, but namesattachmentsas the field.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5), as the posthog-rs stack of PostHog/michaelr-context#87, directed by Michael.ccandbccin this PR?EmailAttachment::newfrom_base64?attachment_upload_failedwith the store's status and answerapp_host; no new optionInvolvement: 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