Repository navigation
feat: send transactional email with client.emails() - #324
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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed f59fa45: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
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.
posthog-rs-v0 Compliance ReportDate: 2026-11-05 11:04:02 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-05 11:04:31 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
|
- 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>
PR overviewThe PR adds transactional email support through client.emails(), including handling custom app hosts for email requests. One issue has been addressed, and one transport-security concern remains open. When a non-loopback HTTP app host is explicitly configured, authenticated email requests send the project secret and email content without TLS, exposing them to network interception. The exposure depends on that configuration; requiring HTTPS would close the remaining gap. Open issues (1)
Fixed/addressed: 1 · PR risk: 4/10 |
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>
There was a problem hiding this comment.
Not approved yet — waiting on the conditions below.
Re-add the stamphog label to request another review once you have addressed this.
stamphog declined to auto-review this pull request because it failed the size gate. It has 2291 substantive lines across 13 files, which is over the 1200-line ceiling (3553 lines across 21 files once docs, generated files and snapshots are counted). Most of that is new code in src/emails/ (shared.rs alone is over 1000 lines) and the new test files. The prerequisites, deny-list and tier gates all passed, so size is the only reason for the refusal.
The author can ask a human reviewer to take this one. Alternatively, split it into smaller pull requests, for example the types and error handling first, then the template send, then CodeEmail, the status read and the shutdown handling, each under the ceiling.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✗ | too large for auto-review (2291L substantive in global — ceiling is 1200L; 2291L, 13F total, 3553L/21F incl. docs/generated/snapshots) |
| tier | ✓ | T1-agent / T1d-complex (3553L, 21F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 0202d53 |
`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>
…l request Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Not approved yet — waiting on the conditions below.
Re-add the stamphog label to request another review once you have addressed this.
stamphog declined to auto-review this pull request because of the size gate. It has 2329 substantive lines of change, and the ceiling is 1200. Most of that is the new src/emails/ module (about 1,000 lines in shared.rs alone) plus the new test files. The other gates passed, but size is a hard limit.
The author can ask a human reviewer to take this one. Alternatively, they could split it into smaller pull requests, for example the types and error handling first, then the send and retry logic, then the status read, with tests and docs alongside each.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✗ | too large for auto-review (2329L substantive in global — ceiling is 1200L; 2329L, 13F total, 3620L/21F incl. docs/generated/snapshots) |
| tier | ✓ | T1-agent / T1d-complex (3620L, 21F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 66246d2 |
… reads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| return Some( | ||
| cloud_app_host(&explicit) | ||
| .map(str::to_string) | ||
| .unwrap_or(explicit), |
There was a problem hiding this comment.
Medium: Require TLS for custom email app hosts
This branch accepts an explicitly configured non-loopback HTTP app host unchanged, and connect() still attaches the project secret; the SDK-created clients permit HTTP because reqwest's HTTPS-only mode is disabled by default, so that configuration sends the key and email content without TLS. (docs.rs) As transport-security hardening, require HTTPS before constructing authenticated email requests, retaining local HTTP only through an explicit development opt-in.
There was a problem hiding this comment.
Not approved yet — waiting on the conditions below.
Re-add the stamphog label to request another review once you have addressed this.
stamphog declined to auto-review this pull request because it failed the size gate. It has 2331 substantive lines across 13 files, above the 1200-line ceiling for auto-review (3626 lines across 21 files once docs, generated files and snapshots are counted). The other gates passed, so size is the only reason for the refusal.
You can ask a human reviewer to look at it. Or you could split it into smaller pull requests, for example the shared types and error handling, then the async and blocking send/get implementations, then the tests, README and example. Each part would need to come in under the ceiling to be considered again.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✗ | too large for auto-review (2331L substantive in global — ceiling is 1200L; 2331L, 13F total, 3626L/21F incl. docs/generated/snapshots) |
| tier | ✓ | T1-agent / T1d-complex (3626L, 21F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 8843f7d |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
💡 Motivation and Context
A Rust backend can't send transactional email through PostHog today: posthog-node and posthog-python have
emails.send, posthog-rs has nothing. This PR addsclient.emails()with the same two send forms and the status read, ported from posthog-node (packages/node/src/emails/plus PostHog/posthog-js#5509) and posthog-python (posthog/emails.pyplus PostHog/posthog-python#1171).Part of the transactional email from code work; the API contract is PostHog/posthog#120921.
Caution
Merge risk: medium, one-way door once released. Nothing existing changes behavior: every change is additive (new types, a new
Client::emails, four new client options, a newError::Emailvariant on the#[non_exhaustive]enum).POST /messaging/send/email/. Until it is deployed, aCodeEmailsend gets a400. Release this after the server deploys the code form. The template form works against today's server.Changes:
send.TemplateEmail(template_id,template_data) andCodeEmail(template_key,subject,htmland/ortext,from,reply_to,capture_engagement) are separate builder types, so a mix of the two forms can't compile. Both takedistinct_id,idempotency_keyandmax_retries. They post the flat body toPOST /api/projects/@current/messaging/send/email/.capture_engagementdefaults totrue.client.emails().get(message_id)readsGET /api/projects/@current/messaging/emails/{id}/intoEmailStatus, withEmailDeliveryStatus::Otherfor statuses this version doesn't know.secret_key(aphs_key withtransactional_email:write) is sent as a Bearer token. Requests go to the region's app host, derived from a PostHog Cloudhost. A customhostis refused withmissing_app_hostunless the newapp_hostis set, so an ingestion proxy never receives the secret key.EmailErrorkeeps PostHog'scode, the HTTP status,attrasfield,why,fix,docs_urlandRetry-After.kind()gives the cross-SDK category for400,401,403,404,409,422,429and503. Argument checks returninvalid_*codes before any request.EmailErrorconverts intoposthog_rs::Error::Email, so?works in functions that return the crate error; itssource()is the email error's own cause, so an error chain prints each layer once.SendEmail, the enum both forms convert into, is#[non_exhaustive].409 idempotency_key_in_flight,429other thanquota_limited, and503retryemail_request_max_retriestimes (default 2) with jittered backoff of about 1 and 2 seconds, or a longerRetry-After; aRetry-Afterover 30 seconds fails at once. Every attempt carries the sameIdempotency-Key, a UUID v4 when the caller passes none. Each request times out afteremail_request_timeout_seconds(default 15).network_error, and a refusal still counts by its status.shutdown()refuses new email requests withclient_closedand waits for requests in flight, retries included, withinshutdown_timeout_ms. That holds on a Tokio runtime and off one, such asfutures::executor::block_on(client.shutdown())on a plain thread. A transport callback (before_send) that callsshutdown()doesn't wait, since its own thread would be the one the wait blocks. A disabled client returnsclient_disabledand sends nothing.src/emails/shared.rs); each has a thinEmailshandle for its HTTP client.migrated_from(such as"resend") is sent asX-PostHog-Migrated-From, as the reference SDKs do.Emailsrustdoc guide, andexamples/send_email.rs. Changeset: minor.posthog_rs::ErrorkeepsSend,Sync,UnwindSafeandRefUnwindSafe, as on main; a unit test pins them onErrorandEmailError.uuidgains itsv4feature. It compiled without it only because a dev-dependency switched it on.Out of scope, as in the reference SDKs: local templates from code (
template_dataon the code form, the content hash, upload by key), headers and tags.cc,bccand attachments follow in #326, stacked on this PR. The SDK adds no React or rendering dependency: the app renders the email and passeshtml.Spec divergence. PostHog/sdk-specs
openspec/specs/emails/spec.mdcovers the template form and lists the code form andcapture_engagementas not covered yet. The code form here follows posthog-node#5509 and posthog-python#1171. The template form andgetfollow the spec. One Rust-shaped difference: the mixed-form refusal (invalid_formin node) is a compile error here, because the two forms are different types.💚 How did you test it?
Test seams:
Client::emails()on a real async and a real blocking client against an httpmock server that answers a scripted sequence and records every request (tests/email_support), plus unit tests of the I/O-free half (argument checks, bodies, reply parsing, error kinds, retry policy, app host, in-flight tracking).tests/test_emails.rs(async, 20 tests) pins both bodies and every header, a retry that reuses the generated UUID v4 key, the client and per-send retry limits,quota_limitedand a 60-secondRetry-Afterfailing at once, the error mapping for 400/403/404/409/422, a2xxwithout a message ID, the sends that make no request (no key, custom host, disabled, bad template ID), network errors, the status read, shutdown waiting for a send in its retry backoff (on the runtime and from a plain thread off it), shutdown keeping to a 100 ms timeout,email_request_timeout_secondscutting off a slow server even when a custom HTTP client has a longer timeout, a reply over 1 MiB, reading stopping at that cap on a server that never finishes its body, and theError::Emailconversion.tests/test_emails_blocking.rs(blocking, 10 tests) covers the same paths on the blocking client.shutdown_from_worker_callback_waits_for_no_email_request(unit) holds an email request open and callsshutdown()frombefore_send: it returns at once instead of waiting out the 10 s shutdown timeout.Proof at the head: the full CI matrix run locally (fmt, both clippy runs, public API snapshot, the build and MSRV rows, publish dry run, all 8 test rows), and a live run of the SDK's real reqwest client against a local recording server and against PostHog Cloud (US and EU) with a made-up key, which answers
401 authentication_failedfrom each region's app host.Shadow diff against the reference SDK: posthog-python (PostHog/posthog-python#1171) and this branch ran the same six inputs (template with every option, minimal template, uppercase template ID, code send with every option, text-only code send, status read) against one local recorder. Method, path, JSON body,
Authorization,Accept,Content-Type,Idempotency-Key(or its generated UUID v4 shape) andX-PostHog-Migrated-Frommatch in all six; onlyUser-Agentdiffers, by SDK name.Not proved here: an authenticated send, replay and status read against a real project, since no project secret key or verified sender was available for this port. Replay and dedup are server behavior; the SDK side (one key on every attempt,
replayedand warnings parsing) is pinned by the tests above.Follow-ups, outside this PR's goal:
emailsreads the client's crate-visible options through one function,shared::connect, rather than through an accessor the client owns; and some unit tests ofsharedenter private helpers that the integration tests also cover from outside.📝 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.EmailErroris one boxed struct with accessors and akind()enum, returned throughResult; argument errors share that type withinvalid_*codes;gettakes&str;email_request_timeout_secondsis in seconds like the crate's other timeouts.🤖 Generated with Claude Code