Repository navigation
Conversation
- Raise EmailValidationError (EmailError and ValueError) for local checks, with the contract's code and the argument in field, on both forms - Raise client_disabled from a disabled client, and missing_app_host when the client's host is not PostHog Cloud - Wait up to 30 seconds for a longer Retry-After instead of failing - Take max_retries per send, and capture_engagement on the template form - Send migrated_from as X-PostHog-Migrated-From on every email request - Refuse an idempotency key with a trailing newline - Drop replayed from SendEmailResult Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 of 5 tasks
A migrated_from with a line break passed construction, then failed the sync send with a bare ValueError. It must now be visible ASCII. The not_found and idempotency_key docs name the cases a template send with capture_engagement and a reused key run into. Tests now cover a send's max_retries on the upload and the async send, assert the outcome of an override, and read the migrated header from the prepared request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the email send route a wrong template ID answers template_not_found, so the not_found entry now says so for a template send with capture_engagement. The upload test now turns retries on for one send over a client with none, so it fails when the upload ignores them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per the contract's 2026-10-25 entry, capture_engagement defaults to True on both forms and the SDK always sends it. A template send that turns capture off goes to the email send route, since the template route drops fields it doesn't know; a template send that keeps it on stays on the template route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner
Author
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moved here from PostHog#1124, closed on 2026-10-25 so this fork stack is the only copy. It opens upstream again after review.
💡 Motivation and Context
posthog.emails.senddiffers from the shared email SDK contract (PostHog/posthog#120921) in eleven places, in both the template form (PostHog#1093) and the code form (PostHog#1101). A caller who reads the contract, or moves between posthog-node and posthog-python, gets other error codes, other exceptions and missing options.This PR makes both forms follow the contract:
hostisn't PostHog Cloud raisesapp_host_requiredmissing_app_hostRetry-Afterover 30 s fails the send at onceSendEmailResulthas areplayedfieldreplayed: the contract has no line for it, and posthog-node doesn't expose itSendEmailResult(id=None)EmailErrorwithcodeclient_disabled, as posthog-node does.idis always astrTypeErrororValueErrorEmailValidationError, anEmailErrorand aValueError, withnamevalidation_error, the contract'scodeand the argument infieldsend(..., max_retries=0)overrides the client's retriesmigrated_frommigrated_from="resend"onPosthog,AsyncPosthog,EmailsorAsyncEmailssendsX-PostHog-Migrated-Fromon every email requestcapture_engagement, and the code form leaves it out when absentcapture_engagement,trueby default, on both forms.capture_engagement=Falseturns capture off for that sendinvalid_idempotency_keyclient_closedstays: the contract gets a line for it.Impact
Pays into Grow Workflows through email sends from code: the spec's Code-sending orgs metric. Both SDKs now behave alike, so a caller can follow one contract and one set of docs.
migrated_fromlets PostHog tell which sends moved from another provider once send attribution (PostHog/posthog#120628) reads the header. Expected move: small, up. Run cost: n/a.Merge Danger
Warning
🟡 Merge risk: medium · two-way door until a release; a release makes
EmailValidationError,client_disabled,max_retries,capture_engagementandmigrated_froma public contract (merge-risk rubric)TypeError, or checksresult.id is None, breaks.capture_engagement: trueunless the caller passesfalse. A template send withfalsegoes to/messaging/send/email/astemplate: {id}. That route is in feat(messaging): upload, send and lock code-managed email templates PostHog/posthog#118924 and isn't merged, so such a send answers404until it lands. Other template sends keep today's route.Changes
EmailValidationError(EmailError, ValueError)for every local check of both forms.invalid_template_id,invalid_template_key,invalid_idempotency_key, andinvalid_formfor every other argument.fieldnames the argument, such asto,cc,attachments,from_ormax_retries. It'sNonewhen the form itself is wrong, such as bothtemplate_idandtemplate_key.ValueError, soexcept ValueErrorstill catches what used to be aValueError. A caller that catchesTypeErrordoesn't catch it anymore.client_disabled: the disabled check moved next to the shutdown check in_start_send, so a send runs its local checks first, then raisesclient_disabledorclient_closed. Itsnameisapplication_error, per the contract's SDK codes.max(backoff, Retry-After), capped at 30 s.max_retrieson a send overrides the client's, for the send, the upload and the send after it.migrated_from: a client option onPosthogandAsyncPosthog, passed to theiremails, and an option ofEmailsandAsyncEmails. A value that isn't visible ASCII, such as an empty one or one with a line break, raisesValueErrorat construction, liketimeout.capture_engagement, on by default: per the contract's 2026-10-25 entry, it defaults toTrueon both forms, and every send carries it in the body:truewhen the caller leaves it out,falsewhen passed. The docstrings of both forms say a project whose admin turned engagement capture off captures nothing, whatever the send asks.falsebehind a202. A template send withfalsegoes to/messaging/send/email/withtemplate: {id}, which honors it. A template send withtrueor no value stays on/messaging_templates/{id}/send/, so the template form keeps working on today's servers.sendand client docstrings, both changesets, the public API snapshot, and the strict types check (migrated_from,capture_engagementandmax_retrieson a typed send).💚 How did you test it?
Test seams:
Emails.sendandAsyncEmails.sendon a real client with the HTTP transport faked (FakeAdapter,httpx.MockTransport), andPosthog.emailsfor the client options. The tests assert the requests the client makes and what it returns or raises. Each test below failed before its change, except the two retry tests the review added for the upload and the async send, which pin behavior that already held. The upload one fails when the upload ignores a send's retries.test_rejects_invalid_arguments_before_sendingandtest_rejects_a_code_send_that_mixes_or_misses_fields: each case assertsEmailValidationError,ValueError,name,codeandfield, and that no request went out. New cases: a trailing newline in the idempotency key, a negative or booleanmax_retries, and acapture_engagementthat isn't a bool.test_rejects_an_attachment_that_is_not_a_file:invalid_formonattachments, and the content is still never repeated.test_a_disabled_client_refuses_the_sendand its async twin:client_disabled,application_error, no request.test_a_client_with_a_custom_host_never_sends_it_the_secret_key:missing_app_host, no request.test_waits_for_retry_after_up_to_30_seconds:Retry-After: 5waits 5 s, andRetry-After: 120waits 30 s, then the retry succeeds.test_a_send_overrides_the_clients_max_retries: a send's0turns the client's retries off and the send fails, and a send's1turns them on over a client's0and the send succeeds.max_retriesnever reaches the body.test_a_sends_max_retries_also_covers_its_upload: a send's1over a client's0retries a503on the upload, then sends.test_async_send_overrides_the_clients_max_retriescovers the async send.test_every_request_of_a_send_says_where_the_caller_migrated_from: the send, the upload and the second send all carryX-PostHog-Migrated-From. The async upload test and the client options test check it too, and the template send test checks a client without it sends no such header.test_rejects_a_migrated_from_that_is_not_a_header_value: an empty value, a line break, or a number raisesValueErrorwhen the client is built.test_every_send_says_whether_to_capture_engagement: both forms sendcapture_engagement: truewhen the argument is absent andfalsewhen it is passed, and a template send keeps today's route unless it passesfalse.test_a_template_send_that_turns_capture_off_uses_the_email_send_route: the route, thetemplate: {id}body,message_data,identifiersand the flag.pytest(4938 passed, on08bc64b),ruff format --check,ruff check,mypy | mypy-baseline filter, the strict types script, the public API snapshot check, andpython -W error -c "import posthog".Proofs owed:
capture_engagement=Falseagainst a server without feat(messaging): upload, send and lock code-managed email templates PostHog/posthog#118924 answers404. No server PR is merged, so neither direction can run yet.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5) in Claude Code, as a lane of a planned map that Michael directs. Skills used:tdd,merge-risk,pr-description,qa-swarm.Decisions
EmailValidationError(EmailError, ValueError)with the contract's code andfield, on both formsclient_disabled, as posthog-node doesreplayedon the resultemail_request_max_retries, sincemax_retriesis capture's optioncapture_engagementdefaultTrueon every form, always sent;Falseturns it off for that sendinvalid_form, with the argument infieldcapture_engagementroute on the template form before PostHog/posthog#118924 landsFalsegoes to/messaging/send/email/astemplate: {id};Trueor no value stays on the template route, which drops the flag. Replaces: any explicit value went to the new routeOpen: the contract should name
email_request_max_retriesas Python's client option, sincemax_retriesalready configures capture. Recommendation: add it under "Language extras".Involvement: 0 grilling rounds, 0 prototypes, reviews: a QA swarm of 8 panel lanes in round 1, 2 lanes in round 2 and 1 final read (GPT-6.1 Sol and Claude Opus 5.5), then 1 GPT-6.1 Sol review of the
capture_engagementchange, proof: live run and cross-version recorded as gaps.🤖 Generated with Claude Code