Repository navigation
Conversation
Leaving category out, or passing Emails::CATEGORY_TRANSACTIONAL, sends transactional email. Any other value is the key of one of the project's marketing message categories. The SDK sends the field only when set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 33b4463: 1 Should 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.
posthog-php-fork_curl Compliance ReportDate: 2026-11-12T14:45:03.670365+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 33ms |
| Format Validation.Event Has Uuid | ✅ | 528ms |
| Format Validation.Event Has Lib Properties | ✅ | 529ms |
| Format Validation.Distinct Id Is String | ✅ | 530ms |
| Format Validation.Token Is Present | ✅ | 530ms |
| Format Validation.Custom Properties Preserved | ✅ | 530ms |
| Format Validation.Event Has Timestamp | ✅ | 530ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 530ms |
| Retry Behavior.Retries On 503 | ✅ | 5852ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2533ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2533ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6345ms |
| Retry Behavior.Implements Backoff | ✅ | 16274ms |
| Retry Behavior.Retries On 500 | ✅ | 5646ms |
| Retry Behavior.Retries On 502 | ✅ | 5647ms |
| Retry Behavior.Retries On 504 | ✅ | 5645ms |
| Retry Behavior.Max Retries Respected | ✅ | 16278ms |
| Deduplication.Generates Unique Uuids | ✅ | 534ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5644ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10863ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5649ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 539ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 531ms |
| Compression.Sends Gzip When Enabled | ✅ | 535ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 531ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 518ms |
| Batch Format.Multiple Events Batched Together | ✅ | 521ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2532ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2535ms |
| Error Handling.Retries On 408 | ✅ | 5646ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 810ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 522ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 522ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 522ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 522ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 522ms |
| Request Payload.Groups Round Trip | ✅ | 522ms |
| Request Payload.Groups Default To Empty Object | ✅ | 522ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 522ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 522ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 523ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 517ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 517ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 526ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 524ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 625ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 624ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 538ms |
posthog-php-lib_curl Compliance ReportDate: 2026-11-12T14:44:47.719828+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 30ms |
| Format Validation.Event Has Uuid | ✅ | 524ms |
| Format Validation.Event Has Lib Properties | ✅ | 525ms |
| Format Validation.Distinct Id Is String | ✅ | 526ms |
| Format Validation.Token Is Present | ✅ | 526ms |
| Format Validation.Custom Properties Preserved | ✅ | 525ms |
| Format Validation.Event Has Timestamp | ✅ | 527ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 526ms |
| Retry Behavior.Retries On 503 | ✅ | 5835ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2527ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2529ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6335ms |
| Retry Behavior.Implements Backoff | ✅ | 16236ms |
| Retry Behavior.Retries On 500 | ✅ | 5146ms |
| Retry Behavior.Retries On 502 | ✅ | 5635ms |
| Retry Behavior.Retries On 504 | ✅ | 5631ms |
| Retry Behavior.Max Retries Respected | ✅ | 17051ms |
| Deduplication.Generates Unique Uuids | ✅ | 234ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5634ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10843ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5636ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 533ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 527ms |
| Compression.Sends Gzip When Enabled | ✅ | 526ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 527ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 522ms |
| Batch Format.Multiple Events Batched Together | ✅ | 513ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2528ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2528ms |
| Error Handling.Retries On 408 | ✅ | 5634ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 803ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 525ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 524ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 523ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 523ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 523ms |
| Request Payload.Groups Round Trip | ✅ | 523ms |
| Request Payload.Groups Default To Empty Object | ✅ | 524ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 523ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 524ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 524ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 519ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 509ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 528ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 527ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 627ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 626ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 527ms |
posthog-php-socket Compliance ReportDate: 2026-11-12T14:45:00.387870+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 20ms |
| Format Validation.Event Has Uuid | ✅ | 515ms |
| Format Validation.Event Has Lib Properties | ✅ | 517ms |
| Format Validation.Distinct Id Is String | ✅ | 517ms |
| Format Validation.Token Is Present | ✅ | 517ms |
| Format Validation.Custom Properties Preserved | ✅ | 517ms |
| Format Validation.Event Has Timestamp | ✅ | 516ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 517ms |
| Retry Behavior.Retries On 503 | ✅ | 5822ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2520ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2519ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6325ms |
| Retry Behavior.Implements Backoff | ✅ | 16232ms |
| Retry Behavior.Retries On 500 | ✅ | 5130ms |
| Retry Behavior.Retries On 502 | ✅ | 5625ms |
| Retry Behavior.Retries On 504 | ✅ | 5625ms |
| Retry Behavior.Max Retries Respected | ✅ | 16236ms |
| Deduplication.Generates Unique Uuids | ✅ | 521ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5623ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10820ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5136ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 522ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 518ms |
| Compression.Sends Gzip When Enabled | ✅ | 517ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 519ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 516ms |
| Batch Format.Multiple Events Batched Together | ✅ | 510ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2520ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2520ms |
| Error Handling.Retries On 408 | ✅ | 5623ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 801ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 518ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 514ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 515ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 515ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 515ms |
| Request Payload.Groups Round Trip | ✅ | 515ms |
| Request Payload.Groups Default To Empty Object | ✅ | 517ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 517ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 517ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 517ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 514ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 506ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 520ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 518ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 619ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 618ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 520ms |
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions made while building this PR:
|
A code send's category is now Emails::CATEGORY_TRANSACTIONAL or the category itself: key, type, name and an optional description, which PostHog creates or updates by key. Template sends no longer take a category, because a template follows its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions for contract revision 2, which replace the earlier lines for the same questions:
|
Drives the public client and its curl transport against the loopback server and pins the whole request body, so a renamed or extra category field fails the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fake follows the server's code-send schema and category rules: it refuses unknown fields, upserts a category by key, keeps a missing description, clears an empty one and refuses a type change with category_mismatch. The category test asserts what the fake stored instead of the request body's key names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fake send route now refuses a marketing send to a recipient who opted out of its category or of all marketing, before it stores anything. The unit tests no longer check these two cases through a stubbed transport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The category refusals now run through the public client and its curl transport against the fake send route, and assert that no request reached it. 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 Emails::CATEGORY_TRANSACTIONAL on a template in a category. The fake send server 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>
PostHog now creates a category the first time a send names its key, or restores a deleted one, and otherwise leaves it unchanged. The category's name and description apply only at creation; renames happen in PostHog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fake send route now refuses a marketing send without a clickable
{{ unsubscribe_url }} link, and keeps a category it created when the
opt-out refusal comes after it, as the server does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostHog refuses a send whose template_key names a template in a marketing category with marketing_template. The send fake now does the same, and the follow-the-template tests seed a transactional category. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

💡 Motivation and Context
Developers who send email from code with
emails()->send()can only send transactional mail. A team that moves a workflow's marketing email to a code send loses the email's message category, so recipients' opt-outs and unsubscribe headers stop applying.A send with
template_keynow takes an optionalcategory. Leave it out to send transactional email as before, or to follow the category of the template thattemplate_keynames. A template made in PostHog is transactional: PostHog refuses a send whosetemplate_keynames a template in a marketing category withmarketing_template.Emails::CATEGORY_TRANSACTIONALasks for transactional email with no category, so PostHog refuses it withcategory_mismatchon a template in a category. To file the email under a message category, pass the category itself. PostHog creates the category the first time a send names its key, or restores it if it was deleted, and otherwise leaves it unchanged. Itsnameanddescriptionapply only when the send creates the category; change them later in PostHog.Depends on PostHog/posthog#132118, which pins the API contract (revision 2) and builds the server half. Release once customers can use it.
Public API change: a new
categoryoption on code sends and the constantEmails::CATEGORY_TRANSACTIONAL. The emails spec has nocategoryyet. This PR follows the contract in the backend PR, which a PostHog maintainer owns.Impact
The backend PR adds
category_typeto thetransactional email api sendevent, and its insight counts code sends by category type. Marketing code sends from PHP show up there once this ships.Warning
🟡 Merge risk: medium · two-way door at merge, one-way once a release ships: the
categoryoption shape andEmails::CATEGORY_TRANSACTIONALcategoryto a code send get it on the wire, and the server's category refusals asEmailError; no caller does yet.categoryhas the same body as today.template_keynames a template in a marketing category getsmarketing_template, and the docs say so. Template categories are parked server-side, so no marketing template send ships from this package.ac055ee) is create-only, so a send never renames a category or changes its description, as these docs say.Rollout
marketing_template.categoryis one of thetemplate_keyform's fields. A template made in PostHog follows its own category, so atemplate_idsend withcategorythrowsinvalid_formbefore any request, likesubjectdoes.categoryor a nulldescriptioncounts as left out, like every other option. The SDK never sends a default. An emptydescriptiongoes out as"". Like the name, it applies only when the send creates the category.template_keythat names a template in a marketing category getsmarketing_template; the docblock, README and change intent say so.template_keynames a template in a category, a left-outcategoryfollows that category, and an explicitEmails::CATEGORY_TRANSACTIONALgetscategory_mismatch. The SDK keeps the two apart on the wire, and the docblock, README and change intent now say so instead of calling them the same.categorymust beEmails::CATEGORY_TRANSACTIONALor an array with a nonemptykeyandname, atypeof'marketing'or'transactional', an optional stringdescription, and no other keys. Anything else throwsinvalid_categorybefore any request. The server owns the key format and the lengths.getErrorCode():category_mismatch,too_many_categories,unsubscribe_link_required,copies_not_allowed,marketing_template(400) andrecipient_opted_out(422, namedvalidation_errorlikerecipient_suppressed).send()docblock describe the option. Aminorchange intent is in.changeset/.💚 How did you test it?
1f2d5d3, locally in Docker (PHP 8.4, CI's version): full PHPUnit suite, 2710 tests green;composer api:check, CI'sphpcbfdiff check and PHPCS on the changed files clean; the adapter tests pass.8a8899f, the email tests again at5c470bf: 2706 tests, all pass butPackageArchiveTest, which needsgit, missing from the stock image; CI runs it green. The email tests also pass on PHP 8.4. PHPCS is clean on the changed files, andcomposer api:checkis up to date.Clientand its curl transport send to a loopback fake that follows the server's code-send schema. The fake refuses unknown fields, creates each category the first time a send names its key and leaves it unchanged after, and refuses a marketing send to a recipient who opted out. It refuses a type change withcategory_mismatch. Like the server, it refuses a marketing send without{{ unsubscribe_url }}before it writes the category, and one without a clickable link, or to an opted-out recipient, after. The tests assert what the fake stored.descriptioncreates the category without one. The built-in"transactional"and a nullcategorysend without a category. A type change throwscategory_mismatchoncategoryand stores nothing. An opted-out recipient throwsrecipient_opted_out(422,validation_error): the category the send created stays, and no email is queued.categorythrowsinvalid_form. A bare key string, a list, a missing key, an unknown type, an empty name, a non-string description and a misspelled field each throwinvalid_category. None reaches the fake.8cc2bc2: putting the old rename back in the fake fails the create-only test, and sending a null description fails the null-description test. At67b2842, refusing the opt-out before the category write fails the opt-out test, and the old bare-placeholder fixture fails the marketing sends. Earlier, renaming the wire key failed these tests too.template_keythat names a template in a transactional category (receipts): a left-out or nullcategoryfollows it, and"transactional"getscategory_mismatch. Like the server, it refuses a template in a marketing category withmarketing_template: that test throwsmarketing_template(400,validation_error) and stores nothing. Red first: it failed against the old fake, which sent the email (send() did not throw), before the fake learned the refusal. A send withcategoryleft out, and with it null, is stored in the template's category.Emails::CATEGORY_TRANSACTIONALthrowscategory_mismatchoncategory(400,validation_error) and stores nothing.lib/Emails.phpfail those tests at5d16b9f: dropping an explicit"transactional"fails the refusal test, and sending"transactional"for a left-outcategoryfails both follow-the-template cases.Client,PostHog::emails()->send()and the SDK's curl transport against a local PHP server that records each request. The recorded bodies match the contract's wire shape. A 422recipient_opted_outand a 400too_many_categoriesreply each throwEmailErrorwith that code and status.1f2d5d3: green.1205eactakes the category object, but it isn't deployed, so the run so far reached only the local router and the fake send route.categoryis byte-identical to today's. A server without this feature refusescategoryas a field it doesn't take (400, naming the field), so the send fails loudly. That comes from reading the server code, not from a run.📝 Checklist
If releasing new changes
pnpm changeto generate a change intent file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code, Claude Opus 5.5, one worker of several building
categoryacross the SDKs to the contract in the backend PR."transactional", or the category object withkey,type,nameand optionaldescription; replaced a plain key stringnameanddescriptionapply only at creation; replaced the upsert by keymarketing_template; template categories wait for their own decisionEmails::CATEGORY_TRANSACTIONAL, beside the class's other public constantsInvolvement: no grilling rounds; revision 1 had a GPT-6.1 Sol review (no findings) and PostHog Review (one finding, rejected: the author is a maintainer); on revision 2, GPT-6.1 Sol found one test gap in round 2 (tests stubbed the server; fixed with the fake send route in
30d1e60) and one in round 3 (one test covered six behaviors; split in8a8899f), and its final reads of8a8899f,b5bd044and5c470bffound nothing. After Michael's decision that a left-out category and an explicit transactional differ on a template in a category, round 4 reviewed30d1e60..5d16b9fand found nothing. The proof judge flagged cases still tested through a stubbed transport: a nullcategoryandrecipient_opted_outmoved onto the fake inb5bd044, and the category refusals in5c470bf. PostHog Review found nothing onee07ee6,317be94,8a8899f,b5bd044or5c470bf; loopback live run captured on both heads. After the create-only decision, round 5 on860e7dfraised two findings: the backend PR's head still renames (declined: the server half of the same decision lands in the backend stack, and the real send gates this PR), and the lost null-description case (fixed in8cc2bc2). Round 6 on that fix found nothing. The proof judge on8cc2bc2found the marketing fixture used a bare placeholder and the fake refused an opt-out before writing the category; both are fixed in67b2842, and round 7, run under the standing review rule, found nothing. After the decision to park template categories, round 8 on1f2d5d3, also under the standing rule, found nothing.🤖 Generated with Claude Code