Repository navigation
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code form of emails.send takes the new optional fields of the contract. cc and bcc accept one address or a list and are always sent as a list. Attachment bytes are Base64-encoded once, before the retry loop. A list of byte values is refused with a TypeError that names bytes(). None of the fields reach the template upload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server accepts an empty address list, and the SDK checks types only, so a computed empty list of copies no longer raises before the send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PostHog that doesn't take a field refuses the send with invalid_request and names it in EmailError.field, which a test now pins. HTML with no text goes out without a plain-text part. Template and code sends share one set of idempotency keys, and a retry replays after its workflow is paused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Base64-encode attachment content from bytearray and memoryview too - Refuse a template_key with a trailing newline before the send - Take an empty html or text when the other body has content - Raise TypeError for a cc, bcc or reply_to that is not text - Inject the async test transport through http_client Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Base64-encode a non-contiguous memoryview through bytes() - Raise TypeError for a subject or from_ that is not a string - Name bytearray and memoryview in the changeset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
Author
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions made while building this PR:
|
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.
💡 Motivation and Context
posthog.emails.senddiffers from the shared email SDK contract (PostHog/posthog#120921) in eleven places, in both the template form (#1093) and the code form (#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_engagementsend(template_id=..., capture_engagement=False)invalid_idempotency_keyclient_closedstays: the contract gets a line for it.Note
Review only the commits after
a6bc0ac. This PR stacks on #1101, which stacks on #1093. GitHub can't base a pull request on a branch in a fork, and #1101's branchfeat/emails-code-formlives in a fork. So this PR targets #1093's branchposthog/transactional-emails, and its diff also shows #1101's commits. It retargets to #1101's base once #1101 merges. The stack is visible on the fork, where Silthus#3 sits on Silthus#2.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_engagementgoes to/messaging/send/email/astemplate: {id}. That route is in feat(messaging): upload, send and lock code-managed email templates posthog#118924 and isn't merged, so such a send answers404until it lands. Sends without it 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_engagementon the template form: the template route drops fields it doesn't know (DRF's default), so the flag would be lost behind a202. A template send that sets it goes to/messaging/send/email/withtemplate: {id}, which takes it. A template send without it stays on/messaging_templates/{id}/send/. Once feat(messaging): upload, send and lock code-managed email templates posthog#118924 lands, the contract moves every template send to the new route.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_a_template_send_that_sets_capture_engagement_uses_the_email_send_route: the route, thetemplate: {id}body,message_data,identifiersand the flag.pytest(4933 passed, on97abc5d),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_engagementagainst a server without feat(messaging): upload, send and lock code-managed email templates 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 optioninvalid_form, with the argument infieldcapture_engagementon the template form before PostHog/posthog#118924 lands/messaging/send/email/astemplate: {id}, since the template route drops unknown fieldsOpen: 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), proof: live run and cross-version recorded as gaps.
🤖 Generated with Claude Code