Skip to content

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

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

Silthus wants to merge 6 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. Symfony gives a referenced part a generated content ID when it renders, but the HTML sent still says cid:<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 as Shop, 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 the SentMessage ID.
  • PostHog options as headers. X-PostHog-Distinct-Id, -Template-Key, -Idempotency-Key and -Capture-Engagement set those options. They never go out as email headers. X-PostHog-Capture-Engagement takes true or false and wins over Symfony's X-Track. Without it, X-Track maps to engagement capture. An X-Track whose opens and clicks differ is always refused. The option headers are read raw, so a distinct ID such as 用户42 isn't sent MIME-encoded. The template key comes from the header, then the DSN's default_template_key, then posthog-php-mailer.
  • 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, 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 with setBody() 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.
  • 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 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.
  • 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.

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 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/42

Note

🔵 Merge risk: low · two-way door until released (merge-risk rubric)

  • New, opt-in code under PostHog\Mailer: nothing runs unless an app registers the factory or builds the transport. symfony/mailer and symfony/framework-bundle are dev dependencies only.
  • The one shared-path change: the client's retry decision moved, unchanged, onto EmailError::isRetryable(). The existing retry tests pass, along with a transport test for each row of the errors table.
  • The remote-template path depends on Symfony Mailer 8.2, still at BETA1. If Symfony changes RemoteTemplateEmail before 8.2.0 ships, only that path breaks, and the 8.2 CI job shows it.
  • Revert: git revert the merge commit before the release; after a release, the transport and isRetryable() are public API.

💚 How did you test it?

  • Live run on US Cloud: one send of conformance case 01 through posthog+api://<key>@default and PostHogTransportFactory, to a PostHog inbox. PostHog answered 202 with a message ID, Emails::get() read it back as delivered a second later, and the request carried X-PostHog-Integration: posthog-php-mailer/4.20.0. The From was a verified PostHog address instead of case 01's shop@example.com.
  • Red:
    • On feat(emails): name the sending integration with an integration option #304's head, the Mailer tests fail with every PostHog\Mailer class missing.
    • The contract rules on the first head's mapper: the named inline part in case 10, the quoted sender, the engagement header, and the template-email checks fail (7 tests).
    • The refined rules on the second head's mapper: the referenced attached part, a part Symfony gave a generated content ID, and mixed X-Track with the engagement header fail (3 tests). The Unicode distinct ID test fails without the raw read (sent as =?utf-8?Q?…?=).
    • The body walk on the third head's mapper: the 4 unsupported_part shapes 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.
    • Before the fourth head: a part named logo with content ID logo'v2@shop.example sends as logo, and an html() stream that can't rewind sends empty HTML.
    • Before this head: on Mailer and Mime 6.4.0, the body walk renamed inline parts, so 6 inline tests sent the generated content ID. A custom text part with other text than the email's passed.
  • Green on this head, 67 Mailer tests:
    • PHP 8.2 with Symfony Mailer 7.4 (the lock) and with Mailer 6.4: OK, the 7 template tests skipped.
    • PHP 8.2 with Mailer, Mime and FrameworkBundle 6.4.0: OK, plus one skip for the pre-render case above.
    • PHP 8.4 with Mailer 8.2.0-BETA1: all 67, template tests included.
  • Lane gate on PHP 8.2 and 8.4 in Docker, the CI commands: 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 and isRetryable()).
  • New CI job symfony-mailer runs 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.
  • Test seams:
    • The conformance cases go through the real Emails sender with only its HTTP transport stubbed, asserting the request body and headers.
    • A Symfony kernel test boots FrameworkBundle with the DSN and the service definition above, then sends through MailerInterface.
    • A separate-process test blocks every Symfony\Component\Mailer and Mime class and still sends with the core SDK.

📝 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. Hold as a draft: it releases only together with the other SDKs' email send.

Question Answer Who Where
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
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
What about the From on a template email? Allowed, because Symfony requires one, and not sent; PostHog uses the template's From 🤖 Agent 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. Replaces "explicit content ID first", which broke images Symfony had given a generated content ID 👍 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>, so the server parses it; a plain name stays as is 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
Are the template key and engagement 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
Engagement header and X-Track disagree? The header wins over an X-Track whose flags agree; an X-Track whose opens and clicks differ always refuses as mixed_tracking 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
What about a custom setBody() body? Each part must be the email's text, its HTML, or one of its attachments, else refused as unsupported_part; template emails aren't checked 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
Must a custom body's text match the email's? Yes: a text or HTML part with other content than ->text() or ->html() is refused as unsupported_part, so the send never differs from the body 🤖 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
Which schemes? posthog+api only, and none when Symfony's PostHog bridge is installed 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/42
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
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

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

…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-05T13:27:14.645322+00:00
Duration: 116090ms

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

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-05T13:27:05.718307+00:00
Duration: 115788ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 33ms
Format Validation.Event Has Uuid ✅ 528ms
Format Validation.Event Has Lib Properties ✅ 529ms
Format Validation.Distinct Id Is String ✅ 530ms
Format Validation.Token Is Present ✅ 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

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-05T13:27:04.119344+00:00
Duration: 116474ms

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

Silthus and others added 5 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>

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