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>
posthog-php-socket Compliance ReportDate: 2026-11-13T08:05:36.010084+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 25ms |
| Format Validation.Event Has Uuid | ✅ | 521ms |
| Format Validation.Event Has Lib Properties | ✅ | 522ms |
| Format Validation.Distinct Id Is String | ✅ | 523ms |
| Format Validation.Token Is Present | ✅ | 522ms |
| Format Validation.Custom Properties Preserved | ✅ | 522ms |
| Format Validation.Event Has Timestamp | ✅ | 523ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 523ms |
| Retry Behavior.Retries On 503 | ✅ | 5830ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2523ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2526ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6330ms |
| Retry Behavior.Implements Backoff | ✅ | 16244ms |
| Retry Behavior.Retries On 500 | ✅ | 5632ms |
| Retry Behavior.Retries On 502 | ✅ | 5629ms |
| Retry Behavior.Retries On 504 | ✅ | 5627ms |
| Retry Behavior.Max Retries Respected | ✅ | 16248ms |
| Deduplication.Generates Unique Uuids | ✅ | 527ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5628ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10839ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5632ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 527ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 524ms |
| Compression.Sends Gzip When Enabled | ✅ | 522ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 522ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 519ms |
| Batch Format.Multiple Events Batched Together | ✅ | 511ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2524ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2524ms |
| Error Handling.Retries On 408 | ✅ | 5630ms |
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 | ✅ | 522ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 521ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 521ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 521ms |
| 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 | ✅ | 520ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 517ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 508ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 524ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 522ms |
| 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 | ✅ | 525ms |
posthog-php-fork_curl Compliance ReportDate: 2026-11-13T08:05:19.986908+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 26ms |
| Format Validation.Event Has Uuid | ✅ | 519ms |
| Format Validation.Event Has Lib Properties | ✅ | 520ms |
| Format Validation.Distinct Id Is String | ✅ | 519ms |
| Format Validation.Token Is Present | ✅ | 520ms |
| Format Validation.Custom Properties Preserved | ✅ | 519ms |
| Format Validation.Event Has Timestamp | ✅ | 520ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 520ms |
| Retry Behavior.Retries On 503 | ✅ | 5835ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2526ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2523ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6330ms |
| Retry Behavior.Implements Backoff | ✅ | 15757ms |
| Retry Behavior.Retries On 500 | ✅ | 5633ms |
| Retry Behavior.Retries On 502 | ✅ | 5634ms |
| Retry Behavior.Retries On 504 | ✅ | 5632ms |
| Retry Behavior.Max Retries Respected | ✅ | 16251ms |
| Deduplication.Generates Unique Uuids | ✅ | 530ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5634ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10845ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5635ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 526ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 520ms |
| Compression.Sends Gzip When Enabled | ✅ | 521ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 520ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 512ms |
| Batch Format.Multiple Events Batched Together | ✅ | 513ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2522ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2522ms |
| Error Handling.Retries On 408 | ✅ | 5630ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 806ms < 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 | ✅ | 514ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 515ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 515ms |
| Request Payload.Groups Round Trip | ✅ | 514ms |
| Request Payload.Groups Default To Empty Object | ✅ | 515ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 514ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 514ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 514ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 511ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 510ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 516ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 515ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 615ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 616ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 521ms |
posthog-php-lib_curl Compliance ReportDate: 2026-11-13T08:05:47.399190+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 | ✅ | 523ms |
| Format Validation.Custom Properties Preserved | ✅ | 524ms |
| Format Validation.Event Has Timestamp | ✅ | 525ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 523ms |
| Retry Behavior.Retries On 503 | ✅ | 5832ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2529ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2527ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6332ms |
| Retry Behavior.Implements Backoff | ✅ | 16247ms |
| Retry Behavior.Retries On 500 | ✅ | 5634ms |
| Retry Behavior.Retries On 502 | ✅ | 5631ms |
| Retry Behavior.Retries On 504 | ✅ | 5644ms |
| Retry Behavior.Max Retries Respected | ✅ | 17050ms |
| Deduplication.Generates Unique Uuids | ✅ | 231ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5631ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10839ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5636ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 528ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 525ms |
| Compression.Sends Gzip When Enabled | ✅ | 525ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 524ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 519ms |
| Batch Format.Multiple Events Batched Together | ✅ | 512ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2527ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2526ms |
| Error Handling.Retries On 408 | ✅ | 5631ms |
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 | ✅ | 524ms |
| 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 | ✅ | 522ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 521ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 521ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 522ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 516ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 509ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 524ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 522ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 624ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 624ms |
| 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>
capture_engagement only decides whether engagement events land on the recipient's person. It can't untrack an email: PostHog still adds its open pixel and never rewrites links. Refuse X-Track opens=false, which PostHog can't honor, and drop any other X-Track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 8416825: 3 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
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.
…irst Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A MIME-encoded or capitalised X-Track, or opens=0, slipped past the open_tracking_off refusal. Scope the engagement header docs to rendered emails: a PostHog template follows the project's setting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r-transport EmailError::isRetryable() takes the upload rules of the base: a storage 429 or 5xx on an attachment is retryable, upload_quota_exceeded is not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK now uploads attachments by SHA-256 before the send, so the transport tests run against a fake of PostHog's upload and send routes and its file storage. Cases assert the email PostHog queued and the bytes storage holds, not the request order. A send without tracking options no longer carries capture_engagement. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The send API took tracking and sensitive, and capture_engagement is deprecated, so the transport follows the updated adapter contract: - X-Track sets tracking.opens and tracking.clicks as Symfony's TrackingHeader reads them (true, false or default). Opens off is no longer refused, and clicks without opens goes to PostHog, which refuses it. A second X-Track, an unknown flag or value is refused. - X-PostHog-Capture-Engagement sets tracking.engagement, so capture_engagement is never sent; X-PostHog-Sensitive sets sensitive. - Text and HTML convert to UTF-8 from their declared charset; a charset that can't convert is refused. - A custom body must hold every part the send carries, including the attachments. - Content-* headers on the email are refused like any other header. - X-PostHog-Template-Key and the default key take at most 256 characters, as the API does. The symfony-bridge stack maps the same way. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…finds it An app installed with --classmap-authoritative couldn't load PostHogTransport: the interface came from class_alias, which Composer never sees. The file now declares the interface in both branches, and CI runs the mailer tests under an authoritative classmap. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The error map gains the upload rows of the base: a quota refusal on the upload route isn't retryable, a storage 5xx is retried with the same key and stays retryable, a storage refusal isn't, and none sends the email. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 879370f: 4 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
… keep A Sender or Return-Path other than the From address is refused as envelope_mismatch, whatever the envelope says. A custom body part counts as the email's own text or HTML only in the email's charset and shown as the body, not attached. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd a double cid A second X-PostHog option header is refused instead of keeping the last one. An attachment with headers of its own, such as a calendar invite's Content-Type parameters, is refused instead of losing them. An inline part the HTML refers to by both its name and its content ID is refused, since one reference would break. A part with a name but no filename sends its name as the filename, as the Symfony bridge does. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The factory took Symfony's HTTP client and ignored it, so an app's proxy, TLS and MockHttpClient settings never applied to PostHog sends. It now sends through that client, keeps the curl transport's answer cap and network_error mapping, and drops the internal test hook. The tests drive the transport through MockHttpClient and FrameworkBundle's mock_response_factory, and CI pins http-client to each Symfony line. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A framework package that passed a malformed integration got a transport that failed every send with invalid_integration. The constructor now checks it as it checks the default template key, with the same rule Emails::send() applies. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 9dec817: 1 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 4 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
Symfony's stream checks the status on its first chunk, so a 429 or 503 threw an HTTP exception past the SDK's retries. The transport now reads the headers without that check before it streams the answer. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A custom body part or multipart with a header of its own is refused as unsupported_part, as attachments already were. A custom HTML part may use the content IDs Symfony writes when it builds the email's own body. A Content-ID set as a header counts as the part's content ID. Ticket: PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…every Return-Path A body Email built is compared with the HTML a copy of the email builds, so a reference Email leaves alone is no longer refused. A part with neither a filename nor a name sends attachment.<extension> for its type. Each Return-Path header must be the From address, not only the first. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g requests The SDK retries a send and caps how long it waits, so the app's retrying http_client no longer retries on top of it. The client's default credentials stay off the send and the storage upload, which carry their own. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… docblock back Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ScopingHttpClient re-merged the request headers and dropped the empty Authorization override, so an inner default Authorization went out on the storage upload. The factory now clears the credentials and turns the retries off once with withOptions(), which every Symfony decorator forwards to the client that holds the defaults. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 9c601d0: 1 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 5 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
| $client = $client->withOptions( | ||
| ['headers' => ['authorization' => []], 'auth_basic' => null, 'auth_bearer' => null] | ||
| ); | ||
| try { | ||
| return $client->withOptions(['max_retries' => 0]); |
There was a problem hiding this comment.
P1 · Apply SDK overrides after URL-scoped client defaults
When ScopingHttpClient has credentials or max_retries in a matching URL scope, withOptions() only alters the wrapped client's defaults. The scope overrides them at request time: uploads expose app credentials, and scoped retries bypass the SDK's retry cap. Apply these overrides to each request, preserving the SDK or signed upload Authorization header.
|
|
||
| protected function doSend(SentMessage $message): void | ||
| { | ||
| $send = $this->mapper->map(MessageConverter::toEmail($message->getOriginalMessage()), $message->getEnvelope()); |
There was a problem hiding this comment.
P2 · Validate original MIME parts before converting a Message
For a Symfony\Component\Mime\Message whose TextPart has a Content-Language header, MessageConverter::toEmail() recreates the body without that header. EmailMapper then sees no custom body and sends the changed email instead of refusing it as unsupported_part. Validate the original MIME tree before conversion, or reject conversions that discard part headers.
…Hog requests A ScopingHttpClient scope applies its options when a request runs, after withOptions() has cleared the inner client's defaults. Each request now also turns the credentials and the retries off itself, since a request option wins over a scope's. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge it MessageConverter rebuilds a Message's text and HTML without their parts' own headers and dispositions. The mapper now converts the message itself and walks the original body under the custom-body rules first. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MessageConverter has already read a stream body, and a stream that can't rewind reads empty the second time, so a valid Message was refused. The check before conversion now looks only at what conversion drops: the parts' own headers and the disposition of the text and HTML. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nto the body A DataPart is a TextPart, so MessageConverter took one in a text or HTML position as the email's body and dropped its filename. The check before conversion now walks the positions the converter reads as text and HTML, and leaves the later parts of a mixed or related body to the attachment rules. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arge An attachFromPath() file was read whole before the SDK's 40 MB check, so a large file could exhaust PHP's memory and crash the worker. Attachments are now read in chunks, and the read stops with message_too_large once the files pass the SDK's limit after Base64 encoding. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
💡 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. 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 part without a filename sends its name as the filename, and a part with neither sendsattachment.<extension>for its type. A Content-ID set as a header counts as the part's content ID, likesetContentId(). A sender name with a character RFC 5322 reserves, such asShop, Inc, goes out quoted. A text or HTML body set as a stream is read once. A body in another charset, such as ISO-8859-1, is converted to UTF-8, the only charset the send carries. The SDK does the rest (file uploads by hash, validation, retries, idempotency), and the transport sets PostHog's message ID as theSentMessageID.emails()->send()does (feat: send cc, bcc and attachments with emails()->send() #292), so a file PostHog already holds isn't sent again. A failed upload is aPostHogTransportExceptionthat says whether to retry. Files are read in chunks, so a file that would take the email past 40 MB after Base64 encoding is refused asmessage_too_largebefore it fills PHP's memory.X-PostHog-Distinct-Id,-Template-Key,-Idempotency-Key,-Capture-Engagementand-Sensitiveset those options. They never go out as email headers.X-PostHog-Capture-EngagementandX-PostHog-Sensitivetaketrueorfalse. Capture-Engagementfalseturns off the engagement events (tracking.engagement);X-PostHog-Sensitive: truesendssensitive. The template key comes from the header, then the DSN'sdefault_template_key, thenposthog-php-mailer, and is at most 256 characters, as the send API takes.X-Track.X-Track: opens=…; clicks=…(or Symfony 8.2'sTrackingHeader) setstracking.opensandtracking.clicks:trueandfalseset the flag,defaultleaves it to PostHog. An unknown name, a part without=, any other value, or a secondX-Trackis refused asunsupported_header. The transport never sendscapture_engagement. Clicks without opens go to PostHog, which refuses them asclicks_need_opens, and that comes back as the transport error with PostHog's code and field.LogicExceptionthat readsPostHog cannot send this email: <code>. <detail>. That covers more than one To, tags, metadata, a custom header (Content-*headers included), a priority other than normal, an inline part the HTML doesn't refer to, two From addresses, a repeatedX-PostHog-*option header, an envelope that differs from the headers, a Sender or any Return-Path other than the From address (Symfony would bounce to it; the send has one sender), an attachment with headers of its own (such as a calendar invite'smethod), one inline part the HTML refers to by both its name and its content ID, a charset that can't convert to UTF-8, and a body set withsetBody()that holds a part other than the email's own text, HTML, and attachments, or misses one of them, or holds them in another charset or as an attachment, or carries a header of its own (unsupported_part). A MimeMessagethat isn't anEmailis refused when Symfony'sMessageConverterwould drop something from it: a part's own header, an attachment disposition on its text or HTML, or an attachment where the converter takes the text or HTML. A custom HTML part matches when it is the email's HTML as written, or as Symfony builds it, which turns somecid:<name>references intocid:<content ID>. 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, engagement, sensitive andX-Trackheaders are still checked there, and not sent: the template sets its own. Before 8.2 the transport still loads, also under Composer's--classmap-authoritative; 8.2 is at BETA1, so this path is tested against the beta.http_clientservice Symfony injects, so the app's proxy, TLS andMockHttpClientsettings apply to PostHog sends. Without one, the SDK sends with curl. Both cap PostHog's answer at 1 MB, turn a failed connection into a retryablenetwork_error, and leave a 429 or 503 to the SDK's retries. The client's own retries are off for these requests, so a retryinghttp_clientdoesn't retry on top of the SDK or wait past its Retry-After cap. Its default credentials (auth_bearer,auth_basic, anAuthorizationheader) never go out with a send or a storage upload, including those set on a matchingScopingHttpClientscope.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. A malformed name throwsInvalidArgumentExceptionwhen the transport is built, under the same ruleemails()->send()applies, so a bad value never fails every send at runtime.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 constructor's signature is unchanged by this takeover; it now rejects a malformedintegrationup front. The upstream Symfony PostHog Mailer bridge (PostHog/symfony#1) maps the same email to the same send: theX-Track, engagement and sensitive mapping, clicks without opens passed through to PostHog, the 256-character key,Content-*refused, bodies converted to UTF-8, a custom body that must be complete and in the email's charsets, Sender and every Return-Path held to the From address, repeated option headers, attachment headers and double inline references refused, a custom HTML body compared with the one Email builds, and the filename falling back to the name, then toattachment.<extension>. Every adapter shares the conformance cases in the tests. The sdk-specsemailsspec covers the send options this maps onto; the transport adds no option of its own. Ticket: https://github.com/PostHog/michaelr-context/issues/42Impact
Lets a Symfony app send transactional email through PostHog with one DSN, and counts those sends as
posthog-php-mailer. Watch, from release day: weekly organizations whose transactional email API sends come from a framework integration (the send event'sclient), broken down by integration. Success: theposthog-php-mailerand Laravel rows rise after release, for teams on those frameworks.Warning
🟡 Merge risk: medium · two-way door until the SDK release; the stack's merge to
mainstarts that release and makes the transport public APIMAILER_DSN=posthog+api://and register the factory, in every region: on the base, Symfony refuses the scheme; here each email goes out as one PostHog send, or is refused before any request. Covered by the conformance tests through the realEmailssender, the factory tests through the app's client, the FrameworkBundle kernel test, and Symfony Mailer 6.4.0, 6.4 and 8.2 in CI.EmailError::isRetryable(), under the existing retry tests. Theintegrationcheck moved into one internal rule with the same message, under feat(emails): name the sending integration with an integration option #304's tests.PostHogTransportException, and nothing is dropped.message_too_largeafter a bounded read, covered by a 200 MB file under a 128 MB memory limit.MAILER_DSN. The transport runs only for an app whose DSN namesposthog+api://; pointing the DSN back at its previous transport switches it off.mainstarts the SDK release)Rollout
X-PostHog-Integration: posthog-php-mailer/*. No alert pages on them yet.💚 How did you test it?
X-Track, sensitive, charset and custom-body rules: the new conformance tests on the previous head's mapper, 87 tests with 8 errors and 14 failures (the old mapper refusedopens=falseasopen_tracking_off, sentcapture_engagement, and passedContent-*headers and other charsets).--classmap-authoritative: on the previous head, buildingPostHogTransportfails because the class can't load, since the old file declaredRemoteTemplateSupportwithclass_alias.isRetryable(), the upload-quota row retries and the failing-gateway row doesn't (2 failures).ScopingHttpClientthat brought an inner default back, and credentials and retries set on a matching scope: 3 more failures), a body Email built that keeps a reference it doesn't rewrite, a part with neither filename nor name, and a second Return-Path (2 errors, 1 failure). PostHog Review on the fixes: aMessagewhose conversion drops a part header or disposition (3 failures). Final read 3: a validMessagewith stream bodies that can't rewind (1 error). Final read 4: an attachment as aMessage's body or first mixed part (2 failures). The proof gate's judge: a 200 MBattachFromPath()file under a 128 MB memory limit ended in a fatal out-of-memory error. A mutation that drops the fallback for a client withoutmax_retriesfailstestAClientWithoutRetriesOfItsOwnSendsAsItIs.http_client(the factory and kernel tests reach the real network instead of the app's client: 6 errors, 1 failure);integrationchecked at construction (3 failures).symfony/http-clienton the same Symfony line, as CI'ssymfony-mailerjob now runs it.composer validate --strict, PHPUnit (all 2945 OK; the skips are the existing environment skips plus the template tests), the compliance adapter tests, PHPCS on the changed files,composer api:check(snapshot updated for the new public classes,isRetryable(), andRemoteTemplateSupport; the factory no longer has its own constructor).symfony-maileron 6.4.0, 6.4 and 8.2, the framework jobs, PHPCS, public API, compliance.Emailssender with a fake PostHog in place of the network: it negotiates uploads by hash, takes a PUT only as signed and with the right checksum, refuses an attachment it doesn't hold and clicks without opens, and queues one email per idempotency key. The tests assert the send body, the stored bytes, and the queued email.http_clientthroughmock_response_factory, then sends throughMailerInterface.MockHttpClientbacked by the same fake PostHog, including a file upload, an unreachable host, a busy PostHog that answers 503 once, an oversized answer, aRetryableHttpClient, a client with default credentials (also under aScopingHttpClient, and on a matching scope), a scope that sets retries, and aNativeHttpClientwithout retries of its own.Symfony\Component\MailerandMimeclass and still sends with the core SDK.posthog+api://to a PostHog inbox, before the merge. An earlier head passed the same send on US Cloud: 202 with a message ID, read back asdelivered, withX-PostHog-Integration: posthog-php-mailer/4.20.0.📝 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, and taken over in place by a second stack thread that merged #304 forward and brought the mapping in line with the adapter contract. Hold as a draft: it releases only together with the other SDKs' email send.
X-Trackdo?tracking.opensandtracking.clicks;X-PostHog-Sensitivepassessensitivethrough;capture_engagementis never sent. Replaces theopen_tracking_offrefusalemails()->send()categorymapped?cid:<name>thencid:<content ID>; else an inline part's own content ID; else refused asinline_without_cid"Shop, Inc" <shop@example.com>; a plain name stays as issetBody()body?unsupported_part; template emails aren't checkedposthog+apionly, and none when Symfony's PostHog bridge is installedclicks_need_openscomes back as the transport error, not retried; same in the Symfony bridgeX-Trackname, a part without=, or a secondX-Track?unsupported_header; an empty part is skippedinvalid_template_keyContent-*headers?unsupported_part;Content-*headers are refused like any custom headerEmails($client->emails()), notClient: a DSN-only app has no project key, and a client without one is disabledunsupported_header; a send has one Fromenvelope_mismatch, even when the envelope matches; the send has one senderhttp_clientwhen Symfony injects one, else curl; the internal test hook is goneintegrationcaught?InvalidArgumentException, like the default template keyMessagethat isn't anEmailchecked?MessageConverterrebuilds it, without reading a body: a part header, a text or HTML attachment disposition, or an attachment in a text or HTML position, which the conversion would drop, is refusedattachment.<extension>; every Return-Path is checkedemailsspec covers every option it sends. An adapter spec is a separate sdk-specs changebodyToIterable()and refused asmessage_too_largepast 40 MB after Base64 encoding. Replaces the earlier "no"EmailError::isRetryable(), now public, shared by the client and the transportInvolvement: no grilling for this PR; reviews: four GPT-6.1 Sol rounds on the first heads (all findings fixed), PostHog Review, and a qa-swarm panel: round 1 with 10 lenses (10 findings fixed, 6 rejected with reasons, 1 follow-up), round 2 on the fixes with 6 lenses (6 fixed, 2 rejected with reasons), final reads (5 fixed), and PostHog Review on the fixes (4 fixed); proof captured (red, green on three Mailer versions, lane gate; live run in the launch train).
🤖 Generated with Claude Code