Skip to content

feat(emails): add category to emails send - #309

Draft
Silthus wants to merge 12 commits into
mainfrom
feat/emails-send-category
Draft

Silthus wants to merge 12 commits into
mainfrom
feat/emails-send-category

Conversation

@Silthus

@Silthus Silthus commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

💡 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_key now takes an optional category. Leave it out to send transactional email as before, or to follow the category of the template that template_key names. A template made in PostHog is transactional: PostHog refuses a send whose template_key names a template in a marketing category with marketing_template. Emails::CATEGORY_TRANSACTIONAL asks for transactional email with no category, so PostHog refuses it with category_mismatch on 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. Its name and description apply 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 category option on code sends and the constant Emails::CATEGORY_TRANSACTIONAL. The emails spec has no category yet. This PR follows the contract in the backend PR, which a PostHog maintainer owns.

Impact

The backend PR adds category_type to the transactional email api send event, 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 category option shape and Emails::CATEGORY_TRANSACTIONAL

  • PHP callers that pass category to a code send get it on the wire, and the server's category refusals as EmailError; no caller does yet.
  • Every existing call is unchanged: the exact-body test passes as before, so a send without category has the same body as today.
  • Uncovered: a real send against the live server (owed, below); a contract drift there fails the caller's own send visibly.
  • Templates made in PostHog are transactional only: a send whose template_key names a template in a marketing category gets marketing_template, and the docs say so. Template categories are parked server-side, so no marketing template send ships from this package.
  • The backend dependency (feat(messaging): send code emails in a marketing message category posthog#132118 at ac055ee) is create-only, so a send never renames a category or changes its description, as these docs say.
  • Switch-off: none: an optional option, live once released; fix forward.

Rollout

  • Stages: the backend (feat(messaging): send code emails in a marketing message category posthog#132118) and CDP (#132149) deploy first; then merge and release this package, after the real send through this branch.
  • Decided: "Create-only, no renames (Recommended)", 2026-11-12: a send never renames a category or changes its description.
  • Decided: "Ship code sends, park templates (Recommended)", 2026-11-12: code-send categories ship; a template made in PostHog stays transactional, and one in a marketing category is refused with marketing_template.
  • Watch: no alert pages on a wrong category; the real send checks the stored category before release.
PostHog::emails()->send([
    'to' => 'user@example.com',
    'distinct_id' => 'user_123',
    'template_key' => 'monthly-news',
    'category' => [
        'key' => 'newsletter',
        'type' => 'marketing', // or 'transactional'
        'name' => 'Newsletter',
        'description' => 'Monthly product news', // optional
    ],
    'subject' => 'This month at Acme',
    'html' => $renderedHtml, // must contain {{ unsubscribe_url }}
]);
  • Code sends only. category is one of the template_key form's fields. A template made in PostHog follows its own category, so a template_id send with category throws invalid_form before any request, like subject does.
  • Sent only when set. A null category or a null description counts as left out, like every other option. The SDK never sends a default. An empty description goes out as "". Like the name, it applies only when the send creates the category.
  • Templates are transactional. A template_key that names a template in a marketing category gets marketing_template; the docblock, README and change intent say so.
  • Left out is not transactional. When template_key names a template in a category, a left-out category follows that category, and an explicit Emails::CATEGORY_TRANSACTIONAL gets category_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.
  • Shape check only. category must be Emails::CATEGORY_TRANSACTIONAL or an array with a nonempty key and name, a type of 'marketing' or 'transactional', an optional string description, and no other keys. Anything else throws invalid_category before any request. The server owns the key format and the lengths.
  • Server codes come through getErrorCode(): category_mismatch, too_many_categories, unsubscribe_link_required, copies_not_allowed, marketing_template (400) and recipient_opted_out (422, named validation_error like recipient_suppressed).
  • README and the send() docblock describe the option. A minor change intent is in .changeset/.

💚 How did you test it?

  • Lane gate at 1f2d5d3, locally in Docker (PHP 8.4, CI's version): full PHPUnit suite, 2710 tests green; composer api:check, CI's phpcbf diff check and PHPCS on the changed files clean; the adapter tests pass.
  • Earlier lane gate: PHPUnit full suite on PHP 8.2 at 8a8899f, the email tests again at 5c470bf: 2706 tests, all pass but PackageArchiveTest, which needs git, 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, and composer api:check is up to date.
  • Over HTTP, against a fake send route: the public Client and 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 with category_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.
    • A send creates its category, and a later send with another name and description leaves it unchanged. A null description creates the category without one. The built-in "transactional" and a null category send without a category. A type change throws category_mismatch on category and stores nothing. An opted-out recipient throws recipient_opted_out (422, validation_error): the category the send created stays, and no email is queued.
    • A template send with category throws invalid_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 throw invalid_category. None reaches the fake.
    • Mutation-checked at 8cc2bc2: putting the old rename back in the fake fails the create-only test, and sending a null description fails the null-description test. At 67b2842, 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.
    • The fake also models a template_key that names a template in a transactional category (receipts): a left-out or null category follows it, and "transactional" gets category_mismatch. Like the server, it refuses a template in a marketing category with marketing_template: that test throws marketing_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 with category left out, and with it null, is stored in the template's category. Emails::CATEGORY_TRANSACTIONAL throws category_mismatch on category (400, validation_error) and stores nothing.
    • Two mutants of lib/Emails.php fail those tests at 5d16b9f: dropping an explicit "transactional" fails the refusal test, and sending "transactional" for a left-out category fails both follow-the-template cases.
  • Live run (loopback): the real 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 422 recipient_opted_out and a 400 too_many_categories reply each throw EmailError with that code and status.
  • CI on 1f2d5d3: green.
  • Gap, owed before this leaves draft: a live run against PostHog itself. Michael accepted it, and the cross-version gap, until the backend is live; the real send through this branch, confirmed by Michael, stays the merge gate. The backend PR's head 1205eac takes the category object, but it isn't deployed, so the run so far reached only the local router and the fake send route.
  • Gap, owed with it: cross-version proof. A send with no category is byte-identical to today's. A server without this feature refuses category as 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

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm change to generate a change intent file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Code, Claude Opus 5.5, one worker of several building category across the SDKs to the contract in the backend PR.

Question Answer Who Where
Wire value The built-in "transactional", or the category object with key, type, name and optional description; replaced a plain key string 👤 Human contract
Which send forms take it Code sends only, in all SDKs; replaced both forms 👤 Human contract
What a send does to an existing category "Create-only, no renames (Recommended)" (2026-11-12): a send creates a missing category or restores a deleted one, and otherwise leaves it unchanged; name and description apply only at creation; replaced the upsert by key 👍 Accepted default contract, redesign 2026-11-12
Whether a template made in PostHog can be marketing "Ship code sends, park templates (Recommended)" (2026-11-12): no; templates are transactional only, and one in a marketing category is refused with marketing_template; template categories wait for their own decision 👤 Human contract, 2026-11-12 ~14:30
Constant name Emails::CATEGORY_TRANSACTIONAL, beside the class's other public constants 🤖 Agent comment
PHP shape of the category An array with the contract's keys, like every other send option; no new class 🤖 Agent comment
Local validation The shape only, with unknown keys refused like unknown send options; the server owns key format and lengths 🤖 Agent comment

Involvement: 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 in 8a8899f), and its final reads of 8a8899f, b5bd044 and 5c470bf found nothing. After Michael's decision that a left-out category and an explicit transactional differ on a template in a category, round 4 reviewed 30d1e60..5d16b9f and found nothing. The proof judge flagged cases still tested through a stubbed transport: a null category and recipient_opted_out moved onto the fake in b5bd044, and the category refusals in 5c470bf. PostHog Review found nothing on ee07ee6, 317be94, 8a8899f, b5bd044 or 5c470bf; loopback live run captured on both heads. After the create-only decision, round 5 on 860e7df raised 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 in 8cc2bc2). Round 6 on that fix found nothing. The proof judge on 8cc2bc2 found the marketing fixture used a bare placeholder and the fake refused an opt-out before writing the category; both are fixed in 67b2842, and round 7, run under the standing review rule, found nothing. After the decision to park template categories, round 8 on 1f2d5d3, also under the standing rule, found nothing.

🤖 Generated with Claude Code

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>
@Silthus Silthus self-assigned this Nov 9, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/Emails.php Resolved
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-12T14:45:03.670365+00:00
Duration: 116386ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
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

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-12T14:44:47.719828+00:00
Duration: 116262ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
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

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-socket Compliance Report

Date: 2026-11-12T14:45:00.387870+00:00
Duration: 114660ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
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

@Silthus

Silthus commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions made while building this PR:

  • 🤖 Constant name Emails::CATEGORY_TRANSACTIONAL, beside the class's other public constants such as DEFAULT_MAX_RETRIES.
  • 🤖 Which send forms take it Both, as a shared field. The server checks a template send against the template's own category and answers category_mismatch.
  • 🤖 Local validation A nonempty string only. The server owns the 64-character limit and the key lookup, so the SDK does not duplicate them.

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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions for contract revision 2, which replace the earlier lines for the same questions:

  • 🤖 PHP shape of the category An array with the contract's keys (key, type, name, optional description), like every other send option. No new class, because the SDK takes all send input as arrays.
  • 🤖 Local validation The shape only: Emails::CATEGORY_TRANSACTIONAL or an array with a nonempty key and name, a known type and an optional string description. Unknown keys are refused, like unknown send options. The server owns the key format and the lengths.
  • 🤖 Which send forms take it Code sends only, per the contract. A template_id send with category throws invalid_form, like subject.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed ee07ee6: no findings.

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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 317be94: no findings.

Silthus and others added 2 commits November 10, 2026 06:02
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>
@posthog

posthog Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

PostHog Review · Deep · reviewed 5c470bf

Nothing to post.

Step Status Result
Prepare the diff done 4 files, 1 chunk
Pick perspectives done 3 perspectives
Run review passes done 0 issues
Merge overlapping findings done 0 → 0
Validate findings done
Publish done Nothing to post

Four people posing together

Silthus and others added 2 commits November 10, 2026 06:15
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>
Silthus and others added 5 commits November 10, 2026 10:59
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant