Skip to content

feat(mailer): send Symfony Mailer email through PostHog with posthog+api:// - #305

Draft
Silthus wants to merge 32 commits into
feat/emails-integration-optionfrom
feat/symfony-mailer-transport
Draft

Silthus wants to merge 32 commits into
feat/emails-integration-optionfrom
feat/symfony-mailer-transport

Conversation

@Silthus

@Silthus Silthus commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

💡 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 existing Email objects go out as PostHog transactional sends:

# .env
MAILER_DSN=posthog+api://phs_your_project_secret_key@default   # or @eu.posthog.com, or your own host

# config/services.yaml
services:
    PostHog\Mailer\PostHogTransportFactory:
        parent: mailer.transport_factory.abstract
        tags: [mailer.transport_factory]
  • One email, one send. PostHogTransport converts the message to an Email and maps it to the options of one emails()->send(): from, the single to, cc, bcc, reply-to, subject, html, text, attachments, and inline images under the reference the HTML uses: cid:<name>, then cid:<content ID>, each matched as a whole reference, so logo never matches cid: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 sends attachment.<extension> for its type. A Content-ID set as a header counts as the part's content ID, like setContentId(). A sender name with a character RFC 5322 reserves, such as Shop, 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 the SentMessage ID.
  • Files go through the SDK's upload. Attachments and inline images upload by SHA-256 before the send, as 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 a PostHogTransportException that 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 as message_too_large before it fills PHP's memory.
  • PostHog options as headers. X-PostHog-Distinct-Id, -Template-Key, -Idempotency-Key, -Capture-Engagement and -Sensitive set those options. They never go out as email headers. X-PostHog-Capture-Engagement and X-PostHog-Sensitive take true or false. Capture-Engagement false turns off the engagement events (tracking.engagement); X-PostHog-Sensitive: true sends sensitive. The template key comes from the header, then the DSN's default_template_key, then posthog-php-mailer, and is at most 256 characters, as the send API takes.
  • Symfony's X-Track. X-Track: opens=…; clicks=… (or Symfony 8.2's TrackingHeader) sets tracking.opens and tracking.clicks: true and false set the flag, default leaves it to PostHog. An unknown name, a part without =, any other value, or a second X-Track is refused as unsupported_header. The transport never sends capture_engagement. Clicks without opens go to PostHog, which refuses them as clicks_need_opens, and that comes back as the transport error with PostHog's code and field.
  • Refused, not dropped. An email PostHog can't carry fails before any request, as a Mailer LogicException that reads PostHog 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 repeated X-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's method), 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 with setBody() 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 Mime Message that isn't an Email is refused when Symfony's MessageConverter would 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 some cid:<name> references into cid:<content ID>. A silent drop would send a different email than the app built.
  • Errors that say what to do. A failed send is a PostHogTransportException, a Mailer TransportException carrying the status, PostHog's error code, the field, isRetryable(), and getRetryAfter(). That needs EmailError::isRetryable(), so it is now public; the client's own retry loop uses it.
  • Remote templates on Symfony Mailer 8.2. A RemoteTemplateEmail sends a template made in PostHog: its ID and variables, plus distinct ID and idempotency key, and no content. The template key, engagement, sensitive and X-Track headers 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.
  • The app's HTTP client. The factory sends through the http_client service Symfony injects, so the app's proxy, TLS and MockHttpClient settings 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 retryable network_error, and leave a 429 or 503 to the SDK's retries. The client's own retries are off for these requests, so a retrying http_client doesn't retry on top of the SDK or wait past its Retry-After cap. Its default credentials (auth_bearer, auth_basic, an Authorization header) never go out with a send or a storage upload, including those set on a matching ScopingHttpClient scope.
  • Symfony's own bridge wins. When Symfony's own PostHog Mailer bridge is installed, this factory supports no scheme, so the two never compete for posthog+api://.
  • Sends name the integration. Each send carries 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 throws InvalidArgumentException when the transport is built, under the same rule emails()->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/mailer stays a suggest: 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 PostHogTransport with integration: '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 malformed integration up front. The upstream Symfony PostHog Mailer bridge (PostHog/symfony#1) maps the same email to the same send: the X-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 to attachment.<extension>. Every adapter shares the conformance cases in the tests. The sdk-specs emails spec covers the send options this maps onto; the transport adds no option of its own. Ticket: https://github.com/PostHog/michaelr-context/issues/42

Impact

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's client), broken down by integration. Success: the posthog-php-mailer and 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 main starts that release and makes the transport public API

  • Symfony apps that set MAILER_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 real Emails sender, 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.
  • Every other posthog-php user: unchanged. The core never loads Symfony, which a separate-process test proves. The client's retry decision moved, unchanged, onto EmailError::isRetryable(), under the existing retry tests. The integration check moved into one internal rule with the same message, under feat(emails): name the sending integration with an integration option #304's tests.
  • Uncovered: PostHog accepting the mapped send and its files. The launch train's real send at this head covers it. A miss is a broken core action for the few apps that opted in, behind their DSN: their sends fail loudly with a PostHogTransportException, and nothing is dropped.
  • A file too large for the email: refused as message_too_large after a bounded read, covered by a 200 MB file under a 128 MB memory limit.
  • Uncovered: remote templates on Symfony Mailer 8.2, tested against BETA1 only. A change before 8.2.0 breaks only that path, and the 8.2 CI job shows it.
  • Switch-off: MAILER_DSN. The transport runs only for an app whose DSN names posthog+api://; pointing the DSN back at its previous transport switches it off.
  • Tier: critical (f: the stack's merge to main starts the SDK release)

Rollout

💚 How did you test it?

  • Red on the takeover's changes:
    • The 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 refused opens=false as open_tracking_off, sent capture_engagement, and passed Content-* headers and other charsets).
    • Composer's --classmap-authoritative: on the previous head, building PostHogTransport fails because the class can't load, since the old file declared RemoteTemplateSupport with class_alias.
    • Upload errors: with the client's old retry rule in place of isRetryable(), the upload-quota row retries and the failing-gateway row doesn't (2 failures).
    • The review fixes, each test first. Red for the bridge parity (a custom body part or multipart with its own header, the email's own rendered body set as its body): 1 error, 2 failures. Red for PostHog Review (a busy PostHog through the app's client, a Content-ID set as a header): 1 error, 1 failure.
    • Round 2 and the bridge's latest items, each test first: the app's retrying client (12 requests where the SDK makes 3), its default credentials on the storage upload (3 failures; the final reads added a ScopingHttpClient that 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: a Message whose conversion drops a part header or disposition (3 failures). Final read 3: a valid Message with stream bodies that can't rewind (1 error). Final read 4: an attachment as a Message's body or first mixed part (2 failures). The proof gate's judge: a 200 MB attachFromPath() file under a 128 MB memory limit ended in a fatal out-of-memory error. A mutation that drops the fallback for a client without max_retries fails testAClientWithoutRetriesOfItsOwnSendsAsItIs.
    • Earlier review fixes: Sender and Return-Path, custom-body charset and disposition (4 failures); the app's http_client (the factory and kernel tests reach the real network instead of the app's client: 6 errors, 1 failure); integration checked at construction (3 failures).
  • Green on this head, 134 Mailer tests:
    • PHP 8.2 with Mailer 6.4 (latest) and with Mailer, Mime and FrameworkBundle 6.4.0: OK, the template tests skipped, and on 6.4.0 the tests of a body the app rendered first.
    • PHP 8.4 with Mailer 8.2.0-BETA1: all 134, template tests included, none skipped.
    • Each run under an authoritative classmap, with symfony/http-client on the same Symfony line, as CI's symfony-mailer job now runs it.
  • Lane gate on PHP 8.4 in Docker, the CI commands: 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(), and RemoteTemplateSupport; the factory no longer has its own constructor).
  • CI on this head: PHPUnit on PHP 8.2 to 8.5, symfony-mailer on 6.4.0, 6.4 and 8.2, the framework jobs, PHPCS, public API, compliance.
  • Test seams:
    • The conformance cases go through the real Emails sender 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.
    • A Symfony kernel test boots FrameworkBundle with the DSN and the service definition above, answers its http_client through mock_response_factory, then sends through MailerInterface.
    • The factory tests send through a MockHttpClient backed by the same fake PostHog, including a file upload, an unreachable host, a busy PostHog that answers 503 once, an oversized answer, a RetryableHttpClient, a client with default credentials (also under a ScopingHttpClient, and on a matching scope), a scope that sets retries, and a NativeHttpClient without retries of its own.
    • A separate-process test blocks every Symfony\Component\Mailer and Mime class and still sends with the core SDK.
  • Live run: owed at this head. It runs in the email launch train, one real send of conformance case 01 through 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 as delivered, with X-PostHog-Integration: posthog-php-mailer/4.20.0.

📝 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. The Symfony Mailer docs page is a separate posthog.com PR.
  • 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)

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.

Question Answer Who Where
What does X-Track do? Sets tracking.opens and tracking.clicks; X-PostHog-Sensitive passes sensitive through; capture_engagement is never sent. Replaces the open_tracking_off refusal 👤 Human https://github.com/PostHog/michaelr-context/issues/42
How do attachments travel? Through the SDK's upload by hash, like emails()->send() 👤 Human https://github.com/PostHog/michaelr-context/issues/42
Is category mapped? No, out of scope 👤 Human https://github.com/PostHog/michaelr-context/issues/42
What happens to an email PostHog can't carry? Refused before any request with a named code; nothing is dropped silently 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
Which content ID does an inline part get? The reference the HTML uses, cid:<name> then cid:<content ID>; else an inline part's own content ID; else refused as inline_without_cid 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
How does a sender name with a comma go out? Quoted, such as "Shop, Inc" <shop@example.com>; a plain name stays as is 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
Are the option headers checked on a template email? Yes, and refused as on a rendered email, but not sent 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
What about a custom setBody() body? It must hold exactly the email's text, HTML and attachments, else unsupported_part; template emails aren't checked 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
Which schemes? posthog+api only, and none when Symfony's PostHog bridge is installed 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
Which Symfony versions? Mailer 6.4, 7.x and 8.x; remote templates on 8.2 and later 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
Clicks without opens? Passed through to PostHog, whose clicks_need_opens comes back as the transport error, not retried; same in the Symfony bridge 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
An unknown X-Track name, a part without =, or a second X-Track? Refused as unsupported_header; an empty part is skipped 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
How long may a template key be? At most 256 characters, as the send API takes; longer is invalid_template_key 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
A body in another charset, or Content-* headers? Bodies convert to UTF-8, an unconvertible charset is unsupported_part; Content-* headers are refused like any custom header 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
What does the transport take? Emails ($client->emails()), not Client: a DSN-only app has no project key, and a client without one is disabled 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Two From addresses? Refused as unsupported_header; a send has one From 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Sender or Return-Path other than the From address? Refused as envelope_mismatch, even when the envelope matches; the send has one sender 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Which HTTP client sends? The app's http_client when Symfony injects one, else curl; the internal test hook is gone 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
When is a bad integration caught? When the transport is built, as InvalidArgumentException, like the default template key 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Is a Mime Message that isn't an Email checked? Yes, before MessageConverter rebuilds 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 refused 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Does the mapping match the Symfony bridge? Yes: repeated option headers, part headers and custom-body checks refused the same way; a Content-ID header counts as the content ID; a custom HTML body is compared with the one Email builds; a part with no name sends attachment.<extension>; every Return-Path is checked 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Does the app's retrying client retry PostHog requests? No: each request turns its retries off, and the SDK alone retries, within its Retry-After cap 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Do the app client's default credentials go out? No: they stay off the send and the storage upload, which carry their own 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Is the adapter mapping held to an sdk-specs spec? No spec covers framework mail adapters, so it follows the repo rule to carry on; the emails spec covers every option it sends. An adapter spec is a separate sdk-specs change 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42
Bound attachment size before reading the file? Yes: files are read in chunks through bodyToIterable() and refused as message_too_large past 40 MB after Base64 encoding. Replaces the earlier "no" 🤖 Agent #305 (comment)
Where does the retry decision live? On EmailError::isRetryable(), now public, shared by the client and the transport 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42

Involvement: 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

…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>
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-socket Compliance Report

Date: 2026-11-13T08:05:36.010084+00:00
Duration: 115966ms

⚠️ 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 ✅ 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

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-13T08:05:19.986908+00:00
Duration: 115313ms

⚠️ 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 ✅ 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

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-13T08:05:47.399190+00:00
Duration: 116552ms

⚠️ 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 ✅ 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

Silthus and others added 6 commits November 5, 2026 12:54
… 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>

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

Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Silthus and others added 2 commits November 9, 2026 13:45
…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>

@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 617f4bd: 1 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Silthus and others added 3 commits November 9, 2026 14:02
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>
Silthus and others added 3 commits November 13, 2026 05:02
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>

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

Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
Silthus and others added 6 commits November 13, 2026 05:19
… 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>

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

Comment thread lib/Mailer/PostHogTransportFactory.php Resolved
Comment thread lib/Internal/Mailer/EmailMapper.php Resolved
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>
Silthus and others added 5 commits November 13, 2026 06:29
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>

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

Comment thread lib/Mailer/PostHogTransportFactory.php Outdated
Comment on lines +120 to +124
$client = $client->withOptions(
['headers' => ['authorization' => []], 'auth_basic' => null, 'auth_bearer' => null]
);
try {
return $client->withOptions(['max_retries' => 0]);

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.

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.

Comment thread lib/Mailer/PostHogTransport.php Outdated

protected function doSend(SentMessage $message): void
{
$send = $this->mapper->map(MessageConverter::toEmail($message->getOriginalMessage()), $message->getEnvelope());

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.

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.

Silthus and others added 5 commits November 13, 2026 07:32
…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>

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