Repository navigation
Conversation
This was referenced Oct 24, 2026
A send with template_key carries subject and html, text or both, flat at the top of the body with distinct_id. With template_data, the content is a local template sent by key and content hash, uploaded when PostHog answers template_outdated. A code send also takes from_, reply_to, cc, bcc, headers, tags, attachments and max_retries, and captures engagement unless it passes capture_engagement=False. A template_id send doesn't take capture_engagement. migrated_from on the client or on Emails goes out as X-PostHog-Migrated-From on every email request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus
changed the base branch from
base/posthog-transactional-emails
to
main
October 31, 2026 08:25
Silthus
force-pushed
the
feat/emails-code-form
branch
from
October 31, 2026 08:25
a6bc0ac to
b80b8ab
Compare
A 409 template_edited_in_posthog from a local-template upload is a permanent refusal, not a concurrent request, so it carries the name validation_error like template_outdated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostHog stores a local template on upload, and its upload route needs an HTML body. A text-only local template got 409 template_outdated, then a 400 on the upload, on every send. The client now raises ValueError before any request, as posthog-node refuses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wrapper that forwards its own optional values passed text=None or template_data=None, and the code form raised TypeError, while distinct_id=None and the template form's template_data=None already counted as left out. The code form's optional fields now do too, and their types say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docstring names None as a left-out field, and the error names the html rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 2, 2026
Closed
Draft
Owner
Author
|
Moved to PostHog#1171. |
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 toemails.send, beside the template form that PostHog#1093 shipped in 7.81.0. The SDK contract is PostHog/sdk-specsopenspec/specs/emails/spec.md, plus the draft changeadd-emails-code-formfor the code form.Part of map Silthus/posthog#461. Rebased onto
mainafter PostHog#1093 merged, so this diff holds only the code form. It absorbs what the spec allows from the closed #3.Impact
Pays into Grow Workflows through email sends from code: the spec's Code-sending orgs metric, not measured yet (send attribution 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
Caution
🔴 Merge risk: high · one-way door once released (merge-risk rubric)
template_key,from_,reply_to,capture_engagement,cc,bcc,headers,tags,attachments,max_retries), themigrated_fromclient option and theEmailTagandEmailAttachmentshapes a public contract.409branch could upload or send twice. Tests pin one upload at most per send and the same idempotency key on the second send.send/email/with the flat body, which is not on PostHog master yet. Release this after it.Changes
main)send.TemplateEmailArgsandCodeEmailArgstype the keyword arguments withUnpack, one@overloadeach, onEmailsandAsyncEmails. A send with both forms, neither form, or a field of the other form raisesTypeErrorbefore any request.template_data): a flat body toPOST /messaging/send/email/:template_key,subject,html,text,from,reply_to,capture_engagement,to,distinct_id. It never uploads.template_data): the client hashessubject,htmlandtextwith the server'scontent_hashv1 and sendstemplate: {key, hash},message_dataandidentifiers.distinct_id. On409 template_outdatedit uploads by key (PUT /messaging_templates/by_key/{key}/,if_not_superseded,if_code_managed) and sends once more with the same body andIdempotency-Key. A refused upload (409 template_edited_in_posthog) raises avalidation_errorand sends nothing. A local template withouthtmlraisesValueErrorbefore any request, because PostHog stores it and a stored template needs HTML.Nonecounts as left out for an optional code-form field (html,text,from_,reply_to,cc,bcc,headers,tags,attachments,capture_engagement,template_data), as it already did fordistinct_id. A wrapper can pass its own optional values through.capture_engagementis always sent there,Trueunless the call passesFalse. The template form refuses it withTypeError.ccandbccas lists,headers,tags, andattachmentswithbytes-like content Base64-encoded once, before the retry loop.migrated_fromon the client, sent asX-PostHog-Migrated-Fromon every email request and refused at construction unless it is visible ASCII; andmax_retrieson a code send, as the template form has it.mainhas them: aRetry-Afterover 30 s raises at once,replayedandwarningsstay on the result, and argument checks raiseTypeErrororValueError.💚 How did you test it?
Test seams:
Emails.sendandAsyncEmails.sendon a real client with the HTTP transport faked, as PostHog#1093's tests do (FakeAdapter,httpx.MockTransport). The tests assert the requests the client makes and what it returns or raises. The type seam is.github/scripts/check_strict_types.shunder pyright strict.distinct_idandcapture_engagement; the hash against the server's v1 test vectors; upload then resend with one key, sync and async; at most one upload per send, with a refused upload namedvalidation_error; no upload for rendered content; copies, headers, tags and attachments on both sends of a local template and never on the upload; Base64 once across retries; eachTypeErrorandValueErrorrefusal, including a local template withouthtml;Nonefor every optional code-form field sent as left out;capture_engagementrefused on the template form;migrated_fromonsendandget, and refused when blank or not text;max_retrieson a code send.migrated_fromheader, the capture default, the template-form capture refusal or the code send'smax_retrieseach fails a test.e5fdedf:ruff format --check,ruff check,mypy | mypy-baseline filter, the strict types script, the public API snapshot, and the fullpytestsuite (5936 passed).main(dca2207) against1fef822: 22 invented inputs throughEmails.sendwith the transport faked, recording every request and result. The 12 template-form cases are identical. The 10 that differ are all code-form sends, whichmainrefuses and this PR sends as described above.e5fdedfonly drops two comments.cigap); the lane gate above runs the same steps.Proofs owed:
400 sender_unavailablewithout one, even when the send passesfrom_.send/email/. An older server answers400, which raisesvalidation_error.📝 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 lane sdk3 of map Map: send code-managed email templates with one PostHog call posthog#461, directed by Michael. Rebuilt frommain'sposthog/emails.pywith the reviewed code form of the earlier feat: send email content from code with posthog.emails.send #2, because a three-way rebase conflicted in most hunks.fleet proof.Decisions
cc,bcc,headers,tags,attachmentsherefromfrom_nametemplate_keydistinct_idgoes on a code sendidentifiers.distinct_idTypeErrorandValueError, as 7.81.0 has them; the typed validation error of #3 is not portedmigrated_from,max_retrieson a code send, and the capture default on the code form only; not its Retry-After clamp or the removal ofreplayed, which the spec contradictsNonefor an optional code-form fielddistinct_idand in posthog-node; the alternative was to document that the code form never takesNonehtml, checked before any request, since PostHog can't store a template without it409 template_edited_in_posthogisvalidation_error, as contract #120921 saysNo open questions.
Follow-ups:
sendand its checks; split it if a reviewer asks.reply_to,ccorbcclist is sent as[], as posthog-node does. An older server branch refusedreply_to: []withvalidation_erroronreply_to; the live run checks the restacked server.Involvement: carries the earlier #2 review rounds and design pass. This rebuild: one full-scope GPT-6.1 Sol read at
b80b8ab80(2 findings: 1 fixed in3f2428df8, 1 rejected because the flatdistinct_idis the round's decision); a QA swarm at3f2428df8with a full two-family panel (GPT-6.1 Sol on six lenses, Claude Opus 5.5 fresh eyes: 3 fixed) and a narrow round on the fixes (comments dropped); shadow diff captured; live run owed to lane dj3.🤖 Generated with Claude Code