Repository navigation
Conversation
This was referenced Oct 22, 2026
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]>
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]>
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 <[email protected]>
Silthus
force-pushed
the
feat/emails-code-form
branch
from
October 23, 2026 13:59
20a55c8 to
2b3413d
Compare
This was referenced Oct 23, 2026
- 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 <[email protected]>
3 of 5 tasks
- 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 <[email protected]>
Author
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions from the round-3 and round-4 reviews of the rebased code form:
|
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, rebased onto its head275a64don 2026-10-23). 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#121272, PostHog/posthog#121867, PostHog/posthog#121851 and PostHog/posthog#121852. 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.subjectmust be a nonempty string.htmlandtextmust be strings, and at least one must have content, so a local template with HTML and an empty text file sends.template_keymust be letters, digits,-and_from start to end, so a key with a trailing newline raisesValueErrorbefore any request. Across the code form, a value of the wrong type raisesTypeErrorand an empty one raisesValueError.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.bytes,bytearrayand anymemoryview(such asBytesIO.getbuffer()) are 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.400 invalid_request, andEmailError.fieldnames the field. A test pins this, and no request repeats.400 transactional_email_pausedand422 idempotency_key_reusedmap like every400and422, with no retry. TheCodeEmailArgsdocstring now says that HTML with no text goes out without a plain-text part, that template and code sends share one set of idempotency keys, and that a retry replays after its workflow is paused._start_send), soshutdown()waits for all three requests, and a send after shutdown raisesclient_closed.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. An emptytextsends and hashes like notext.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_attachment_content_from_any_bytes_buffer_is_base64_encoded:bytearray,memoryviewand a stridedmemoryviewgive the same Base64 asbytes.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_a_server_that_does_not_take_a_field_refuses_the_send_and_names_it: a400 invalid_requestwithattr: ccraisesvalidation_errorwithfield == "cc", after one request. Droppingfieldfrom the error fails it.test_rejects_a_code_send_that_mixes_or_misses_fields: mixed forms, no form, no content, no subject, asubjectorfrom_that is not text, a literalfromkeyword, bad keys (a space, a trailing newline, too long), emptyhtmlandtext, a blankccaddress (ValueError), accthat is not text (TypeError),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(4919 passed, ona6bc0ac),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:
400 invalid_requeston that field. So a server with #121272 but without feat(messaging): send cc, bcc, headers and tags with a code send posthog#121851 or feat(messaging): send attachments with a code send posthog#121852 fails a send that hascc,bcc,headers,tagsorattachments, instead of dropping them behind a202. Sends without those fields still work. A server without #121272 answers404on/messaging/send/email/. None of the server PRs is merged, so neither direction can run yet. Release order is now a usability question, not a data-loss one.275a64dand this PR'sa6bc0ac, sync and async, over 15 server answers: accepted, warnings, replayed,400,400 transactional_email_paused,401,404,409in flight then202,409 template_outdated,422,429withRetry-Afterthen202,429 quota_limited,503three times, a network error then202, and a malformed2xxbody. Requests, headers, bodies, results and errors are byte-identical.📝 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,attachmentshereEmailError.fieldalready carriesattr, so a test pins it and the docstring names itcontentas a list of byte valuesTypeError, since the contract takes only bytes or a Base64 stringhtmlortextbeside a body with content_requesttakes, instead of moving error mapping into_request, so #1093's loop changes the leastInvolvement: 0 grilling rounds, 0 prototypes, 1 design pass before the PR opened (2 findings, both fixed), 4 cross-model review rounds and 2 final reads (round 1: 1 finding, since fixed on the server; round 2: 2 findings, 1 fixed, 1 recorded as the cross-version gap; final read 1: clean; round 3 after the rebase, 8 lanes including 2 team perspectives: 10 findings, 8 fixed, 1 answered with proof, 1 rejected; round 4: 3 findings, 2 fixed, 1 rejected as #1093's code; final read 2: clean), no review bots on this repo, proof: shadow diff captured; live run, cross-version and prod shape recorded as gaps.
Open questions
posthog/emails.py, over the size where a split usually pays. Recommendation: keep one PR, since it is already open and in review, and splitting means new upstream PRs.app_host_required, where the contract saysmissing_app_host. ARetry-Afterover 30 s raises at once, where the contract waits up to 30 s. The result has an extrareplayedfield. A disabled client returnsid=Noneinstead of aclient_disablederror. Local checks raiseTypeErrorandValueError, notvalidation_error. There is no per-sendmax_retries, nomigrated_fromoption orX-PostHog-Migrated-Fromheader, and the template form has nocapture_engagement. The client option isemail_request_max_retries, notmax_retries.client_closedis not in the contract's SDK codes. Its idempotency-key check accepts a trailing newline (this PR fixes the same pattern fortemplate_key). The template form 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/.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