Skip to content

feat: send transactional email with client.emails() - #324

Merged
Silthus merged 10 commits into
mainfrom
posthog/emails-send
Nov 5, 2026
Merged

Silthus merged 10 commits into
mainfrom
posthog/emails-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

💡 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 adds client.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.py plus PostHog/posthog-python#1171).

use posthog_rs::{CodeEmail, TemplateEmail};

// A template made transactional in PostHog, by its ID.
let sent = posthog
    .emails()
    .send(
        TemplateEmail::new("jane@example.com", template_id)
            .template_data(json!({ "name": "Jane" }))
            .distinct_id("user_123")
            .idempotency_key("welcome:user_123"),
    )
    .await?;

// Content the app rendered, such as React Email output, named by a key.
posthog
    .emails()
    .send(
        CodeEmail::new("jane@example.com", "password-reset", "Reset your password")
            .html(rendered_html)
            .distinct_id("user_123"),
    )
    .await?;

let status = posthog.emails().get(&sent.id).await?;

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 new Error::Email variant on the #[non_exhaustive] enum).

  • A release makes these names a public contract.
  • The code form needs the server's code arm of POST /messaging/send/email/. Until it is deployed, a CodeEmail send gets a 400. Release this after the server deploys the code form. The template form works against today's server.

Changes:

  • Two forms, one send. TemplateEmail (template_id, template_data) and CodeEmail (template_key, subject, html and/or text, from, reply_to, capture_engagement) are separate builder types, so a mix of the two forms can't compile. Both take distinct_id, idempotency_key and max_retries. They post the flat body to POST /api/projects/@current/messaging/send/email/. capture_engagement defaults to true.
  • Status read. client.emails().get(message_id) reads GET /api/projects/@current/messaging/emails/{id}/ into EmailStatus, with EmailDeliveryStatus::Other for statuses this version doesn't know.
  • Auth and host. The existing secret_key (a phs_ key with transactional_email:write) is sent as a Bearer token. Requests go to the region's app host, derived from a PostHog Cloud host. A custom host is refused with missing_app_host unless the new app_host is set, so an ingestion proxy never receives the secret key.
  • Errors. One EmailError keeps PostHog's code, the HTTP status, attr as field, why, fix, docs_url and Retry-After. kind() gives the cross-SDK category for 400, 401, 403, 404, 409, 422, 429 and 503. Argument checks return invalid_* codes before any request. EmailError converts into posthog_rs::Error::Email, so ? works in functions that return the crate error; its source() 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].
  • Retries. Network errors, 409 idempotency_key_in_flight, 429 other than quota_limited, and 503 retry email_request_max_retries times (default 2) with jittered backoff of about 1 and 2 seconds, or a longer Retry-After; a Retry-After over 30 seconds fails at once. Every attempt carries the same Idempotency-Key, a UUID v4 when the caller passes none. Each request times out after email_request_timeout_seconds (default 15).
  • Bounded replies. A reply is read up to 1 MiB after decompression. A larger body counts as unreadable: an acceptance is a retryable network_error, and a refusal still counts by its status.
  • Shutdown. shutdown() refuses new email requests with client_closed and waits for requests in flight, retries included, within shutdown_timeout_ms. That holds on a Tokio runtime and off one, such as futures::executor::block_on(client.shutdown()) on a plain thread. A transport callback (before_send) that calls shutdown() doesn't wait, since its own thread would be the one the wait blocks. A disabled client returns client_disabled and sends nothing.
  • Async and blocking. Both clients share the I/O-free half (src/emails/shared.rs); each has a thin Emails handle for its HTTP client.
  • migrated_from (such as "resend") is sent as X-PostHog-Migrated-From, as the reference SDKs do.
  • Docs: a README section, the Emails rustdoc guide, and examples/send_email.rs. Changeset: minor.
  • posthog_rs::Error keeps Send, Sync, UnwindSafe and RefUnwindSafe, as on main; a unit test pins them on Error and EmailError.
  • uuid gains its v4 feature. 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_data on the code form, the content hash, upload by key), headers and tags. cc, bcc and attachments follow in #326, stacked on this PR. The SDK adds no React or rendering dependency: the app renders the email and passes html.

Spec divergence. PostHog/sdk-specs openspec/specs/emails/spec.md covers the template form and lists the code form and capture_engagement as not covered yet. The code form here follows posthog-node#5509 and posthog-python#1171. The template form and get follow the spec. One Rust-shaped difference: the mixed-form refusal (invalid_form in 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_limited and a 60-second Retry-After failing at once, the error mapping for 400/403/404/409/422, a 2xx without 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_seconds cutting 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 the Error::Email conversion.
  • 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 calls shutdown() from before_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_failed from 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) and X-PostHog-Migrated-From match in all six; only User-Agent differs, 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, replayed and warnings parsing) is pinned by the tests above.

Follow-ups, outside this PR's goal: emails reads the client's crate-visible options through one function, shared::connect, rather than through an accessor the client owns; and some unit tests of shared enter private helpers that the integration tests also cover from outside.

📝 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 slice of the transactional email SDK ports, directed by Michael.
  • Decisions: two builder types instead of one options struct, so the forms can't mix; EmailError is one boxed struct with accessors and a kind() enum, returned through Result; argument errors share that type with invalid_* codes; get takes &str; email_request_timeout_seconds is in seconds like the crate's other timeouts.

🤖 Generated with Claude Code

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>

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

Comment thread src/error.rs Resolved
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-rs-v0 Compliance Report

Date: 2026-11-05 11:04:02 UTC
Duration: 15562ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 147ms
Format Validation.Event Has Uuid ✅ 146ms
Format Validation.Event Has Lib Properties ✅ 157ms
Format Validation.Distinct Id Is String ✅ 153ms
Format Validation.Token Is Present ✅ 148ms
Format Validation.Custom Properties Preserved ✅ 145ms
Format Validation.Event Has Timestamp ✅ 152ms
Retry Behavior.Retries On 503 ✅ 5160ms
Retry Behavior.Does Not Retry On 400 ✅ 2157ms
Retry Behavior.Does Not Retry On 401 ✅ 2157ms
Retry Behavior.Respects Retry After Header ✅ 5108ms
Retry Behavior.Implements Backoff ✅ 15111ms
Retry Behavior.Retries On 500 ✅ 5106ms
Retry Behavior.Retries On 502 ✅ 5104ms
Retry Behavior.Retries On 504 ✅ 5100ms
Retry Behavior.Max Retries Respected ✅ 15103ms
Deduplication.Generates Unique Uuids ✅ 103ms
Deduplication.Preserves Uuid On Retry ✅ 5027ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10036ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5044ms
Deduplication.No Duplicate Events In Batch ✅ 26ms
Deduplication.Different Events Have Different Uuids ✅ 24ms
Compression.Sends Gzip When Enabled ✅ 25ms
Batch Format.Uses Proper Batch Structure ✅ 29ms
Batch Format.Flush With No Events Sends Nothing ✅ 51ms
Batch Format.Multiple Events Batched Together ✅ 123ms
Error Handling.Does Not Retry On 403 ✅ 2071ms
Error Handling.Does Not Retry On 413 ✅ 2099ms
Error Handling.Retries On 408 ✅ 5092ms

Feature_Flags Tests

✅ 17/17 tests passed

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

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-rs-v1 Compliance Report

Date: 2026-11-05 11:04:31 UTC
Duration: 22630ms

✅ 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 ✅ 96ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 95ms
Required Headers.Has Authorization Bearer Header ✅ 102ms
Required Headers.Has Content Type Json ✅ 92ms
Required Headers.Has Posthog Sdk Info Format ✅ 97ms
Required Headers.Has Posthog Attempt Header ✅ 97ms
Required Headers.Has Posthog Request Id ✅ 96ms
Required Headers.Has Posthog Request Timestamp ✅ 102ms
Required Headers.Has User Agent ✅ 96ms
Body Format.Body Has Created At And Batch ✅ 96ms
Body Format.No Api Key In Body ✅ 92ms
Body Format.No Sent At In Body ✅ 91ms
Event Format.Event Has Required Root Fields ✅ 91ms
Event Format.Event Uuid Is Valid ✅ 91ms
Event Format.Event Timestamp Is Rfc3339 ✅ 91ms
Event Format.Distinct Id Is String ✅ 90ms
Event Format.Distinct Id At Root Not Properties ✅ 90ms
Event Format.Custom Properties Preserved ✅ 90ms
Event Format.Set Properties Preserved ✅ 88ms
Event Format.Set Once Properties Preserved ✅ 84ms
Event Format.Groups Properties Preserved ✅ 81ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 77ms
Event Format.Event Has Required Root Fields Batch ✅ 111ms
Event Format.Event Uuid Is Valid Batch ✅ 115ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 111ms
Event Format.Distinct Id Is String Batch ✅ 111ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 111ms
Event Format.Custom Properties Preserved Batch ✅ 110ms
Event Format.Set Properties Preserved Batch ✅ 110ms
Event Format.Set Once Properties Preserved Batch ✅ 111ms
Event Format.Groups Properties Preserved Batch ✅ 101ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 101ms
Batch Behavior.Multiple Events In Single Batch ✅ 133ms
Batch Behavior.Batch Envelope Smoke ✅ 109ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 67ms
Batch Behavior.Flush At Triggers Batch ✅ 1090ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 72ms
Deduplication.Generates Unique Uuids ✅ 114ms
Deduplication.Different Events Same Content Different Uuids ✅ 98ms
Deduplication.Preserves Uuid On Retry ✅ 5077ms
Deduplication.Preserves Timestamp On Retry ✅ 5045ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5065ms
Deduplication.No Duplicate Events In Batch ✅ 91ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 48ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 10040ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 5050ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 2045ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 5045ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 27ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 20ms
Response Format Validation.Success No Retry After When All Ok ✅ 23ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 21ms
Response Format Validation.Success No Retry After When Drop Only ✅ 23ms
Response Format Validation.Response Echoes Request Id ✅ 18ms
Retry Behavior.Retries On 408 ✅ 5020ms
Retry Behavior.Retries On 500 ✅ 5016ms
Retry Behavior.Retries On 503 ✅ 5018ms
Retry Behavior.Retries On 504 ✅ 5018ms
Retry Behavior.Retryable Errors Have Retry After ✅ 2022ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 8019ms
Retry Behavior.Does Not Retry On 400 ✅ 2026ms
Retry Behavior.Does Not Retry On 401 ✅ 2020ms
Retry Behavior.Does Not Retry On 402 ✅ 2020ms
Retry Behavior.Does Not Retry On 413 ✅ 2021ms
Retry Behavior.Does Not Retry On 415 ✅ 2018ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2019ms
Retry Behavior.Implements Backoff ✅ 15024ms
Retry Behavior.Max Retries Respected ✅ 15020ms
Partial Batch Handling.Handles 200 Full Success ✅ 2028ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3034ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3024ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3023ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 5024ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 5018ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 5021ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 5018ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 5022ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 5023ms
Partial Batch Handling.Respects Retry After On Partial ✅ 5020ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3018ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3020ms
Compression.Sends Gzip Content Encoding ✅ 17ms
Compression.No Content Encoding When Disabled ✅ 18ms
Compression.Compressed Body Is Decompressible ✅ 17ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2017ms
Event Options.Cookieless Mode Override ✅ 19ms
Event Options.Disable Skew Correction Override ✅ 18ms
Event Options.Process Person Profile Override ✅ 18ms
Event Options.Product Tour Id Override ✅ 18ms
Event Options.Unset Options Omitted ✅ 19ms
Event Options.Options Override In Batch ✅ 17ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 18ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 18ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 18ms

Feature_Flags Tests

✅ 17/17 tests passed

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

Silthus and others added 2 commits November 2, 2026 16:48
- 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>
@Silthus
Silthus marked this pull request as ready for review November 2, 2026 19:01
@Silthus
Silthus requested a review from a team as a code owner November 2, 2026 19:01

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

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

veria-ai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

PR overview

The 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

Silthus and others added 2 commits November 2, 2026 19: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>

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

@stamphog stamphog Bot removed the stamphog label Nov 2, 2026
`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>

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

Comment thread src/error.rs Resolved
…l request

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

@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 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>
Comment thread src/emails/shared.rs
return Some(
cloud_app_host(&explicit)
.map(str::to_string)
.unwrap_or(explicit),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@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 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>
@Silthus
Silthus merged commit 1b43466 into main Nov 5, 2026
31 checks passed
@Silthus
Silthus deleted the posthog/emails-send branch November 5, 2026 12:10
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