Repository navigation
Conversation
…api:// PostHogTransport and its factory turn a Symfony Email into one transactional send through emails()->send(): rendered content, attachments, inline images by content ID, and the X-PostHog-* option headers. An email PostHog cannot carry, such as a second To, tags, metadata or a custom header, is refused before any request. On Symfony Mailer 8.2 a RemoteTemplateEmail sends a template made in PostHog. A failed send is a TransportException that says whether to retry, so EmailError::isRetryable() is now public. The factory steps aside when Symfony's own PostHog bridge is installed. symfony/mailer stays a suggestion; the core SDK never loads it. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
posthog-php-socket Compliance ReportDate: 2026-11-05T13:27:14.645322+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 27ms |
| Format Validation.Event Has Uuid | ✅ | 523ms |
| Format Validation.Event Has Lib Properties | ✅ | 524ms |
| Format Validation.Distinct Id Is String | ✅ | 524ms |
| Format Validation.Token Is Present | ✅ | 524ms |
| Format Validation.Custom Properties Preserved | ✅ | 525ms |
| Format Validation.Event Has Timestamp | ✅ | 523ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 523ms |
| Retry Behavior.Retries On 503 | ✅ | 5832ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2525ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2528ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6332ms |
| Retry Behavior.Implements Backoff | ✅ | 16247ms |
| Retry Behavior.Retries On 500 | ✅ | 5632ms |
| Retry Behavior.Retries On 502 | ✅ | 5632ms |
| Retry Behavior.Retries On 504 | ✅ | 5633ms |
| Retry Behavior.Max Retries Respected | ✅ | 16249ms |
| Deduplication.Generates Unique Uuids | ✅ | 531ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5629ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10843ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5635ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 531ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 525ms |
| Compression.Sends Gzip When Enabled | ✅ | 527ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 524ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 520ms |
| Batch Format.Multiple Events Batched Together | ✅ | 514ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2526ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2526ms |
| Error Handling.Retries On 408 | ✅ | 5633ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 802ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 524ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 523ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 522ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 523ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 525ms |
| Request Payload.Groups Round Trip | ✅ | 521ms |
| 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 | ✅ | 523ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 522ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 517ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 509ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 526ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 523ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 624ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 625ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 527ms |
Contributor
posthog-php-fork_curl Compliance ReportDate: 2026-11-05T13:27:05.718307+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 | ✅ | 529ms |
| Format Validation.Custom Properties Preserved | ✅ | 529ms |
| Format Validation.Event Has Timestamp | ✅ | 530ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 529ms |
| Retry Behavior.Retries On 503 | ✅ | 5852ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2532ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2532ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6343ms |
| Retry Behavior.Implements Backoff | ✅ | 16273ms |
| Retry Behavior.Retries On 500 | ✅ | 5646ms |
| Retry Behavior.Retries On 502 | ✅ | 5642ms |
| Retry Behavior.Retries On 504 | ✅ | 5644ms |
| Retry Behavior.Max Retries Respected | ✅ | 16272ms |
| Deduplication.Generates Unique Uuids | ✅ | 534ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5641ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10850ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5153ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 532ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 529ms |
| Compression.Sends Gzip When Enabled | ✅ | 531ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 531ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 518ms |
| Batch Format.Multiple Events Batched Together | ✅ | 517ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2531ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2530ms |
| Error Handling.Retries On 408 | ✅ | 5645ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 809ms < 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 | ✅ | 521ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 520ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 520ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 520ms |
| Request Payload.Groups Round Trip | ✅ | 520ms |
| Request Payload.Groups Default To Empty Object | ✅ | 521ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 520ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 521ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 521ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 516ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 514ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 522ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 522ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 624ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 622ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 529ms |
Contributor
posthog-php-lib_curl Compliance ReportDate: 2026-11-05T13:27:04.119344+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 25ms |
| Format Validation.Event Has Uuid | ✅ | 520ms |
| Format Validation.Event Has Lib Properties | ✅ | 522ms |
| Format Validation.Distinct Id Is String | ✅ | 522ms |
| Format Validation.Token Is Present | ✅ | 523ms |
| Format Validation.Custom Properties Preserved | ✅ | 522ms |
| Format Validation.Event Has Timestamp | ✅ | 524ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 522ms |
| Retry Behavior.Retries On 503 | ✅ | 5831ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2527ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2524ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6331ms |
| Retry Behavior.Implements Backoff | ✅ | 16246ms |
| Retry Behavior.Retries On 500 | ✅ | 5630ms |
| Retry Behavior.Retries On 502 | ✅ | 5632ms |
| Retry Behavior.Retries On 504 | ✅ | 5629ms |
| Retry Behavior.Max Retries Respected | ✅ | 17040ms |
| Deduplication.Generates Unique Uuids | ✅ | 235ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5630ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10837ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5633ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 527ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 523ms |
| Compression.Sends Gzip When Enabled | ✅ | 522ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 523ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 518ms |
| Batch Format.Multiple Events Batched Together | ✅ | 511ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2524ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2526ms |
| Error Handling.Retries On 408 | ✅ | 5632ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 804ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 521ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 521ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 522ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 520ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 521ms |
| Request Payload.Groups Round Trip | ✅ | 521ms |
| Request Payload.Groups Default To Empty Object | ✅ | 521ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 520ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 521ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 521ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 516ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 508ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 525ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 521ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 624ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 623ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 523ms |
… and template options - An inline part without a content ID uses its name only when the HTML refers to it as cid:<name>; otherwise it is refused with inline_without_cid, as conformance case 10 expects. - A sender display name with a character RFC 5322 reserves, such as a comma, is quoted, so the server parses the address. - X-PostHog-Template-Key, X-PostHog-Capture-Engagement and X-Track are checked on template emails too, and still not sent there. - X-PostHog-Capture-Engagement takes "true" or "false" and is refused as unsupported_header otherwise; it wins over X-Track. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A part's content ID is the reference the HTML uses: cid:<name> first, then cid:<its content ID>. Symfony gives a referenced part a generated content ID when it renders the body, but the HTML sent still says cid:<name>, so taking the content ID first broke the image. A part the HTML refers to sends inline even when it was attached. An inline part the HTML doesn't refer to sends its own content ID, or is refused with inline_without_cid. - X-Track with opens and clicks set apart always refuses with mixed_tracking; X-PostHog-Capture-Engagement wins only over an X-Track whose flags agree. - The X-PostHog-* option headers are read raw, so a distinct ID that isn't ASCII is not sent MIME-encoded. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rendered email's body may hold only its text part, its HTML part, and its own attachments, at most one text and one HTML part, each only when the email has that body. Anything else, such as an AMP alternative or a part set with setBody() that isn't an attachment, is refused with unsupported_part. A body Symfony generates holds nothing else, and template emails aren't checked. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A name that only starts the content ID the HTML refers to, such as logo in "cid:logo'v2@shop.example", no longer counts as a reference. Each cid: reference is read in full, a quoted one up to its closing quote, and compared exactly. A text or HTML body set as a stream that can't rewind sent empty HTML: Symfony reads the HTML again when it builds the body that the part check walks. Each body is now read once and the check walks a clone that holds the bodies as strings, so the app's own email is unchanged. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The part check built the generated body to walk it. On Symfony Mime
releases before 6.4.37 and 7.4.9, building it renames each part the
HTML refers to after its generated content ID, so a named inline image
sent under that ID and broke. A generated body holds only the email's
own parts, so the check now walks only a body set with setBody(), read
with Message::getBody(), and never builds the generated one.
A custom body's text or HTML part must also hold the email's own text
or HTML. Before, ->text('old')->setBody(new TextPart('new')) passed and
sent 'old'. Now it is refused as unsupported_part.
CI also runs the Mailer tests on Symfony 6.4.0, the oldest supported
release.
Ticket: PostHog/michaelr-context#42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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 Symfony app sends its email through Symfony Mailer, so sending through PostHog today means rewriting every send as a
emails()->send()call. This PR adds a Mailer transport. The app sets one DSN, and its existingEmailobjects go out as PostHog transactional sends:PostHogTransportconverts the message to anEmailand maps it to the options of oneemails()->send(): from, the single to, cc, bcc, reply-to, subject, html, text, attachments, and inline images under the reference the HTML uses:cid:<name>, thencid:<content ID>, each matched as a whole reference, sologonever matchescid:logo'v2@shop.example. Symfony gives a referenced part a generated content ID when it renders, but the HTML sent still sayscid:<name>. A part the HTML refers to sends inline even when it was attached. An inline part the HTML doesn't refer to sends its own content ID, or is refused. A sender name with a character RFC 5322 reserves, such asShop, Inc, goes out quoted, so the server parses the address. A text or HTML body set as a stream is read once, so a stream that can't rewind still sends in full. The SDK does the rest (validation, retries, idempotency), and the transport sets PostHog's message ID as theSentMessageID.X-PostHog-Distinct-Id,-Template-Key,-Idempotency-Keyand-Capture-Engagementset those options. They never go out as email headers.X-PostHog-Capture-Engagementtakestrueorfalseand wins over Symfony'sX-Track. Without it,X-Trackmaps to engagement capture. AnX-Trackwhose opens and clicks differ is always refused. The option headers are read raw, so a distinct ID such as用户42isn't sent MIME-encoded. The template key comes from the header, then the DSN'sdefault_template_key, thenposthog-php-mailer.LogicExceptionthat readsPostHog cannot send this email: <code>. <detail>. That covers more than one To, tags, metadata, a custom header, a priority other than normal, an inline part the HTML doesn't refer to, two From addresses, an envelope that differs from the headers, and a body set withsetBody()that holds a part other than the email's own text, HTML, and attachments (unsupported_part, such as an AMP alternative, or a text part with other text than->text()). A silent drop would send a different email than the app built.PostHogTransportException, a MailerTransportExceptioncarrying the status, PostHog's error code, the field,isRetryable(), andgetRetryAfter(). That needsEmailError::isRetryable(), so it is now public; the client's own retry loop uses it.RemoteTemplateEmailsends a template made in PostHog: its ID and variables, plus distinct ID and idempotency key, and no content. The template key and engagement headers are still checked there, and not sent. Before 8.2 the transport still loads; 8.2 is at BETA1, so this path is tested against the beta.posthog+api://.X-PostHog-Integration: posthog-php-mailer/<SDK version>(the option from feat(emails): name the sending integration with an integration option #304). A framework package that builds the transport passes its own name, such as Laravel.Symfony Mime before 6.4.37 and 7.4.9: when the app renders an email itself before it sends (
$email->toString()), that Symfony renames each inline part the HTML refers to after its generated content ID, and the transport can't recover the name, so the image doesn't resolve. The transport itself never builds the generated body, so a plain send works on every supported release.symfony/mailerstays asuggest: the core SDK never loads it, and a test proves an app without it still sends.Stacked on #304 (merge that first).
Seams for reviewers. This is the base of two stacks. The Laravel mail driver builds
PostHogTransportwithintegration: 'laravel/<version>'and its own default template key, so a break here is a constructor or mapping change that driver relies on. The upstream Symfony PostHog Mailer bridge must map the same email to the same send. Both now match on the From of a template email (Symfony requires one; the transport doesn't send it), the inline content ID rule, two From addresses (refused),unsupported_part, quoted sender names, and checking the template key and engagement headers on template emails. Every adapter shares the conformance cases in the tests. Ticket: https://github.com/PostHog/michaelr-context/issues/42Note
🔵 Merge risk: low · two-way door until released (merge-risk rubric)
PostHog\Mailer: nothing runs unless an app registers the factory or builds the transport.symfony/mailerandsymfony/framework-bundleare dev dependencies only.EmailError::isRetryable(). The existing retry tests pass, along with a transport test for each row of the errors table.RemoteTemplateEmailbefore 8.2.0 ships, only that path breaks, and the 8.2 CI job shows it.git revertthe merge commit before the release; after a release, the transport andisRetryable()are public API.💚 How did you test it?
posthog+api://<key>@defaultandPostHogTransportFactory, to a PostHog inbox. PostHog answered 202 with a message ID,Emails::get()read it back asdelivereda second later, and the request carriedX-PostHog-Integration: posthog-php-mailer/4.20.0. The From was a verified PostHog address instead of case 01'sshop@example.com.PostHog\Mailerclass missing.X-Trackwith the engagement header fail (3 tests). The Unicode distinct ID test fails without the raw read (sent as=?utf-8?Q?…?=).unsupported_partshapes fail (an AMP alternative, a body part that is not an attachment, a second text part, an HTML part without an HTML body). A normal multipart email with an attachment and an inline image sends.logowith content IDlogo'v2@shop.examplesends aslogo, and anhtml()stream that can't rewind sends empty HTML.composer validate --strict, PHPUnit (2751 tests, OK; the skips are the existing environment skips plus the template tests), the adapter Python tests, PHPCS and phpcbf (no errors, no fixes),composer api:check(snapshot updated for the new public classes andisRetryable()).symfony-mailerruns the Mailer tests on Mailer 6.4.0 and the latest 6.4 (PHP 8.2), and on 8.2 (PHP 8.4, beta stability until 8.2.0 ships), so CI covers both ends of the supported range. The 6.4.0 row turns off Composer's advisory block for that test install only. Its exact commands ran on the box: Mailer, Mime and FrameworkBundle v6.4.0, PHPUnit OK.Emailssender with only its HTTP transport stubbed, asserting the request body and headers.MailerInterface.Symfony\Component\MailerandMimeclass and still sends with the core SDK.📝 Checklist
If releasing new changes
pnpm changeto generate a change intent file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built by Claude Code (Opus 5.5) in a T3 Code stack thread. Hold as a draft: it releases only together with the other SDKs' email send.
Emails($client->emails()), notClient: a DSN-only app has no project key, and a client without one is disabledcid:<name>thencid:<content ID>; else an inline part's own content ID; else refused asinline_without_cid. Replaces "explicit content ID first", which broke images Symfony had given a generated content ID"Shop, Inc" <shop@example.com>, so the server parses it; a plain name stays as isX-Trackdisagree?X-Trackwhose flags agree; anX-Trackwhose opens and clicks differ always refuses asmixed_trackingsetBody()body?unsupported_part; template emails aren't checked->text()or->html()is refused asunsupported_part, so the send never differs from the bodyunsupported_header; a send has one Fromposthog+apionly, and none when Symfony's PostHog bridge is installedEmailError::isRetryable(), now public, shared by the client and the transportInvolvement: no grilling for this PR; reviews: GPT-6.1 Sol round 1 (5 findings, all fixed) a fixes round 2 (1 finding, fixed), and a fixes round 3 (2 findings, fixed), a fixes round 4 pending; proof captured (live run, red, green on three Mailer versions, lane gate).
🤖 Generated with Claude Code