Repository navigation
Conversation
emails.send takes an optional category on the template_key form. Left out or TRANSACTIONAL_CATEGORY sends transactional email; any other value is the key of a marketing message category in the project. The SDK sends the field only when the caller sets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-python Compliance ReportDate: 2026-11-12T14:48:36.161893+00:00 ✅ All Tests Passed!270/270 tests passed Capture_V1 Tests✅ 95/95 tests passed View Details
Capture_Ai Tests✅ 5/5 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
Feature_Flags_V4 Tests✅ 149/149 tests passed View Details
Feature_Flags_Local_Evaluation Tests✅ 4/4 tests passed View Details
|
SDK performance: posthog-pythonBenchmarks completed. No changes flagged for review. Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate. View all results
Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals. Each shard uses its own runner; compare Before PR against With PR within a row, not across shards. Compared 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions for this PR, updated for revision 2 of the contract in PostHog/posthog#132118:
|
category on a code send is now TRANSACTIONAL_CATEGORY or a MessageCategory with a key, type, name and optional description. The server creates or updates the category by key, so the wire carries the object instead of a bare key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed faafa5a: 1 Must fix.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
The server keeps a category's description when the field is missing and clears it on an empty string, so the SDK sends "" as "" and leaves the field out only for None. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The category tests now send through an in-memory fake of PostHog's code send that refuses fields outside the contract's schema and upserts categories by key. They assert on the stored category and the recorded send instead of the request body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code send whose template_key names a template follows that template's category when category is left out, and PostHog refuses an explicit TRANSACTIONAL_CATEGORY on a template in a category. The fake now models template-bound keys, and the docs no longer call the two the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… creation A send that names a category now creates it when the project has none with that key, and otherwise leaves it as it is. Drop the update and the keep-or-clear description wording, name copies_not_allowed among the refusals, and prove a later send leaves the stored category unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decided 2026-11-12. The second line replaces "Are
|
A template in a marketing category is refused with marketing_template, so the docs and changeset name that code, and the test fake seeds a template in a transactional category for follow-the-template and category_mismatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decided 2026-11-12.
|
💡 Motivation and Context
Developers who send email from code with
emails.sendcan only send transactional email. A team that moves a marketing email from a workflow to a code send loses its message category, so recipients' opt-outs and unsubscribe links stop applying.emails.sendnow takes an optionalcategoryon the code form (the one withtemplate_key). It isTRANSACTIONAL_CATEGORYor aMessageCategorythat defines the category in code. A send creates the category when the project has none with its key, or restores a deleted one, and otherwise leaves it as it is. Itsnameanddescriptionapply only at creation; change them later in PostHog.Depends on PostHog/posthog#132118, which pins the API contract (revision 2) and adds the server side. Release once customers can use it.
Caution
🔴 Merge risk: high · two-way door at merge, one-way at release: once
posthogandposthoganalyticsship,category,MessageCategoryandTRANSACTIONAL_CATEGORYare public API.category, in both regions. A send withoutcategorykeeps today's body byte for byte.ac055eeis create-only, so it does. This PR still releases only after the backend is live and a real send passes.template_keythat names a template in a marketing category getsmarketing_template(400), so no template send can skip opt-outs. Template categories are parked.workflows-code-send-categoriesflag off, the server refuses every category object.Rollout
category_mismatch,too_many_categories,unsubscribe_link_required,copies_not_allowed,recipient_opted_outandmarketing_templateon the server's send route.MessageCategoryis a frozen dataclass:key,type: Literal["marketing", "transactional"],nameand an optionaldescription.TRANSACTIONAL_CATEGORY: Final = "transactional"is the built-in value. Both are exported fromposthog.emails.category: NotRequired[Optional[Union[Literal["transactional"], MessageCategory]]]onCodeEmailArgs. A type checker refuses a bare key string such as"newsletter", the shape from the first revision, and an unknowntype."transactional", or{"key", "type", "name"}plusdescriptionunless it isNone. An emptydescriptiongoes out as"";Noneleaves the field out. Either only matters when the send creates the category.category=Nonecounts as left out, like the other optional code-send fields.categoryis the constant or aMessageCategory,typeis one of the two values, and the fields are strings. It raisesTypeErrororValueErrorbefore any request. The server owns key format and lengths.template_id) doesn't takecategory, the same as in the other SDKs. A template send follows its template's category. So does a code send whosetemplate_keynames a template, when it leavescategoryout; PostHog refusesTRANSACTIONAL_CATEGORYthere if the template is in a category.template_keythat names a template in a marketing category is refused withmarketing_template(400), whatevercategorysays. The module docstring, thecategorydocstring and the changeset say so.EmailError.codewithname == "validation_error", likerecipient_suppressed:category_mismatch,too_many_categories,unsubscribe_link_required,copies_not_allowed,marketing_template(400) andrecipient_opted_out(422). The SDK keeps no list of codes, so the docstrings name them.MessageCategorydocstring and theCodeEmailArgsdocstring. The minor changeset is updated.💚 How did you test it?
Lane gate on
8590215, all green:ruff format --check .,ruff check ., mypy against the baseline, the fullpytestsuite (6258 passed),python -W error -c "import posthog"andmake public_api_check. CI on8590215: 49 checks pass, among them.github/scripts/check_strict_types.sh(pyright strict).Emails.sendagainstFakeSendApi, an in-memory fake of PostHog's code send. It refuses fields outside the contract, creates a category by key and never changes one that exists, refuses a changedtypewithcategory_mismatch, refuses a marketing body without{{ unsubscribe_url }}, and refuses atemplate_keythat names a template in a marketing category withmarketing_template. The tests assert on what the fake stored and sent:NoneorTRANSACTIONAL_CATEGORYis transactional and creates no categorycategory_mismatchand nothing is senttemplate_keythat names a template in a transactional category, a left-out orNonecategory follows the template's category, andTRANSACTIONAL_CATEGORYis refused withcategory_mismatchtemplate_keythat names a template in a marketing category is refused withmarketing_templateand nothing is sent. Red first: this test failed against the fake before it refused marketing templatestype, a non-string field, orcategoryon the template form fails before a requestnullfor an unset one, leaving outtype, never sending the category, rejecting a transactional category, dropping an explicitTRANSACTIONAL_CATEGORY, and sending"transactional"for a left-out category each fail a test. Putting the old rename back in the fake fails the create-only test.MessageCategoryandTRANSACTIONAL_CATEGORY. It rejectscategory="newsletter",type="promo", andcategoryon the template form.AsyncEmailsand a standaloneEmails, sending over HTTP to a local stub server that answers in the contract's format. The raw request bodies:None: nocategorykeyTRANSACTIONAL_CATEGORY:"category":"transactional""category":{"key":"newsletter","type":"marketing","name":"Newsletter","description":"Monthly product news"}"category":{"key":"receipts","type":"transactional","name":"Receipts"}"category":{"key":"newsletter","type":"marketing","name":"Newsletter","description":""}EmailErrorwith codescategory_mismatch,too_many_categories,unsubscribe_link_requiredandrecipient_opted_out, andname="validation_error"d8667f6(create-only categories), said the backend at22a68ffstill updates a category's name and description on a send. Rejected for this PR: that is the backend half of the same decision, and it lands in feat(messaging): send code emails in a marketing message category posthog#132118 before this PR can release. The SDK's wire is unchanged. The backend is now create-only atac055ee, which settles it.8590215(templates are transactional only): no findings.d8667f6; re-recording them on8590215waits on Michael.Live run output (stub server)
📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code, Claude Opus 5.5 (
claude-opus-5-5), built test-first. This PR is one of 8 SDK PRs that build to the contract pinned in PostHog/posthog#132118. Revision 2 of that contract changedcategoryfrom a key string to a category object that the send defines in code.categorycarry?"transactional"or a category object that the send upserts by key, which replaces the bare key stringemails.sendform takescategory?marketing_template. Template categories are parkednameanddescriptionapply only when the send creates the category. Replaces "creates or updates"MessageCategorywithLiteralfortype, plus aFinalconstantTRANSACTIONAL_CATEGORY, instead of a TypedDictcopies_not_alloweddocs needed?ccorbcc"Involvement: no grilling rounds, no prototypes; five GPT-6.1 Sol review rounds and three final reads, and PostHog Review on each head through
8919451; live-run proof against a stub server captured.🤖 Generated with Claude Code