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.
| cargo/posthog-rs: minor | ||
| --- | ||
|
|
||
| A `CodeEmail` takes copies and files: `cc` and `bcc` each add one address, and `attachment` adds an `EmailAttachment`. `EmailAttachment::new` takes the file's bytes and encodes them as Base64 once; `EmailAttachment::from_base64` sends content you already encoded as is. Add `content_type` and `content_id`, and reference a `content_id` as `cid:<id>` in the HTML to show the file inline. PostHog can't fetch a file from a path or URL, and an attachment's `Debug` output never shows its content. A `TemplateEmail` takes none of these, because the template sets its own recipients and content. A PostHog version that doesn't take one of these fields yet refuses the send with an error that names the field, and never sends the email without it. |
There was a problem hiding this comment.
i think we can merge both changesets into just done? its the same feat, or maybe you should merge main into this branch to fix conflicts?
…-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-06 08:09:53 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-06 08:10:17 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
|
💡 Motivation and Context
A Rust backend that sends an invoice, a receipt or a report through PostHog can't attach the file or copy anyone in today:
CodeEmailtakes onetoand the content, nothing more. posthog-node and posthog-python are gainingcc,bccandattachmentson the code form (PostHog/posthog-js#5514, PostHog/posthog-python#1172), backed by the server contract in PostHog/posthog#127988. This PR brings posthog-rs to the same shape.Stacked on #324, which adds
client.emails(). Review that one first.Note
Merge risk: low, one-way door once released. Every change is additive: three builder methods on
CodeEmailand a newEmailAttachmenttype. A send that uses none of them sends the same body as before.validation_errornaming the field, and the email isn't sent. Release this after the server deploys the fields.Changes:
ccandbcc.CodeEmail::cc(address)andCodeEmail::bcc(address)each add one bare address; call them again to add more. The body carries a list, and omits the field when there are none, as posthog-python does.CodeEmail::attachment(EmailAttachment)adds a file.EmailAttachment::new(filename, bytes)encodes the bytes as standard Base64 once;EmailAttachment::from_base64(filename, content)sends content the caller already encoded as is.content_typeandcontent_idare optional; acontent_idshows the file inline ascid:<id>. The body carries{filename, content, content_type?, content_id?}per file, the server contract's shape.EmailAttachment'sDebugprints the file name, type and ID, and the content only as<N Base64 characters>, so a printedCodeEmailnever dumps a file.TemplateEmailtakes none of these, because a template sets its own recipients and content and the server refuses them on that form.ccandbcc, and at most 10 files with 512 KB of file content in total (counted before Base64), and refuses blocked file types such as.exe. The docs say so, and the SDK passes the server's400 invalid_requestthrough as avalidation_errorwhosefield()names the address or file (cc__0,attachments__0__filename), withwhyandfix. Checking the limits in the SDK too would make two sources of truth.pathkey, unknown keys and wrong types withinvalid_attachment. HereEmailAttachmenthas no such fields, so those mistakes don't compile.base64becomes a regular dependency (it was optional, for themcpfeature).Cargo.lockis unchanged.Emailsguide, the README example, and a doc example onEmailAttachment. Changeset: minor.💚 How did you test it?
Test seams:
Client::emails()on a real async and a real blocking client against the recording httpmock server from #324.code_send_carries_copies_and_attachments(async and blocking) pins the body: twocc, onebcc, a binary PDF that isn't valid UTF-8 encoded once (JVBERgCf/w==) with itscontent_type, and a pre-encoded PNG with acontent_id, sent unchanged.a_printed_send_never_shows_the_filepins theDebugoutput.a_server_without_a_field_refuses_the_send_and_names_itpins what an older server's refusal looks like to the caller: avalidation_errorwithfield() == Some("attachments"), after one request and no retry.Proof at the head, beyond the tests:
cc, onebcc, a binary PDF and an inline PNG (content_id("logo")): all four recipients got the email. Theccheader lists both copies; thebccaddress is only in the envelope, with noBccheader. Both files arrived byte-identical (sha256 of each against the file sent), the PNG inline ascid:logo.from_base64arrived byte-identical..exefile:400,validation_errorwithfield()attachments__0__filename, pluswhyandfix. No email went out.cc("Jane <jane@example.com>"):400,field()cc__0. No email went out.cc,bccand files got400 validation_errorwithfield()attachments, and no email went out, not even without the files. A send with only accgot the same refusal namingcc, and one with only abccnamedbcc; neither reached any address. A plain send to the same server was delivered. The testa_server_without_a_field_refuses_the_send_and_names_itscripts that recorded reply.cc, empty lists, and one file with every field. Method, path, JSON body (including each file's Base64 and the omitted empty fields) and the meaningful headers match in all four; onlyUser-Agentdiffers, by SDK name.reply_toaddresses, built against this PR's base and against its head, sent identical requests (method, path, body, every compared header) in all seven.mcp), public API snapshot, the MSRV 1.78 checks, and the lib, doc, async email, blocking email andmcptests.📝 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 slice of the transactional email SDK ports, directed by Michael.ccandbccadd one address per call, like the crate's other builders, instead of taking a list;EmailAttachmenthas two constructors (bytes, or Base64 already encoded) instead of the reference SDKs' "bytes or string" union, so the caller says which one they hold; limits are left to the server.🤖 Generated with Claude Code