Repository navigation
Conversation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This was referenced Oct 22, 2026
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 <[email protected]>
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 <[email protected]>
This branch has not been deployed
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
A Python backend that renders its own email, or keeps its email templates in its code, can't send through PostHog today.
emails.sendtakes only a template made in PostHog, by its ID.This PR adds the code form of the shared SDK contract in PostHog/posthog#120921 to
emails.send, beside the template form of #1093:Note
Stacked on #1093 (
posthog/transactional-emails). It retargets to the default branch once #1093 merges.The server side is the send-api stack in PostHog/posthog: PostHog/posthog#118924, PostHog/posthog#121083, PostHog/posthog#121084 and PostHog/posthog#121272. None of them is merged, so this PR builds against the contract at the HTTP seam.
Impact
Pays into Grow Workflows through email sends from code: the spec's Code-sending orgs metric. It is not measured yet: send attribution (PostHog/posthog#120628) has to count
posthog-python/as an SDK. In scope: Messaging, email. Expected move: small, up. Python callers get the same code form as posthog-node. Run cost: n/a.Merge Danger
Warning
🟡 Merge risk: medium · two-way door until a release; a release makes the keyword names (
template_key,from_,reply_to,capture_engagement,cc,bcc,headers,tags,attachments) and theEmailTagandEmailAttachmentshapes a public contract (merge-risk rubric)sendnow takes**kwargstyped by twoTypedDictoverloads. The template form keeps its arguments and request, but the dispatch in_preparenow runs for every send.409branch could upload or send twice. Tests pin one upload at most per send and the same idempotency key on the second send.Changes
send.TemplateEmailArgsandCodeEmailArgstype the keyword arguments withUnpack, one@overloadeach, onEmailsandAsyncEmails. mypy and pyright reject a send that mixes the forms or missessubject. At runtime, a mixed or unknown argument raisesTypeErrorbefore any request.template_data):POST /messaging/send/email/withtemplate_key,subject,html,text. It never uploads.template_data): the client hashessubject,htmlandtextwith the server'scontent_hashv1. It sendstemplate: {key, hash}plusmessage_data.409 template_outdated. The client uploads by key withPUT /messaging_templates/by_key/{key}/,if_not_supersededandif_code_managed. Then it sends once more with the same body andIdempotency-Key. The upload carries noIdempotency-Keyand no link to the code. A refused upload (409 template_edited_in_posthog) raises and sends nothing.from_(sincefromis a Python keyword) becomesfrom. Areply_tostring becomes a one-item list.capture_engagementis left out when absent, so the server default applies.ccandbcctake one address or a list and are always sent as a list. An empty list is sent as is, like an emptyreply_to.headersis adict[str, str].tagsis a list ofEmailTag({name, value}).attachmentsis a list ofEmailAttachment({filename, content, content_type?, content_id?}). Each is left out when absent, and none reaches the template upload.bytesare Base64-encoded once, before the retry loop, so a retry resends the same body. Astris sent as is. A list of byte values (Resend's Node shape) raisesTypeErrorthat namesbytes(content), and no error repeats the content. The SDK checks types only: the server holds the limits.409maps toconcurrent_idempotent_requestsonly for the in-flight code, and tovalidation_errorotherwise, as the contract's error table says. Before, every409other thantemplate_outdatedwasconcurrent_idempotent_requests._requeston each class, so the send and the upload share retries. feat: send transactional email from posthog templates #1093's recipient checks moved into_recipient, so both forms share them. Neither move changes the template form's behavior.invalid_types.py).Parity against posthog-node and the server
posthog-node's code form (slice 10 of the same map) is not built yet, so this table compares with the contract, posthog-node's draft PostHog/posthog-js#5312, and the server route in PostHog/posthog#121272.
to,template_key,subject,html,text,from,reply_to[],capture_engagement,identifiers.distinct_idSendEmailSerializertakes the same fieldsto,template: {key, hash},message_data, sender fields,identifierscc[],bcc[],headers{},tags[{name, value}]on both code-form bodiesattachments[{filename, content, content_type?, content_id?}],contentin Base64Bufferv1:+ SHA-256 of the length-prefixed subject, preheader, html, text, line endings normalizedcontent_hashv1PUT by_key/{key}/withname,subject,html,text,if_not_superseded,if_code_managedif_code_managed: truename: subjectand asource; this PR sendsname: template_keyand nosourceIdempotency-Keyand body404 template_not_found; the contract and PostHog/posthog#121272 answer409 template_outdatedfor an unknown key, so this PR does notJSON.stringifyoutput, so the bytes matchUser-Agentposthog-python/<version>posthog-node/<version>💚 How did you test it?
Test seams:
Emails.sendandAsyncEmails.sendon a real client with the HTTP transport faked, as #1093's tests do (FakeAdapter,httpx.MockTransport). The tests assert the requests the client makes and what it returns or raises.test_rendered_content_is_posted_as_is: one case per code-form mapping row (html only, text only, every field, severalreply_to, empty address lists).test_a_local_template_is_sent_by_key_and_content_hash: the hash equals the server'scontent_hashv1 test vectors, including the Windows line-ending vector.test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_keyand its async twin: send, upload, send, with one idempotency key and the same body.test_a_local_template_uploads_at_most_once_per_send: a refused upload, and a secondtemplate_outdated, each raisevalidation_errorwith no further request.test_rendered_content_is_never_uploaded.test_rendered_content_carries_copies_headers_and_tagsandtest_a_local_template_sends_copies_headers_and_tags_but_never_uploads_them: the new fields reach both sends, and the upload body stays the same.test_attachments_are_sent_base64_encoded_once_across_retries: a503then a202, with the same Base64 body on both tries.test_rejects_an_attachment_that_is_not_a_file: a list of byte values, a missing filename, an unknown field. No request is made, and the message does not repeat the content.test_rejects_a_code_send_that_mixes_or_misses_fields: mixed forms, no form, no content, no subject, a literalfromkeyword, bad keys, a blankccaddress,ccon the template form,template_datathat is not a mapping..github/scripts/check_strict_types.shnow type-checks both forms onEmailsandAsyncEmailsunder pyright strict. It also asserts errors on five wrong sends: both forms at once, a code send withoutsubject, an async mix,ccon the template form, and an attachmentcontentthat is a list of ints. mypy reports the same "no overload matches" errors.pytest(4906 passed),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:
cc,bcc,headers,tagsandattachments. None of the server PRs is merged, so neither direction can run yet. Condition: release this SDK only after #121851 and #121852 deploy.📝 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 the build lane of a planned map that Michael directs. Skills used:tdd,impact,merge-risk,pr-description.EmailErrorwith the contract's fields, the same retry loop,clean()fortemplate_data.Decisions
fromfrom_nametemplate_key, since the code form has no name and a Liquid subject reads badly in the library404 template_not_found409 template_outdated, per the contractcc,bcc,headers,tags,attachmentsherecontentas a list of byte valuesTypeError, since the contract takes only bytes or a Base64 stringInvolvement: 0 grilling rounds, 0 prototypes, 1 design pass before the PR opened (2 findings, both fixed), 2 cross-model review rounds (round 1: 1 finding, open below; round 2: 2 findings, 1 fixed, 1 recorded as the cross-version gap), proof: live run and cross-version recorded as gaps.
Open questions
app_host_required, where the contract saysmissing_app_host. There is noapp_hostclient option (Emails(secret_key, host=...)instead), nomigrated_fromoption orX-PostHog-Migrated-Fromheader, and no per-sendmax_retries. A disabled client returnsid=Noneinstead of aclient_disablederror. The template form has nocapture_engagement, and it stays on/messaging_templates/{id}/send/until feat(messaging): upload, send and lock code-managed email templates posthog#118924 lands.json.dumpsdefaults (spaces,\uescapes). The code form uses compact UTF-8, so it matches posthog-node byte for byte. Recommendation: switch the template form too when it moves to/messaging/send/email/.html. A local template with onlytextgetstemplate_outdated, then its upload fails with400, and nothing is sent. The SDK builds to the contract's "html and/or text" here. The fix is going into the upload route on feat(messaging): send email rendered by the caller's code by key posthog#121272.validation_errorand codes such asinvalid_form. This PR raisesTypeErrororValueErrorbefore any request, as feat: send transactional email from posthog templates #1093's checks do. Recommendation: decide this for both forms at once, in feat: send transactional email from posthog templates #1093's error model.from_as the Python spelling, and say whether the uploadnameis the key.🤖 Generated with Claude Code