From 55c1378a3aee70ed3b65c5c6bef61b5c9200606e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:46:15 +0000 Subject: [PATCH 01/30] feat(mailer): send Symfony Mailer email through PostHog with posthog+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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- .changeset/symfony-mailer-transport.md | 5 + .github/workflows/php.yml | 40 + api/public-api.json | 253 ++ composer.json | 7 +- composer.lock | 2398 ++++++++++++++++- lib/EmailError.php | 16 + lib/Emails.php | 18 +- lib/Internal/Mailer/EmailMapper.php | 279 ++ lib/Internal/Mailer/NoRemoteTemplates.php | 10 + lib/Internal/Mailer/RemoteTemplateSupport.php | 13 + lib/Mailer/PostHogTransport.php | 80 + lib/Mailer/PostHogTransportException.php | 61 + lib/Mailer/PostHogTransportFactory.php | 66 + test/Mailer/CoreWithoutSymfonyMailerTest.php | 53 + test/Mailer/MailerKernel.php | 50 + .../PostHogTransportConformanceTest.php | 286 ++ test/Mailer/PostHogTransportErrorsTest.php | 139 + test/Mailer/PostHogTransportFactoryTest.php | 79 + test/Mailer/RecordedRequests.php | 23 + test/Mailer/SendsThroughStubbedHttp.php | 69 + test/Mailer/SymfonyKernelTest.php | 42 + 21 files changed, 3936 insertions(+), 51 deletions(-) create mode 100644 .changeset/symfony-mailer-transport.md create mode 100644 lib/Internal/Mailer/EmailMapper.php create mode 100644 lib/Internal/Mailer/NoRemoteTemplates.php create mode 100644 lib/Internal/Mailer/RemoteTemplateSupport.php create mode 100644 lib/Mailer/PostHogTransport.php create mode 100644 lib/Mailer/PostHogTransportException.php create mode 100644 lib/Mailer/PostHogTransportFactory.php create mode 100644 test/Mailer/CoreWithoutSymfonyMailerTest.php create mode 100644 test/Mailer/MailerKernel.php create mode 100644 test/Mailer/PostHogTransportConformanceTest.php create mode 100644 test/Mailer/PostHogTransportErrorsTest.php create mode 100644 test/Mailer/PostHogTransportFactoryTest.php create mode 100644 test/Mailer/RecordedRequests.php create mode 100644 test/Mailer/SendsThroughStubbedHttp.php create mode 100644 test/Mailer/SymfonyKernelTest.php diff --git a/.changeset/symfony-mailer-transport.md b/.changeset/symfony-mailer-transport.md new file mode 100644 index 00000000..9ffd8d13 --- /dev/null +++ b/.changeset/symfony-mailer-transport.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Send Symfony Mailer email through PostHog: set `MAILER_DSN=posthog+api://@default` and register `PostHog\Mailer\PostHogTransportFactory`. Rendered emails, attachments, inline images, and the PostHog options (distinct ID, template key, idempotency key, engagement capture) map to a transactional send, and an email PostHog cannot carry is refused before any request. On Symfony Mailer 8.2, a `RemoteTemplateEmail` sends a PostHog template. `EmailError::isRetryable()` is now public. diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index be882bdb..7a5b648d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -115,6 +115,46 @@ jobs: if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: python3 -m unittest discover -s sdk_compliance_adapter -v + symfony-mailer: + needs: detect-markdown-only + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # The lock tests Symfony Mailer 7.4; these cover the oldest and newest supported lines. + - php-version: "8.2" + symfony: "6.4.*" + stability: stable + # Symfony Mailer 8.2 adds remote templates; move to stable once 8.2.0 ships. + - php-version: "8.4" + symfony: "8.2.*" + stability: beta + name: symfony-mailer (${{ matrix.symfony }}) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + with: + php-version: ${{ matrix.php-version }} + extensions: zlib + tools: composer + + - name: Install Symfony ${{ matrix.symfony }} + env: + SYMFONY: ${{ matrix.symfony }} + STABILITY: ${{ matrix.stability }} + run: | + composer config minimum-stability "$STABILITY" + composer config prefer-stable true + composer require --dev --no-update "symfony/mailer:$SYMFONY" "symfony/framework-bundle:$SYMFONY" + composer update --with-all-dependencies --prefer-dist --no-progress + + - name: Run the Symfony Mailer tests + run: ./vendor/bin/phpunit --bootstrap vendor/autoload.php --configuration phpunit.xml --no-coverage test/Mailer + coverage: runs-on: ubuntu-latest needs: diff --git a/api/public-api.json b/api/public-api.json index 1927e0c5..41a03716 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -1843,6 +1843,13 @@ "final": false, "returnType": "?string", "parameters": [] + }, + "isRetryable": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] } } }, @@ -4155,6 +4162,252 @@ } } }, + "PostHog\\Mailer\\PostHogTransport": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": "Symfony\\Component\\Mailer\\Transport\\AbstractTransport", + "implements": [ + "PostHog\\Internal\\Mailer\\NoRemoteTemplates", + "Stringable", + "Symfony\\Component\\Mailer\\Transport\\TransportInterface" + ], + "constants": { + "INTEGRATION": { + "type": "string", + "value": "posthog-php-mailer" + } + }, + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "emails", + "type": "PostHog\\Emails", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "integration", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "defaultTemplateKey", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "templates", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": true, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "dispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "logger", + "type": "?Psr\\Log\\LoggerInterface", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "__toString": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + } + } + }, + "PostHog\\Mailer\\PostHogTransportException": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": "Symfony\\Component\\Mailer\\Exception\\TransportException", + "implements": [ + "Stringable", + "Symfony\\Component\\Mailer\\Exception\\ExceptionInterface", + "Symfony\\Component\\Mailer\\Exception\\TransportExceptionInterface", + "Throwable" + ], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "error", + "type": "PostHog\\EmailError", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "getErrorCode": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + }, + "getField": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?string", + "parameters": [] + }, + "getRetryAfter": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?int", + "parameters": [] + }, + "getStatusCode": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?int", + "parameters": [] + }, + "isRetryable": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + } + } + }, + "PostHog\\Mailer\\PostHogTransportFactory": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": "Symfony\\Component\\Mailer\\Transport\\AbstractTransportFactory", + "implements": [ + "Symfony\\Component\\Mailer\\Transport\\TransportFactoryInterface" + ], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "dispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "client", + "type": "?Symfony\\Contracts\\HttpClient\\HttpClientInterface", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "logger", + "type": "?Psr\\Log\\LoggerInterface", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "sendRequest", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "create": { + "static": false, + "abstract": false, + "final": false, + "returnType": "Symfony\\Component\\Mailer\\Transport\\TransportInterface", + "parameters": [ + { + "name": "dsn", + "type": "Symfony\\Component\\Mailer\\Transport\\Dsn", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + } + } + }, "PostHog\\OpenTelemetry\\ContextSpanProcessor": { "type": "class", "abstract": false, diff --git a/composer.json b/composer.json index af79f0e8..eb967b05 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,12 @@ "squizlabs/php_codesniffer": "^4.0", "ext-zlib": "*", "open-telemetry/api": "^1.0", - "open-telemetry/sdk": "^1.0" + "open-telemetry/sdk": "^1.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0" + }, + "suggest": { + "symfony/mailer": "Send Symfony Mailer email through PostHog with the posthog+api:// transport." }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 32704af0..95a165a1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "21533fc169589e1cf838a08beee4cb6a", + "content-hash": "2c2557b627f2b612d9f20e5bf9de704c", "packages": [ { "name": "psr/clock", @@ -303,16 +303,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", "shasum": "" }, "require": { @@ -359,7 +359,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" }, "funding": [ { @@ -379,7 +379,8 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-09-25T15:50:05+00:00", + "published-time": "2026-09-30T10:23:43+00:00" } ], "packages-dev": [ @@ -520,6 +521,150 @@ ], "time": "2026-10-22T15:37:26+00:00" }, + { + "name": "doctrine/lexer", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2026-10-01T06:59:29+00:00", + "published-time": "2026-10-01T07:01:28+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00", + "published-time": "2025-03-20T21:39:03+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -1644,6 +1789,161 @@ ], "time": "2026-02-18T12:37:06+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00", + "published-time": "2021-02-03T23:27:34+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00", + "published-time": "2021-11-05T16:53:27+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00", + "published-time": "2019-03-25T15:31:44+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", @@ -1852,7 +2152,8 @@ "support": { "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2024-09-11T13:17:53+00:00" + "time": "2024-09-11T13:17:53+00:00", + "published-time": "2024-09-11T13:21:14+00:00" }, { "name": "ramsey/collection", @@ -3131,38 +3432,63 @@ "time": "2024-10-20T05:08:20+00:00" }, { - "name": "symfony/polyfill-php82", - "version": "v1.43.0", + "name": "symfony/cache", + "version": "v7.4.23", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + "url": "https://github.com/symfony/cache.git", + "reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", - "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "url": "https://api.github.com/repos/symfony/cache/zipball/bbaa2ca4a93a94532285bdd5a0caf225d34ff578", + "reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "doctrine/dbal": "<3.6", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php82\\": "" + "Symfony\\Component\\Cache\\": "" }, "classmap": [ - "Resources/stubs" + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3179,16 +3505,14 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "caching", + "psr6" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + "source": "https://github.com/symfony/cache/tree/v7.4.23" }, "funding": [ { @@ -3208,7 +3532,2015 @@ "type": "tidelift" } ], - "time": "2026-09-25T15:50:05+00:00" + "time": "2026-11-01T22:26:51+00:00", + "published-time": "2026-11-04T11:28:20+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "af26a91232085b7edd4e04be62f75e7c25159ece" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/af26a91232085b7edd4e04be62f75e7c25159ece", + "reference": "af26a91232085b7edd4e04be62f75e7c25159ece", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T07:05:14+00:00", + "published-time": "2026-11-04T11:25:38+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/8ef666abf731e85e59cf478929dda6c7cb2fa9c4", + "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:27:33+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4df0d83a8253d95b222c2f136b9dca3a1a9451a7", + "reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-01T17:38:00+00:00", + "published-time": "2026-11-04T11:27:44+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:34+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/545852cfe9c4979c82389155b4d8d05f8864e4f1", + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-25T14:40:16+00:00", + "published-time": "2026-10-28T18:20:15+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00", + "published-time": "2026-08-22T13:44:01+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "658e29751424da3ef4758c60f769e59d1b2981eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/658e29751424da3ef4758c60f769e59d1b2981eb", + "reference": "658e29751424da3ef4758c60f769e59d1b2981eb", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:36+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/53d609da66578dad5a39fe290e8f924ec8f05e93", + "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:28:00+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "c723f990aefe8c1053b18374faf4231eba84f518" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/c723f990aefe8c1053b18374faf4231eba84f518", + "reference": "c723f990aefe8c1053b18374faf4231eba84f518", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-02T10:56:49+00:00", + "published-time": "2026-11-04T11:28:12+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "6a749f2c37cb9def53793633ec72958aaad4b359" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a749f2c37cb9def53793633ec72958aaad4b359", + "reference": "6a749f2c37cb9def53793633ec72958aaad4b359", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4.12|^7.0|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.15|~8.0.15|^8.1.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.4.23|^8.1.11", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4.43|>=7.0,<7.4.15|>=8.0,<8.0.15", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<7.4", + "symfony/http-client": "<6.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", + "symfony/messenger": "<7.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4.43|^7.4.15|^8.0.15", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/json-streamer": "^7.3|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/notifier": "^6.4|^7.0|^8.0", + "symfony/object-mapper": "^7.3|^8.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/semaphore": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.2.5|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/webhook": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.3|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T07:46:24+00:00", + "published-time": "2026-11-04T11:27:38+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7fb73cffa06e2bcc2cb523fcccb1e8174106b206", + "reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:28:53+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/94c57f4c5c45a311d244a0aeadb72b14ee41d25d", + "reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T11:26:18+00:00", + "published-time": "2026-11-04T11:27:47+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "1aa1e2ee7515826bb17b0ad0d2f16962fcaa7859" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/1aa1e2ee7515826bb17b0ad0d2f16962fcaa7859", + "reference": "1aa1e2ee7515826bb17b0ad0d2f16962fcaa7859", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-23T16:51:52+00:00", + "published-time": "2026-10-28T18:19:49+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/94a4c017435bc515f5ac80421a2c555fb9ca3676", + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T17:57:58+00:00", + "published-time": "2026-10-28T18:20:24+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00", + "published-time": "2026-04-26T14:44:50+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T14:40:04+00:00", + "published-time": "2026-09-30T10:23:39+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00", + "published-time": "2026-09-30T10:23:47+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00", + "published-time": "2026-09-30T10:23:32+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/0000ade604b7bc798cb1e75edf6f17df48185f73", + "reference": "0000ade604b7bc798cb1e75edf6f17df48185f73", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:28:04+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-20T17:38:25+00:00", + "published-time": "2026-11-04T11:25:33+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-25T21:04:04+00:00", + "published-time": "2026-10-28T18:20:00+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "6c967db30708068f6eaedb6ed37973bd86de2241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/6c967db30708068f6eaedb6ed37973bd86de2241", + "reference": "6c967db30708068f6eaedb6ed37973bd86de2241", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-22T14:50:44+00:00", + "published-time": "2026-10-28T18:20:27+00:00" }, { "name": "tbachert/spi", diff --git a/lib/EmailError.php b/lib/EmailError.php index ffab5634..d8a057de 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -124,6 +124,22 @@ public function getRetryAfterMs(): ?int return $this->retryAfterMs; } + /** + * Whether sending the email again with the same idempotency key can succeed: no answer arrived, the + * same key is still being sent, PostHog throttled the request, or the service was unavailable. The + * client already retried these up to its retry limit. + */ + public function isRetryable(): bool + { + return match ($this->statusCode) { + null => $this->errorCode === 'network_error', + 409 => $this->errorCode === 'idempotency_key_in_flight', + 429 => $this->errorCode !== 'quota_limited', + 503 => true, + default => false, + }; + } + private static function nameFor(?int $status, string $code): string { if ($code === 'missing_secret_key' || $status === 401) { diff --git a/lib/Emails.php b/lib/Emails.php index ed8150d1..35bdbd09 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -666,25 +666,9 @@ private static function onOneLogLine(string $text): string return addcslashes($text, "\0..\37\177\\"); } - private static function isRetryable(EmailError $error): bool - { - $status = $error->getStatusCode(); - if ($status === null) { - return $error->getErrorCode() === 'network_error'; - } - if ($status === 409) { - return $error->getErrorCode() === 'idempotency_key_in_flight'; - } - if ($status === 429) { - return $error->getErrorCode() !== 'quota_limited'; - } - - return $status === 503; - } - private static function retryDelayMs(EmailError $error, int $attempt, int $maxRetries): ?int { - if ($attempt >= $maxRetries || !self::isRetryable($error)) { + if ($attempt >= $maxRetries || !$error->isRetryable()) { return null; } $retryAfterMs = $error->getRetryAfterMs(); diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php new file mode 100644 index 00000000..ddea5115 --- /dev/null +++ b/lib/Internal/Mailer/EmailMapper.php @@ -0,0 +1,279 @@ + The options of Emails::send(). + */ + public function map(Email $email, Envelope $envelope): array + { + $to = $this->onlyRecipient($email); + $options = $this->headerOptions($email); + $this->refuseEnvelopeMismatch($email, $envelope); + $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; + $send = $template === null + ? $this->renderedContent($email, $options) + : $this->templateContent($email, $template->getReference(), $template->getVariables()); + + return array_filter( + ['to' => $to, ...$send, 'distinct_id' => $options[self::DISTINCT_ID] ?? null, + 'idempotency_key' => $options[self::IDEMPOTENCY_KEY] ?? null, 'integration' => $this->integration], + static fn($value): bool => $value !== null + ); + } + + private function onlyRecipient(Email $email): string + { + $to = $email->getTo(); + if (count($to) !== 1) { + throw self::refusal('multiple_to', 'It takes exactly one To address; send one email per recipient.'); + } + + return $to[0]->getAddress(); + } + + /** + * @return array + */ + private function headerOptions(Email $email): array + { + $options = []; + foreach ($email->getHeaders()->all() as $header) { + $name = strtolower($header->getName()); + if (in_array($name, self::OPTION_HEADERS, true)) { + $options[$name] = $header->getBodyAsString(); + continue; + } + self::refuseHeader($name); + } + if ($email->getPriority() !== self::NORMAL_PRIORITY) { + throw self::refusal('unsupported_priority', 'It has no priority; leave the priority normal.'); + } + if (count($email->getFrom()) > 1) { + throw self::refusal('unsupported_header', 'It takes one From address.'); + } + + return $options; + } + + private static function refuseHeader(string $name): void + { + if ( + in_array($name, self::MAPPED_HEADERS, true) + || in_array($name, self::IGNORED_HEADERS, true) + || str_starts_with($name, 'content-') + || $name === self::TRACKING_HEADER + ) { + return; + } + if ($name === 'x-tag') { + throw self::refusal('tags', 'It has no tags yet; remove the tags.'); + } + if (str_starts_with($name, 'x-metadata-')) { + throw self::refusal('metadata', 'It has no metadata yet; remove the metadata.'); + } + throw self::refusal('unsupported_header', "It can't carry the $name header; remove it."); + } + + private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void + { + $from = $email->getFrom()[0] ?? null; + $recipients = self::addresses([...$email->getTo(), ...$email->getCc(), ...$email->getBcc()]); + $envelopeRecipients = self::addresses($envelope->getRecipients()); + sort($recipients); + sort($envelopeRecipients); + if ($from?->getAddress() !== $envelope->getSender()->getAddress() || $recipients !== $envelopeRecipients) { + throw self::refusal( + 'envelope_mismatch', + 'It sends from the From address to the To, Cc and Bcc addresses only.' + ); + } + } + + /** + * @param array $options + * @return array + */ + private function renderedContent(Email $email, array $options): array + { + return [ + 'template_key' => $this->templateKey($options[self::TEMPLATE_KEY] ?? null), + 'subject' => $email->getSubject(), + 'html' => self::text($email->getHtmlBody()), + 'text' => self::text($email->getTextBody()), + 'from' => ($from = $email->getFrom()[0] ?? null) === null ? null : self::mailbox($from), + 'reply_to' => self::addresses($email->getReplyTo()), + 'cc' => self::addresses($email->getCc()), + 'bcc' => self::addresses($email->getBcc()), + 'attachments' => array_map(self::attachment(...), $email->getAttachments()), + 'capture_engagement' => self::captureEngagement($email, $options[self::CAPTURE_ENGAGEMENT] ?? null), + ]; + } + + /** + * @param array $variables + * @return array + */ + private function templateContent(Email $email, string $templateId, array $variables): array + { + if (!$this->templates) { + throw self::refusal('template_unsupported', 'This mailer sends rendered content only, not a template.'); + } + $content = $email->getSubject() !== null || $email->getReplyTo() !== [] || $email->getCc() !== [] + || $email->getBcc() !== [] || $email->getAttachments() !== []; + if ($content) { + throw self::refusal( + 'template_with_content', + 'A template made in PostHog sets its own subject and content, so remove the subject, Reply-To, ' + . 'Cc, Bcc and attachments.' + ); + } + + return ['template_id' => $templateId, 'template_data' => $variables]; + } + + private function templateKey(?string $explicit): string + { + if ($explicit === null) { + return $this->defaultTemplateKey ?? explode('/', $this->integration, 2)[0]; + } + if (preg_match(self::TEMPLATE_KEY_PATTERN, $explicit) !== 1) { + throw self::refusal( + 'invalid_template_key', + 'X-PostHog-Template-Key can only have letters, digits, hyphens (-) and underscores (_), at most 400.' + ); + } + + return $explicit; + } + + private static function captureEngagement(Email $email, ?string $explicit): bool|string|null + { + $choices = array_values(array_unique(array_filter( + [self::flag($explicit), ...self::tracking($email)], + static fn($choice): bool => $choice !== null + ), SORT_REGULAR)); + if (count($choices) > 1) { + throw self::refusal( + 'mixed_tracking', + 'It captures opens and clicks together; set them to the same value.' + ); + } + + return $choices[0] ?? null; + } + + /** + * @return list + */ + private static function tracking(Email $email): array + { + $header = $email->getHeaders()->get(self::TRACKING_HEADER); + if ($header === null) { + return []; + } + $flags = ['opens' => null, 'clicks' => null]; + foreach (explode(';', $header->getBodyAsString()) as $part) { + [$name, $value] = array_map('trim', explode('=', $part, 2)) + [1 => '']; + if (array_key_exists($name, $flags)) { + $flags[$name] = ['true' => true, 'false' => false][$value] ?? null; + } + } + + return array_values($flags); + } + + private static function flag(?string $value): bool|string|null + { + return match ($value === null ? null : strtolower(trim($value))) { + null => null, + 'true' => true, + 'false' => false, + default => $value, + }; + } + + /** + * @return array + */ + private static function attachment(DataPart $part): array + { + $attachment = ['filename' => $part->getFilename(), 'content' => $part->getBody()]; + $attachment['content_type'] = $part->getContentType(); + if ($part->getDisposition() === 'inline') { + $contentId = $part->hasContentId() ? $part->getContentId() : $part->getName(); + if ($contentId === null) { + throw self::refusal('inline_without_cid', 'An inline attachment needs a name the HTML refers to.'); + } + $attachment['content_id'] = $contentId; + } + + return $attachment; + } + + /** + * @param Address[] $addresses + * @return list + */ + private static function addresses(array $addresses): array + { + return array_values(array_map(static fn(Address $address): string => $address->getAddress(), $addresses)); + } + + private static function mailbox(Address $address): string + { + return $address->getName() === '' ? $address->getAddress() : "{$address->getName()} <{$address->getAddress()}>"; + } + + /** + * @param string|resource|null $body + */ + private static function text(mixed $body): ?string + { + if (!is_resource($body)) { + return $body; + } + if (stream_get_meta_data($body)['seekable']) { + rewind($body); + } + + return stream_get_contents($body); + } + + private static function refusal(string $code, string $detail): LogicException + { + return new LogicException("PostHog cannot send this email: $code. $detail"); + } +} diff --git a/lib/Internal/Mailer/NoRemoteTemplates.php b/lib/Internal/Mailer/NoRemoteTemplates.php new file mode 100644 index 00000000..85fa1dbc --- /dev/null +++ b/lib/Internal/Mailer/NoRemoteTemplates.php @@ -0,0 +1,10 @@ +` and sends nothing. A failed send throws a + * PostHogTransportException that says whether sending again with the same idempotency key is safe. + * + * The reserved headers `X-PostHog-Distinct-Id`, `X-PostHog-Template-Key`, `X-PostHog-Idempotency-Key` and + * `X-PostHog-Capture-Engagement` set the send's options and never go out. On Symfony Mailer 8.2 and later, + * a RemoteTemplateEmail sends a template made in PostHog by its ID. + */ +final class PostHogTransport extends AbstractTransport implements RemoteTemplateSupport +{ + public const INTEGRATION = 'posthog-php-mailer'; + + private EmailMapper $mapper; + + /** + * @param Emails $emails The email sender, such as `$client->emails()`. + * @param string|null $integration The integration that sends, `/`. A framework package + * that registers this transport passes its own, such as `laravel/1.4.0`. Defaults to + * `posthog-php-mailer/`. + * @param string|null $defaultTemplateKey The template key of an email without `X-PostHog-Template-Key`. + * Defaults to the integration's name. + * @param bool $templates Whether a RemoteTemplateEmail may send a template made in PostHog. + */ + public function __construct( + private readonly Emails $emails, + ?string $integration = null, + ?string $defaultTemplateKey = null, + bool $templates = true, + ?EventDispatcherInterface $dispatcher = null, + ?LoggerInterface $logger = null, + ) { + if ($defaultTemplateKey !== null && preg_match('/^[a-zA-Z0-9_-]{1,400}$/D', $defaultTemplateKey) !== 1) { + throw new InvalidArgumentException( + 'The default template key can only have letters, digits, hyphens (-) and underscores (_), at most 400.' + ); + } + parent::__construct($dispatcher, $logger); + $this->mapper = new EmailMapper( + $integration ?? self::INTEGRATION . '/' . PostHog::VERSION, + $defaultTemplateKey, + $templates + ); + } + + public function __toString(): string + { + return 'posthog+api://'; + } + + protected function doSend(SentMessage $message): void + { + $send = $this->mapper->map(MessageConverter::toEmail($message->getOriginalMessage()), $message->getEnvelope()); + try { + $result = $this->emails->send($send); + } catch (EmailError $error) { + throw new PostHogTransportException($error); + } + $message->setMessageId($result->getId()); + } +} diff --git a/lib/Mailer/PostHogTransportException.php b/lib/Mailer/PostHogTransportException.php new file mode 100644 index 00000000..3ab0ba2a --- /dev/null +++ b/lib/Mailer/PostHogTransportException.php @@ -0,0 +1,61 @@ +getMessage(), 0, $error); + } + + /** + * The HTTP status, or null when PostHog did not answer or the client refused the email before sending. + */ + public function getStatusCode(): ?int + { + return $this->error->getStatusCode(); + } + + /** + * PostHog's error code, such as `recipient_suppressed`, or an SDK code such as `network_error`. + */ + public function getErrorCode(): string + { + return $this->error->getErrorCode(); + } + + /** + * The field of the send the error is about, such as `to`. + */ + public function getField(): ?string + { + return $this->error->getField(); + } + + /** + * Whether sending the email again with the same idempotency key can succeed. Set the + * `X-PostHog-Idempotency-Key` header, such as from an order ID, so that a retry never sends twice. + */ + public function isRetryable(): bool + { + return $this->error->isRetryable(); + } + + /** + * How many seconds PostHog asked the client to wait before it sends again. + */ + public function getRetryAfter(): ?int + { + $retryAfterMs = $this->error->getRetryAfterMs(); + + return $retryAfterMs === null ? null : (int) ceil($retryAfterMs / 1000); + } +} diff --git a/lib/Mailer/PostHogTransportFactory.php b/lib/Mailer/PostHogTransportFactory.php new file mode 100644 index 00000000..589df2c8 --- /dev/null +++ b/lib/Mailer/PostHogTransportFactory.php @@ -0,0 +1,66 @@ +getScheme(), $this->getSupportedSchemes(), true)) { + throw new UnsupportedSchemeException($dsn, 'posthog', $this->getSupportedSchemes()); + } + return new PostHogTransport( + new Emails($this->getUser($dsn), 'default', appHost: self::appHost($dsn), transport: $this->sendRequest), + defaultTemplateKey: $dsn->getOption('default_template_key'), + dispatcher: $this->dispatcher, + logger: $this->logger + ); + } + + protected function getSupportedSchemes(): array + { + return class_exists(self::BRIDGE_FACTORY) ? [] : [self::SCHEME]; + } + + private static function appHost(Dsn $dsn): string + { + $host = $dsn->getHost() === 'default' ? 'us.posthog.com' : $dsn->getHost(); + $port = $dsn->getPort(); + + return 'https://' . $host . ($port === null ? '' : ':' . $port); + } +} diff --git a/test/Mailer/CoreWithoutSymfonyMailerTest.php b/test/Mailer/CoreWithoutSymfonyMailerTest.php new file mode 100644 index 00000000..16781fb5 --- /dev/null +++ b/test/Mailer/CoreWithoutSymfonyMailerTest.php @@ -0,0 +1,53 @@ + 'us.i.posthog.com'], + loadFeatureFlags: false, + secretKey: 'phs_secret' + ); + $this->assertInstanceOf(Emails::class, $client->emails()); + + $emails = new Emails( + 'phs_secret', + 'us.i.posthog.com', + transport: static fn(): array => [202, '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}', []] + ); + $result = $emails->send([ + 'to' => 'ana@example.com', + 'from' => 'shop@example.com', + 'template_key' => 'order-shipped', + 'subject' => 'Your order shipped', + 'text' => 'On its way.', + ]); + + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); + } +} diff --git a/test/Mailer/MailerKernel.php b/test/Mailer/MailerKernel.php new file mode 100644 index 00000000..4bc8d73c --- /dev/null +++ b/test/Mailer/MailerKernel.php @@ -0,0 +1,50 @@ +extension('framework', [ + 'secret' => 'test', + 'test' => true, + 'http_method_override' => false, + 'mailer' => ['dsn' => 'posthog+api://phs_kernel@default'], + ]); + $services = $container->services(); + $services->alias('test.mailer', MailerInterface::class)->public(); + $services->set('posthog.test.send_request', Closure::class) + ->factory([Closure::class, 'fromCallable']) + ->args([[RecordedRequests::class, 'send']]); + $services->set(PostHogTransportFactory::class) + ->parent('mailer.transport_factory.abstract') + ->arg('$sendRequest', service('posthog.test.send_request')) + ->tag('mailer.transport_factory'); + } +} diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php new file mode 100644 index 00000000..a65d2791 --- /dev/null +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -0,0 +1,286 @@ + 'ana@example.com', + 'from' => 'Shop ', + 'subject' => 'Your order shipped', + 'html' => '

On its way.

', + 'text' => 'On its way.', + ]; + + private static function shipped(): Email + { + return (new Email()) + ->from(new Address('shop@example.com', 'Shop')) + ->to(new Address('ana@example.com', 'Ana')) + ->subject('Your order shipped') + ->html('

On its way.

') + ->text('On its way.'); + } + + public static function sends(): array + { + return [ + '01 rendered minimal' => [ + fn() => self::shipped(), + self::RENDERED + ['template_key' => 'posthog-php-mailer'], + [], + ], + '02 display names' => [ + fn() => self::shipped()->cc(new Address('bo@example.com', 'Bo')) + ->replyTo(new Address('help@example.com', 'Help')), + self::RENDERED + [ + 'cc' => ['bo@example.com'], + 'reply_to' => ['help@example.com'], + 'template_key' => 'posthog-php-mailer', + ], + [], + ], + '03 cc, bcc and reply-to' => [ + fn() => self::shipped()->cc('bo@example.com')->bcc('audit@example.com') + ->replyTo('a@example.com', 'b@example.com'), + self::RENDERED + [ + 'cc' => ['bo@example.com'], + 'bcc' => ['audit@example.com'], + 'reply_to' => ['a@example.com', 'b@example.com'], + 'template_key' => 'posthog-php-mailer', + ], + [], + ], + '04 attachments and inline' => [ + fn() => self::shipped()->html('') + ->addPart(new DataPart(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf')) + ->addPart((new DataPart(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png')) + ->asInline()->setName('logo')), + ['html' => ''] + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [ + ['filename' => 'invoice.pdf', 'content' => 'JVBERi0xLjQK', 'content_type' => 'application/pdf'], + [ + 'filename' => 'logo.png', + 'content' => 'iVBORw0KGgo=', + 'content_type' => 'image/png', + 'content_id' => 'logo', + ], + ], + ], + [], + ], + '05 PostHog options' => [ + fn() => self::withHeaders(self::shipped(), [ + 'X-PostHog-Distinct-Id' => 'user_42', + 'X-PostHog-Template-Key' => 'order-shipped', + 'X-PostHog-Idempotency-Key' => 'order-981-shipped', + 'X-PostHog-Capture-Engagement' => 'false', + ]), + self::RENDERED + [ + 'template_key' => 'order-shipped', + 'distinct_id' => 'user_42', + 'capture_engagement' => false, + ], + ['Idempotency-Key' => 'order-981-shipped'], + ], + '14 tracking that agrees' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=false']), + self::RENDERED + ['template_key' => 'posthog-php-mailer', 'capture_engagement' => false], + [], + ], + ]; + } + + /** + * @param Closure(): Email $email + * @param array $body + * @param array $headers + */ + #[DataProvider('sends')] + public function testAnEmailSendsOneRequest(Closure $email, array $body, array $headers): void + { + $sent = $this->transport()->send($email()); + + $this->assertCount(1, $this->requests); + $this->assertEquals($body + ['capture_engagement' => true], $this->body()); + $this->assertMatchesRegularExpression( + '#^posthog-php-mailer/[0-9A-Za-z.+-]+$#', + $this->header('X-PostHog-Integration') + ); + foreach ($headers as $name => $value) { + $this->assertSame($value, $this->header($name)); + } + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $sent->getMessageId()); + } + + public function testTheReservedHeadersNeverGoOut(): void + { + $this->transport()->send(self::withHeaders(self::shipped(), [ + 'X-PostHog-Distinct-Id' => 'user_42', + 'X-PostHog-Template-Key' => 'order-shipped', + 'X-PostHog-Idempotency-Key' => 'order-981-shipped', + 'X-PostHog-Capture-Engagement' => 'true', + ])); + + $postHogHeaders = array_values(array_filter( + array_map(static fn(string $line): string => explode(':', $line, 2)[0], $this->requests[0]['headers']), + static fn(string $name): bool => stripos($name, 'X-PostHog-') === 0 + )); + $this->assertSame(['X-PostHog-Integration'], $postHogHeaders); + $this->assertArrayNotHasKey('headers', $this->body()); + } + + public function testCase07TheDefaultTemplateKeyComesFromConfig(): void + { + $this->transport(defaultTemplateKey: 'transactional')->send(self::shipped()); + + $this->assertSame('transactional', $this->body()['template_key']); + } + + public static function refusals(): array + { + return [ + '08 invalid template key' => [ + fn() => self::withHeaders(self::shipped(), ['X-PostHog-Template-Key' => 'order shipped!']), + 'invalid_template_key', + ], + '09 multiple to' => [fn() => self::shipped()->addTo('bo@example.com'), 'multiple_to'], + '09 no to' => [fn() => self::shipped()->to()->cc('bo@example.com'), 'multiple_to'], + '10 inline without cid' => [ + fn() => self::shipped()->addPart((new DataPart(base64_decode('iVBORw0KGgo='), null, 'image/png')) + ->asInline()), + 'inline_without_cid', + ], + '11 a custom header' => [ + fn() => self::withHeaders(self::shipped(), ['X-Campaign' => 'spring']), + 'unsupported_header', + ], + '12 tags' => [ + function () { + $email = self::shipped(); + $email->getHeaders()->add(new TagHeader('receipts')); + return $email; + }, + 'tags', + ], + '12 metadata' => [ + fn() => self::withHeaders(self::shipped(), ['X-Metadata-Order' => '981']), + 'metadata', + ], + '14 mixed tracking' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=false']), + 'mixed_tracking', + ], + 'a priority other than normal' => [ + fn() => self::shipped()->priority(Email::PRIORITY_HIGH), + 'unsupported_priority', + ], + 'two From addresses' => [fn() => self::shipped()->addFrom('ops@example.com'), 'unsupported_header'], + ]; + } + + /** + * @param Closure(): Email $email + */ + #[DataProvider('refusals')] + public function testAnEmailPostHogCannotCarryIsRefusedBeforeAnyRequest(Closure $email, string $code): void + { + $this->assertRefused($code, fn() => $this->transport()->send($email())); + } + + public function testCase13AnEnvelopeThatDiffersFromTheHeadersIsRefused(): void + { + $envelope = new Envelope(new Address('bounce@example.com'), [new Address('ana@example.com')]); + + $this->assertRefused('envelope_mismatch', fn() => $this->transport()->send(self::shipped(), $envelope)); + } + + public function testCase15ARemoteTemplateEmailSendsTheTemplate(): void + { + $this->requireRemoteTemplates(); + $email = (new RemoteTemplateEmail()) + ->from('shop@example.com') + ->to(new Address('ana@example.com', 'Ana')) + ->template(self::TEMPLATE_ID, ['order' => '981']); + $email->getHeaders()->addTextHeader('X-PostHog-Distinct-Id', 'user_42'); + + $this->transport()->send($email); + + $this->assertEquals([ + 'to' => 'ana@example.com', + 'template_id' => self::TEMPLATE_ID, + 'message_data' => ['order' => '981'], + 'distinct_id' => 'user_42', + ], $this->body()); + $this->assertMatchesRegularExpression('#^posthog-php-mailer/#', $this->header('X-PostHog-Integration')); + } + + public function testCase16ATemplateIsRefusedWhenTemplatesAreOff(): void + { + $this->requireRemoteTemplates(); + $email = (new RemoteTemplateEmail())->from('shop@example.com')->to('ana@example.com') + ->template(self::TEMPLATE_ID); + + $this->assertRefused('template_unsupported', fn() => $this->transport(templates: false)->send($email)); + } + + public function testCase17ATemplateWithContentIsRefused(): void + { + $this->requireRemoteTemplates(); + $email = (new RemoteTemplateEmail())->from(new Address('shop@example.com', 'Shop')) + ->to(new Address('ana@example.com', 'Ana'))->subject('Your order shipped') + ->template(self::TEMPLATE_ID); + + $this->assertRefused('template_with_content', fn() => $this->transport()->send($email)); + } + + private function requireRemoteTemplates(): void + { + if (!class_exists(RemoteTemplateEmail::class)) { + $this->markTestSkipped('RemoteTemplateEmail arrives in Symfony Mailer 8.2.'); + } + } + + private function assertRefused(string $code, Closure $send): void + { + try { + $send(); + $this->fail("The email was not refused with $code."); + } catch (LogicException $refusal) { + $this->assertStringStartsWith("PostHog cannot send this email: $code. ", $refusal->getMessage()); + } + $this->assertSame([], $this->requests); + } + + /** + * @param array $headers + */ + private static function withHeaders(Email $email, array $headers): Email + { + foreach ($headers as $name => $value) { + $email->getHeaders()->addTextHeader($name, $value); + } + + return $email; + } +} diff --git a/test/Mailer/PostHogTransportErrorsTest.php b/test/Mailer/PostHogTransportErrorsTest.php new file mode 100644 index 00000000..ebafb7bc --- /dev/null +++ b/test/Mailer/PostHogTransportErrorsTest.php @@ -0,0 +1,139 @@ +previousClock = Clock::get(); + Clock::set(new MockClock('2026-11-05 12:00:00')); + } + + protected function tearDown(): void + { + Clock::set($this->previousClock); + } + + private static function email(): Email + { + return (new Email())->from('shop@example.com')->to('ana@example.com')->subject('Hi')->text('Hi'); + } + + private static function answer(int $status, string $code, array $headers = []): array + { + $error = ['type' => 'x', 'code' => $code, 'detail' => "PostHog said $code.", 'attr' => 'to']; + + return [$status, json_encode($error), $headers]; + } + + public function testAnAcceptedSendSetsPostHogsMessageIdAsTheMessageId(): void + { + $this->reply([202, '{"message_id":"01a18356-c56c-0001-e494-7666bc915188","replayed":false}', []]); + + $sent = $this->transport()->send(self::email()); + + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $sent->getMessageId()); + } + + public static function failures(): array + { + $once = 1; + $sdkRetries = 3; + + return [ + '400 invalid request' => [self::answer(400, 'invalid_input'), 400, 'invalid_input', false, null, $once], + '401 wrong key' => [ + self::answer(401, 'authentication_failed'), 401, 'authentication_failed', false, null, $once, + ], + '404 template not found' => [self::answer(404, 'not_found'), 404, 'not_found', false, null, $once], + '409 key in flight' => [ + self::answer(409, 'idempotency_key_in_flight'), + 409, + 'idempotency_key_in_flight', + true, + null, + $sdkRetries, + ], + '422 suppressed recipient' => [ + self::answer(422, 'recipient_suppressed'), 422, 'recipient_suppressed', false, null, $once, + ], + '429 over quota' => [self::answer(429, 'quota_limited'), 429, 'quota_limited', false, null, $once], + '429 throttled' => [ + self::answer(429, 'throttled', ['Retry-After: 5']), 429, 'throttled', true, 5, $sdkRetries, + ], + '503 unavailable' => [self::answer(503, 'unavailable'), 503, 'unavailable', true, null, $sdkRetries], + 'other 5xx' => [self::answer(500, 'server_error'), 500, 'server_error', false, null, $once], + 'network error' => [ + new EmailError('network_error', 'The request to PostHog failed: timeout'), + null, + 'network_error', + true, + null, + $sdkRetries, + ], + ]; + } + + #[DataProvider('failures')] + public function testAFailedSendIsATransportExceptionThatSaysWhetherToRetry( + array|EmailError $reply, + ?int $status, + string $code, + bool $retryable, + ?int $retryAfter, + int $requests + ): void { + $this->reply($reply, $reply, $reply); + + try { + $this->transport()->send(self::email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertInstanceOf(TransportExceptionInterface::class, $error); + $this->assertSame($status, $error->getStatusCode()); + $this->assertSame($code, $error->getErrorCode()); + $this->assertSame($retryable, $error->isRetryable()); + $this->assertSame($retryAfter, $error->getRetryAfter()); + $this->assertInstanceOf(EmailError::class, $error->getPrevious()); + if ($status !== null) { + $this->assertSame("PostHog said $code.", $error->getMessage()); + $this->assertSame('to', $error->getField()); + } + } + $this->assertCount($requests, $this->requests); + $this->assertSame($this->header('Idempotency-Key'), $this->header('Idempotency-Key', $requests - 1)); + } + + public function testAnEmailTheSenderRefusesFailsWithoutARequest(): void + { + try { + $this->transport()->send((new Email())->from('shop@example.com')->to('ana@example.com')->text('Hi')); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertNull($error->getStatusCode()); + $this->assertSame('invalid_form', $error->getErrorCode()); + $this->assertSame('subject', $error->getField()); + $this->assertFalse($error->isRetryable()); + } + $this->assertSame([], $this->requests); + } +} diff --git a/test/Mailer/PostHogTransportFactoryTest.php b/test/Mailer/PostHogTransportFactoryTest.php new file mode 100644 index 00000000..4ecbdf99 --- /dev/null +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -0,0 +1,79 @@ +fromString($dsn)->send( + (new Email())->from('shop@example.com')->to('ana@example.com')->subject('Hi')->text('Hi') + ); + } + + public static function hosts(): array + { + return [ + 'default is US Cloud' => ['posthog+api://phs_dsn@default', 'https://us.posthog.com'], + 'EU Cloud' => ['posthog+api://phs_dsn@eu.posthog.com', 'https://eu.posthog.com'], + 'a self-hosted PostHog with a port' => [ + 'posthog+api://phs_dsn@posthog.example.com:8443', + 'https://posthog.example.com:8443', + ], + ]; + } + + #[DataProvider('hosts')] + public function testTheDsnSendsWithItsKeyToItsHost(string $dsn, string $appHost): void + { + $this->send($dsn); + + $this->assertSame($appHost . '/api/projects/@current/messaging/send/email/', $this->requests[0]['url']); + $this->assertSame('Bearer phs_dsn', $this->header('Authorization')); + } + + public function testTheDsnSetsTheDefaultTemplateKey(): void + { + $this->send('posthog+api://phs_dsn@default?default_template_key=transactional'); + + $this->assertSame('transactional', $this->body()['template_key']); + } + + public function testAnotherSchemeIsNotPostHogs(): void + { + $factory = new PostHogTransportFactory(); + + $this->assertFalse($factory->supports(Dsn::fromString('posthog+smtp://phs_dsn@default'))); + $this->expectException(UnsupportedSchemeException::class); + $factory->create(Dsn::fromString('posthog+smtp://phs_dsn@default')); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testTheFactoryStepsAsideForSymfonysPostHogBridge(): void + { + $factory = new PostHogTransportFactory(); + $this->assertTrue($factory->supports(Dsn::fromString('posthog+api://phs_dsn@default'))); + + class_alias( + PostHogTransportFactory::class, + 'Symfony\Component\Mailer\Bridge\PostHog\Transport\PostHogTransportFactory' + ); + + $this->assertFalse($factory->supports(Dsn::fromString('posthog+api://phs_dsn@default'))); + } +} diff --git a/test/Mailer/RecordedRequests.php b/test/Mailer/RecordedRequests.php new file mode 100644 index 00000000..48f09d0b --- /dev/null +++ b/test/Mailer/RecordedRequests.php @@ -0,0 +1,23 @@ +, body: ?string}> */ + public static array $requests = []; + + /** + * @param list $headers + * @return array{int, string, list} + */ + public static function send(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array + { + self::$requests[] = compact('method', 'url', 'headers', 'body'); + + return [202, '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}', []]; + } +} diff --git a/test/Mailer/SendsThroughStubbedHttp.php b/test/Mailer/SendsThroughStubbedHttp.php new file mode 100644 index 00000000..27b436d9 --- /dev/null +++ b/test/Mailer/SendsThroughStubbedHttp.php @@ -0,0 +1,69 @@ +, body: ?string}> */ + private array $requests = []; + /** @var list}|EmailError> */ + private array $replies = []; + + private function transport(?string $defaultTemplateKey = null, bool $templates = true): PostHogTransport + { + return new PostHogTransport($this->emails(), defaultTemplateKey: $defaultTemplateKey, templates: $templates); + } + + private function emails(): Emails + { + return new Emails('phs_secret', 'us.i.posthog.com', transport: Closure::fromCallable([$this, 'sendRequest'])); + } + + /** + * @param list $headers + * @return array{int, string, list} + */ + public function sendRequest(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array + { + $this->requests[] = compact('method', 'url', 'headers', 'body'); + $reply = array_shift($this->replies) ?? [202, '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}', []]; + if ($reply instanceof EmailError) { + throw $reply; + } + + return $reply; + } + + /** @param array{int, string, list}|EmailError ...$replies */ + private function reply(...$replies): void + { + $this->replies = $replies; + } + + /** + * @return array + */ + private function body(int $index = 0): array + { + return json_decode($this->requests[$index]['body'], true, 512, JSON_THROW_ON_ERROR); + } + + private function header(string $name, int $index = 0): ?string + { + foreach ($this->requests[$index]['headers'] as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + + return null; + } +} diff --git a/test/Mailer/SymfonyKernelTest.php b/test/Mailer/SymfonyKernelTest.php new file mode 100644 index 00000000..11526ee0 --- /dev/null +++ b/test/Mailer/SymfonyKernelTest.php @@ -0,0 +1,42 @@ +kernel = new MailerKernel('test', false); + $this->kernel->boot(); + } + + protected function tearDown(): void + { + $this->kernel->shutdown(); + (new Filesystem())->remove($this->kernel->getProjectDir() . '/var'); + } + + public function testAnAppWithThePostHogDsnSendsItsEmailsThroughPostHog(): void + { + $mailer = $this->kernel->getContainer()->get('test.mailer'); + + $mailer->send((new Email()) + ->from('shop@example.com') + ->to('ana@example.com') + ->subject('Your order shipped') + ->html('

On its way.

')); + + $this->assertCount(1, RecordedRequests::$requests); + $request = RecordedRequests::$requests[0]; + $this->assertSame('https://us.posthog.com/api/projects/@current/messaging/send/email/', $request['url']); + $this->assertContains('Authorization: Bearer phs_kernel', $request['headers']); + $this->assertSame('ana@example.com', json_decode($request['body'], true)['to']); + } +} From abab0f92ce3afb541336ee6b6e9330c7faac5a8d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:54:46 +0000 Subject: [PATCH 02/30] fix(mailer): match the adapter contract on inline parts, sender names and template options - An inline part without a content ID uses its name only when the HTML refers to it as cid:; 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 89 +++++++++++++------ .../PostHogTransportConformanceTest.php | 83 +++++++++++++++++ 2 files changed, 145 insertions(+), 27 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index ddea5115..85c23c6e 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -42,9 +42,12 @@ public function map(Email $email, Envelope $envelope): array $to = $this->onlyRecipient($email); $options = $this->headerOptions($email); $this->refuseEnvelopeMismatch($email, $envelope); + // Checked on both forms; the template form doesn't send them. + $templateKey = $this->templateKey($options[self::TEMPLATE_KEY] ?? null); + $captureEngagement = self::captureEngagement($email, $options[self::CAPTURE_ENGAGEMENT] ?? null); $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; $send = $template === null - ? $this->renderedContent($email, $options) + ? $this->renderedContent($email, $templateKey, $captureEngagement) : $this->templateContent($email, $template->getReference(), $template->getVariables()); return array_filter( @@ -123,22 +126,26 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void } /** - * @param array $options * @return array */ - private function renderedContent(Email $email, array $options): array + private function renderedContent(Email $email, string $templateKey, ?bool $captureEngagement): array { + $html = self::text($email->getHtmlBody()); + return [ - 'template_key' => $this->templateKey($options[self::TEMPLATE_KEY] ?? null), + 'template_key' => $templateKey, 'subject' => $email->getSubject(), - 'html' => self::text($email->getHtmlBody()), + 'html' => $html, 'text' => self::text($email->getTextBody()), 'from' => ($from = $email->getFrom()[0] ?? null) === null ? null : self::mailbox($from), 'reply_to' => self::addresses($email->getReplyTo()), 'cc' => self::addresses($email->getCc()), 'bcc' => self::addresses($email->getBcc()), - 'attachments' => array_map(self::attachment(...), $email->getAttachments()), - 'capture_engagement' => self::captureEngagement($email, $options[self::CAPTURE_ENGAGEMENT] ?? null), + 'attachments' => array_map( + static fn(DataPart $part): array => self::attachment($part, $html), + $email->getAttachments() + ), + 'capture_engagement' => $captureEngagement, ]; } @@ -179,11 +186,24 @@ private function templateKey(?string $explicit): string return $explicit; } - private static function captureEngagement(Email $email, ?string $explicit): bool|string|null + /** + * The X-PostHog-Capture-Engagement header wins; else Symfony's X-Track, when opens and clicks agree. + */ + private static function captureEngagement(Email $email, ?string $explicit): ?bool { + if ($explicit !== null) { + return match ($explicit) { + 'true' => true, + 'false' => false, + default => throw self::refusal( + 'unsupported_header', + 'X-PostHog-Capture-Engagement must be "true" or "false".' + ), + }; + } $choices = array_values(array_unique(array_filter( - [self::flag($explicit), ...self::tracking($email)], - static fn($choice): bool => $choice !== null + self::tracking($email), + static fn(?bool $choice): bool => $choice !== null ), SORT_REGULAR)); if (count($choices) > 1) { throw self::refusal( @@ -215,34 +235,40 @@ private static function tracking(Email $email): array return array_values($flags); } - private static function flag(?string $value): bool|string|null - { - return match ($value === null ? null : strtolower(trim($value))) { - null => null, - 'true' => true, - 'false' => false, - default => $value, - }; - } - /** * @return array */ - private static function attachment(DataPart $part): array + private static function attachment(DataPart $part, ?string $html): array { $attachment = ['filename' => $part->getFilename(), 'content' => $part->getBody()]; $attachment['content_type'] = $part->getContentType(); if ($part->getDisposition() === 'inline') { - $contentId = $part->hasContentId() ? $part->getContentId() : $part->getName(); - if ($contentId === null) { - throw self::refusal('inline_without_cid', 'An inline attachment needs a name the HTML refers to.'); - } - $attachment['content_id'] = $contentId; + $attachment['content_id'] = self::contentId($part, $html); } return $attachment; } + /** + * An inline part's explicit content ID, else its name when the HTML refers to it as `cid:`. + */ + private static function contentId(DataPart $part, ?string $html): string + { + if ($part->hasContentId()) { + return $part->getContentId(); + } + $name = $part->getName(); + $reference = '/\\b(?i:cid:)' . preg_quote((string) $name, '/') . '(?=[\\s"\'>)]|$)/'; + if ($name === null || $html === null || preg_match($reference, $html) !== 1) { + throw self::refusal( + 'inline_without_cid', + 'An inline attachment needs a content ID, or a name the HTML refers to as cid:.' + ); + } + + return $name; + } + /** * @param Address[] $addresses * @return list @@ -252,9 +278,18 @@ private static function addresses(array $addresses): array return array_values(array_map(static fn(Address $address): string => $address->getAddress(), $addresses)); } + /** + * The sender with its display name, quoted when the name has a character RFC 5322 reserves, such as a comma. + */ private static function mailbox(Address $address): string { - return $address->getName() === '' ? $address->getAddress() : "{$address->getName()} <{$address->getAddress()}>"; + $name = $address->getName(); + if ($name === '') { + return $address->getAddress(); + } + + return (strpbrk($name, '()<>[]:;@\\,."') === false ? $name : $address->getEncodedName()) + . " <{$address->getAddress()}>"; } /** diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index a65d2791..65b2737d 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -103,6 +103,34 @@ public static function sends(): array ], ['Idempotency-Key' => 'order-981-shipped'], ], + 'a sender name with a comma, quoted' => [ + fn() => self::shipped()->from(new Address('shop@example.com', 'Shop, Inc')), + ['from' => '"Shop, Inc" '] + self::RENDERED + + ['template_key' => 'posthog-php-mailer'], + [], + ], + 'an inline part named in the HTML' => [ + fn() => self::shipped()->html('') + ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'), + ['html' => ''] + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [[ + 'filename' => 'logo', + 'content' => 'iVBORw0KGgo=', + 'content_type' => 'image/png', + 'content_id' => 'logo', + ]], + ], + [], + ], + 'the engagement header wins over X-Track' => [ + fn() => self::withHeaders(self::shipped(), [ + 'X-PostHog-Capture-Engagement' => 'false', + 'X-Track' => 'opens=true; clicks=true', + ]), + self::RENDERED + ['template_key' => 'posthog-php-mailer', 'capture_engagement' => false], + [], + ], '14 tracking that agrees' => [ fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=false']), self::RENDERED + ['template_key' => 'posthog-php-mailer', 'capture_engagement' => false], @@ -167,10 +195,19 @@ public static function refusals(): array '09 multiple to' => [fn() => self::shipped()->addTo('bo@example.com'), 'multiple_to'], '09 no to' => [fn() => self::shipped()->to()->cc('bo@example.com'), 'multiple_to'], '10 inline without cid' => [ + fn() => self::shipped()->addPart((new DataPart(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png')) + ->asInline()), + 'inline_without_cid', + ], + '10 inline without a name' => [ fn() => self::shipped()->addPart((new DataPart(base64_decode('iVBORw0KGgo='), null, 'image/png')) ->asInline()), 'inline_without_cid', ], + 'an engagement header that is not true or false' => [ + fn() => self::withHeaders(self::shipped(), ['X-PostHog-Capture-Engagement' => 'yes']), + 'unsupported_header', + ], '11 a custom header' => [ fn() => self::withHeaders(self::shipped(), ['X-Campaign' => 'spring']), 'unsupported_header', @@ -235,6 +272,52 @@ public function testCase15ARemoteTemplateEmailSendsTheTemplate(): void $this->assertMatchesRegularExpression('#^posthog-php-mailer/#', $this->header('X-PostHog-Integration')); } + public function testATemplateEmailChecksButDoesNotSendTheRenderedOptions(): void + { + $this->requireRemoteTemplates(); + $email = self::withHeaders( + (new RemoteTemplateEmail())->from('shop@example.com')->to('ana@example.com')->template(self::TEMPLATE_ID), + ['X-PostHog-Template-Key' => 'order-shipped', 'X-PostHog-Capture-Engagement' => 'false'] + ); + + $this->transport()->send($email); + + $this->assertEquals( + ['to' => 'ana@example.com', 'template_id' => self::TEMPLATE_ID, 'message_data' => []], + $this->body() + ); + } + + /** + * @return array, string}> + */ + public static function templateRefusals(): array + { + return [ + 'an invalid template key' => [['X-PostHog-Template-Key' => 'order shipped!'], 'invalid_template_key'], + 'an engagement header that is not true or false' => [ + ['X-PostHog-Capture-Engagement' => 'yes'], + 'unsupported_header', + ], + 'mixed tracking' => [['X-Track' => 'opens=true; clicks=false'], 'mixed_tracking'], + ]; + } + + /** + * @param array $headers + */ + #[DataProvider('templateRefusals')] + public function testATemplateEmailStillRefusesABadOption(array $headers, string $code): void + { + $this->requireRemoteTemplates(); + $email = self::withHeaders( + (new RemoteTemplateEmail())->from('shop@example.com')->to('ana@example.com')->template(self::TEMPLATE_ID), + $headers + ); + + $this->assertRefused($code, fn() => $this->transport()->send($email)); + } + public function testCase16ATemplateIsRefusedWhenTemplatesAreOff(): void { $this->requireRemoteTemplates(); From 5bb178b47f5f81925e7aa9d51472df59bd6b7da0 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:02:27 +0000 Subject: [PATCH 03/30] fix(mailer): send inline parts under the reference the HTML uses - A part's content ID is the reference the HTML uses: cid: first, then cid:. Symfony gives a referenced part a generated content ID when it renders the body, but the HTML sent still says cid:, 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 62 ++++++++++------ .../PostHogTransportConformanceTest.php | 71 +++++++++++++++++++ 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 85c23c6e..0ac6ce4b 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -7,6 +7,7 @@ use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Header\UnstructuredHeader; use Symfony\Component\Mime\Part\DataPart; /** @@ -76,7 +77,10 @@ private function headerOptions(Email $email): array foreach ($email->getHeaders()->all() as $header) { $name = strtolower($header->getName()); if (in_array($name, self::OPTION_HEADERS, true)) { - $options[$name] = $header->getBodyAsString(); + // The raw value: getBodyAsString() MIME-encodes a value that isn't ASCII, such as a distinct ID. + $options[$name] = $header instanceof UnstructuredHeader + ? $header->getValue() + : $header->getBodyAsString(); continue; } self::refuseHeader($name); @@ -187,20 +191,10 @@ private function templateKey(?string $explicit): string } /** - * The X-PostHog-Capture-Engagement header wins; else Symfony's X-Track, when opens and clicks agree. + * Symfony's X-Track must set opens and clicks alike. The X-PostHog-Capture-Engagement header wins over it. */ private static function captureEngagement(Email $email, ?string $explicit): ?bool { - if ($explicit !== null) { - return match ($explicit) { - 'true' => true, - 'false' => false, - default => throw self::refusal( - 'unsupported_header', - 'X-PostHog-Capture-Engagement must be "true" or "false".' - ), - }; - } $choices = array_values(array_unique(array_filter( self::tracking($email), static fn(?bool $choice): bool => $choice !== null @@ -211,8 +205,18 @@ private static function captureEngagement(Email $email, ?string $explicit): ?boo 'It captures opens and clicks together; set them to the same value.' ); } + if ($explicit === null) { + return $choices[0] ?? null; + } - return $choices[0] ?? null; + return match ($explicit) { + 'true' => true, + 'false' => false, + default => throw self::refusal( + 'unsupported_header', + 'X-PostHog-Capture-Engagement must be "true" or "false".' + ), + }; } /** @@ -242,31 +246,45 @@ private static function attachment(DataPart $part, ?string $html): array { $attachment = ['filename' => $part->getFilename(), 'content' => $part->getBody()]; $attachment['content_type'] = $part->getContentType(); - if ($part->getDisposition() === 'inline') { - $attachment['content_id'] = self::contentId($part, $html); + $contentId = self::contentId($part, $html); + if ($contentId !== null) { + $attachment['content_id'] = $contentId; } return $attachment; } /** - * An inline part's explicit content ID, else its name when the HTML refers to it as `cid:`. + * The content ID the HTML refers to, `cid:` before `cid:`, so a part sends inline under the + * reference the HTML uses. Symfony gives a referenced part a generated content ID when it renders the body, but + * the HTML sent still says `cid:`. An inline part the HTML doesn't refer to sends its own content ID. */ - private static function contentId(DataPart $part, ?string $html): string + private static function contentId(DataPart $part, ?string $html): ?string { - if ($part->hasContentId()) { + $name = $part->getName(); + if ($name !== null && self::refersTo($html, $name)) { + return $name; + } + if ($part->hasContentId() && self::refersTo($html, $part->getContentId())) { return $part->getContentId(); } - $name = $part->getName(); - $reference = '/\\b(?i:cid:)' . preg_quote((string) $name, '/') . '(?=[\\s"\'>)]|$)/'; - if ($name === null || $html === null || preg_match($reference, $html) !== 1) { + if ($part->getDisposition() !== 'inline') { + return null; + } + if (!$part->hasContentId()) { throw self::refusal( 'inline_without_cid', 'An inline attachment needs a content ID, or a name the HTML refers to as cid:.' ); } - return $name; + return $part->getContentId(); + } + + private static function refersTo(?string $html, string $contentId): bool + { + return $html !== null + && preg_match('/\\b(?i:cid:)' . preg_quote($contentId, '/') . '(?=[\\s"\'>)]|$)/', $html) === 1; } /** diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 65b2737d..c2161730 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -123,6 +123,37 @@ public static function sends(): array ], [], ], + 'an attached part the HTML refers to sends inline' => [ + fn() => self::shipped()->html('') + ->attach(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png'), + ['html' => ''] + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [self::logo('logo.png', 'logo.png')], + ], + [], + ], + 'an inline part the HTML refers to by its content ID' => [ + fn() => self::shipped()->html('')->addPart( + (new DataPart(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png')) + ->asInline()->setContentId('logo@shop.example') + ), + ['html' => ''] + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [self::logo('logo.png', 'logo@shop.example')], + ], + [], + ], + 'an inline part with its own content ID the HTML does not refer to' => [ + fn() => self::shipped()->addPart( + (new DataPart(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png')) + ->asInline()->setContentId('logo@shop.example') + ), + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [self::logo('logo.png', 'logo@shop.example')], + ], + [], + ], 'the engagement header wins over X-Track' => [ fn() => self::withHeaders(self::shipped(), [ 'X-PostHog-Capture-Engagement' => 'false', @@ -161,6 +192,26 @@ public function testAnEmailSendsOneRequest(Closure $email, array $body, array $h $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $sent->getMessageId()); } + public function testAPartSymfonyGaveAContentIdSendsUnderTheNameTheHtmlRefersTo(): void + { + $email = self::shipped()->html('') + ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'); + $email->toString(); + $this->assertTrue($email->getAttachments()[0]->hasContentId(), 'Rendering gave the part a content ID.'); + + $this->transport()->send($email); + + $this->assertSame('', $this->body()['html']); + $this->assertSame('logo', $this->body()['attachments'][0]['content_id']); + } + + public function testADistinctIdThatIsNotAsciiReachesPostHogUnchanged(): void + { + $this->transport()->send(self::withHeaders(self::shipped(), ['X-PostHog-Distinct-Id' => '用户42'])); + + $this->assertSame('用户42', $this->body()['distinct_id']); + } + public function testTheReservedHeadersNeverGoOut(): void { $this->transport()->send(self::withHeaders(self::shipped(), [ @@ -224,6 +275,13 @@ function () { fn() => self::withHeaders(self::shipped(), ['X-Metadata-Order' => '981']), 'metadata', ], + '14 mixed tracking, even with the engagement header' => [ + fn() => self::withHeaders(self::shipped(), [ + 'X-PostHog-Capture-Engagement' => 'true', + 'X-Track' => 'opens=true; clicks=false', + ]), + 'mixed_tracking', + ], '14 mixed tracking' => [ fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=false']), 'mixed_tracking', @@ -355,6 +413,19 @@ private function assertRefused(string $code, Closure $send): void $this->assertSame([], $this->requests); } + /** + * @return array + */ + private static function logo(string $filename, string $contentId): array + { + return [ + 'filename' => $filename, + 'content' => 'iVBORw0KGgo=', + 'content_type' => 'image/png', + 'content_id' => $contentId, + ]; + } + /** * @param array $headers */ From d631f1c7336cd4d2b9fae51ca186e5b699f63f22 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:07:31 +0000 Subject: [PATCH 04/30] fix(mailer): refuse a body with parts the send can't carry 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 34 +++++++++++++++ .../PostHogTransportConformanceTest.php | 42 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 0ac6ce4b..62d28803 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -8,7 +8,9 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Header\UnstructuredHeader; +use Symfony\Component\Mime\Part\AbstractMultipartPart; use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\TextPart; /** * @internal Turns a Symfony Email into the options of one PostHog send, or refuses it. @@ -134,6 +136,7 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void */ private function renderedContent(Email $email, string $templateKey, ?bool $captureEngagement): array { + self::refuseUnsupportedParts($email); $html = self::text($email->getHtmlBody()); return [ @@ -153,6 +156,37 @@ private function renderedContent(Email $email, string $templateKey, ?bool $captu ]; } + /** + * The send carries the email's text, HTML and attachments, so a body set with setBody() may hold only those: at + * most one text and one HTML part, each only when the email has that body, and the email's own attachments. + * A body Symfony generates holds nothing else. + */ + private static function refuseUnsupportedParts(Email $email): void + { + $expected = ['plain' => $email->getTextBody() !== null, 'html' => $email->getHtmlBody() !== null]; + $parts = [$email->getBody()]; + while (($part = array_shift($parts)) !== null) { + if ($part instanceof AbstractMultipartPart) { + array_push($parts, ...$part->getParts()); + } elseif ($part instanceof DataPart) { + if (!in_array($part, $email->getAttachments(), true)) { + throw self::refusal( + 'unsupported_part', + "The body holds a {$part->getMediaType()}/{$part->getMediaSubtype()} part that is not " + . 'one of its attachments.' + ); + } + } elseif ($part instanceof TextPart && ($expected[$part->getMediaSubtype()] ?? false)) { + $expected[$part->getMediaSubtype()] = false; + } else { + throw self::refusal( + 'unsupported_part', + "It can't carry the {$part->getMediaType()}/{$part->getMediaSubtype()} part of the body." + ); + } + } + } + /** * @param array $variables * @return array diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index c2161730..4202baac 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -13,6 +13,9 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\AlternativePart; +use Symfony\Component\Mime\Part\Multipart\MixedPart; +use Symfony\Component\Mime\Part\TextPart; /** * The adapter contract's conformance cases, sent through the real Emails sender with HTTP stubbed. @@ -212,6 +215,19 @@ public function testADistinctIdThatIsNotAsciiReachesPostHogUnchanged(): void $this->assertSame('用户42', $this->body()['distinct_id']); } + public function testAMultipartEmailWithAnAttachmentAndAnInlineImageSends(): void + { + $email = self::shipped()->html('

On its way.

') + ->attach(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf') + ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'); + + $this->transport()->send($email); + + $this->assertSame(['invoice.pdf', 'logo'], array_column($this->body()['attachments'], 'filename')); + $this->assertSame('logo', $this->body()['attachments'][1]['content_id']); + $this->assertArrayNotHasKey('content_id', $this->body()['attachments'][0]); + } + public function testTheReservedHeadersNeverGoOut(): void { $this->transport()->send(self::withHeaders(self::shipped(), [ @@ -286,6 +302,32 @@ function () { fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=false']), 'mixed_tracking', ], + 'an AMP alternative set as the body' => [ + fn() => self::shipped()->setBody(new AlternativePart( + new TextPart('On its way.'), + new TextPart('

On its way.

', 'utf-8', 'html'), + new TextPart('

On its way.

', 'utf-8', 'x-amp-html') + )), + 'unsupported_part', + ], + 'a body part that is not an attachment' => [ + fn() => self::shipped()->setBody(new MixedPart( + new TextPart('On its way.'), + new DataPart(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf') + )), + 'unsupported_part', + ], + 'a second text part' => [ + fn() => self::shipped()->setBody(new MixedPart(new TextPart('On its way.'), new TextPart('Again.'))), + 'unsupported_part', + ], + 'an HTML part when the email has no HTML body' => [ + fn() => self::shipped()->html(null)->setBody(new AlternativePart( + new TextPart('On its way.'), + new TextPart('

On its way.

', 'utf-8', 'html') + )), + 'unsupported_part', + ], 'a priority other than normal' => [ fn() => self::shipped()->priority(Email::PRIORITY_HIGH), 'unsupported_priority', From b5974e0f7dcdc6f42a9f7d191d4a34a68123544a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:13:52 +0000 Subject: [PATCH 05/30] fix(mailer): match whole cid references and read stream bodies once 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 33 +++++++++++-- .../PostHogTransportConformanceTest.php | 48 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 62d28803..01c1e1f0 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -136,14 +136,24 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void */ private function renderedContent(Email $email, string $templateKey, ?bool $captureEngagement): array { - self::refuseUnsupportedParts($email); + // A stream body reads once: Symfony reads the HTML again when it builds the body the walk below checks, which + // leaves a stream that can't rewind empty. Read each body once and walk a clone that holds them as strings. $html = self::text($email->getHtmlBody()); + $text = self::text($email->getTextBody()); + $email = clone $email; + if ($html !== null) { + $email->html($html, $email->getHtmlCharset() ?? 'utf-8'); + } + if ($text !== null) { + $email->text($text, $email->getTextCharset() ?? 'utf-8'); + } + self::refuseUnsupportedParts($email); return [ 'template_key' => $templateKey, 'subject' => $email->getSubject(), 'html' => $html, - 'text' => self::text($email->getTextBody()), + 'text' => $text, 'from' => ($from = $email->getFrom()[0] ?? null) === null ? null : self::mailbox($from), 'reply_to' => self::addresses($email->getReplyTo()), 'cc' => self::addresses($email->getCc()), @@ -315,10 +325,25 @@ private static function contentId(DataPart $part, ?string $html): ?string return $part->getContentId(); } + /** + * Whether the HTML holds a complete `cid:` reference to this content ID. A quoted + * reference runs to its closing quote, so `"cid:logo'v2@shop.example"` refers to + * `logo'v2@shop.example` and not to `logo`. An unquoted one, such as `url(cid:logo)`, + * ends at whitespace, a quote, `>` or `)`. + */ private static function refersTo(?string $html, string $contentId): bool { - return $html !== null - && preg_match('/\\b(?i:cid:)' . preg_quote($contentId, '/') . '(?=[\\s"\'>)]|$)/', $html) === 1; + if ($html === null) { + return false; + } + preg_match_all('/(["\'])cid:(.*?)\\1|(?)]+)/i', $html, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + if (($match[3] ?? $match[2]) === $contentId) { + return true; + } + } + + return false; } /** diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 4202baac..65decea4 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -146,6 +146,26 @@ public static function sends(): array ], [], ], + 'a name that only starts the content ID the HTML refers to' => [ + fn() => self::shipped()->html('')->addPart( + (new DataPart(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png')) + ->asInline()->setName('logo')->setContentId('logo\'v2@shop.example') + ), + ['html' => ''] + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [self::logo('logo.png', 'logo\'v2@shop.example')], + ], + [], + ], + 'a part the HTML refers to from CSS' => [ + fn() => self::shipped()->html('
') + ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'), + ['html' => '
'] + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [self::logo('logo', 'logo')], + ], + [], + ], 'an inline part with its own content ID the HTML does not refer to' => [ fn() => self::shipped()->addPart( (new DataPart(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png')) @@ -228,6 +248,34 @@ public function testAMultipartEmailWithAnAttachmentAndAnInlineImageSends(): void $this->assertArrayNotHasKey('content_id', $this->body()['attachments'][0]); } + public function testABodyFromAStreamThatCannotRewindSendsInFull(): void + { + $html = self::streamThatCannotRewind('

On its way.

'); + $text = self::streamThatCannotRewind('On its way.'); + $email = self::shipped()->html($html)->text($text) + ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'); + + $this->transport()->send($email); + + $this->assertSame('

On its way.

', $this->body()['html']); + $this->assertSame('On its way.', $this->body()['text']); + $this->assertSame('logo', $this->body()['attachments'][0]['content_id']); + $this->assertSame($html, $email->getHtmlBody(), 'The app\'s own email keeps its body.'); + } + + /** + * @return resource + */ + private static function streamThatCannotRewind(string $content) + { + [$read, $write] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + fwrite($write, $content); + fclose($write); + self::assertFalse(stream_get_meta_data($read)['seekable']); + + return $read; + } + public function testTheReservedHeadersNeverGoOut(): void { $this->transport()->send(self::withHeaders(self::shipped(), [ From 072de815ac3229a9d1e54cce1e636bda71432648 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:23:49 +0000 Subject: [PATCH 06/30] fix(mailer): check only a custom body, and its text and HTML 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/php.yml | 10 ++++- lib/Internal/Mailer/EmailMapper.php | 42 ++++++++++--------- .../PostHogTransportConformanceTest.php | 25 +++++++++++ 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7a5b648d..d4889dfa 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -127,6 +127,12 @@ jobs: - php-version: "8.2" symfony: "6.4.*" stability: stable + # The first 6.4 release: before 6.4.37, Symfony renames inline parts when it builds the body. + # Composer blocks it for security advisories, which don't apply to a test run. + - php-version: "8.2" + symfony: "6.4.0" + stability: stable + allow-insecure: true # Symfony Mailer 8.2 adds remote templates; move to stable once 8.2.0 ships. - php-version: "8.4" symfony: "8.2.*" @@ -146,10 +152,12 @@ jobs: env: SYMFONY: ${{ matrix.symfony }} STABILITY: ${{ matrix.stability }} + ALLOW_INSECURE: ${{ matrix.allow-insecure || 'false' }} run: | composer config minimum-stability "$STABILITY" + if [ "$ALLOW_INSECURE" = "true" ]; then composer config policy.advisories.block false; fi composer config prefer-stable true - composer require --dev --no-update "symfony/mailer:$SYMFONY" "symfony/framework-bundle:$SYMFONY" + composer require --dev --no-update "symfony/mailer:$SYMFONY" "symfony/mime:$SYMFONY" "symfony/framework-bundle:$SYMFONY" composer update --with-all-dependencies --prefer-dist --no-progress - name: Run the Symfony Mailer tests diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 01c1e1f0..d5c5bef6 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -2,12 +2,14 @@ namespace PostHog\Internal\Mailer; +use ReflectionMethod; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Header\UnstructuredHeader; +use Symfony\Component\Mime\Message; use Symfony\Component\Mime\Part\AbstractMultipartPart; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\TextPart; @@ -136,18 +138,10 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void */ private function renderedContent(Email $email, string $templateKey, ?bool $captureEngagement): array { - // A stream body reads once: Symfony reads the HTML again when it builds the body the walk below checks, which - // leaves a stream that can't rewind empty. Read each body once and walk a clone that holds them as strings. + // Read each body once: a stream that can't rewind is empty on a second read. $html = self::text($email->getHtmlBody()); $text = self::text($email->getTextBody()); - $email = clone $email; - if ($html !== null) { - $email->html($html, $email->getHtmlCharset() ?? 'utf-8'); - } - if ($text !== null) { - $email->text($text, $email->getTextCharset() ?? 'utf-8'); - } - self::refuseUnsupportedParts($email); + self::refuseUnsupportedParts($email, $text, $html); return [ 'template_key' => $templateKey, @@ -168,30 +162,38 @@ private function renderedContent(Email $email, string $templateKey, ?bool $captu /** * The send carries the email's text, HTML and attachments, so a body set with setBody() may hold only those: at - * most one text and one HTML part, each only when the email has that body, and the email's own attachments. - * A body Symfony generates holds nothing else. + * most one text and one HTML part, each holding the email's own text or HTML, and the email's own attachments. + * + * A body Symfony generates holds nothing else, so only a custom body is walked. Message::getBody() returns it + * without building the generated body. Building it reads a stream body, and older Symfony Mime releases, such + * as 6.4.0, rename each part the HTML refers to after its generated content ID. */ - private static function refuseUnsupportedParts(Email $email): void + private static function refuseUnsupportedParts(Email $email, ?string $text, ?string $html): void { - $expected = ['plain' => $email->getTextBody() !== null, 'html' => $email->getHtmlBody() !== null]; - $parts = [$email->getBody()]; + $custom = (new ReflectionMethod(Message::class, 'getBody'))->invoke($email); + $expected = ['plain' => $text, 'html' => $html]; + $parts = $custom === null ? [] : [$custom]; while (($part = array_shift($parts)) !== null) { + $type = "{$part->getMediaType()}/{$part->getMediaSubtype()}"; if ($part instanceof AbstractMultipartPart) { array_push($parts, ...$part->getParts()); } elseif ($part instanceof DataPart) { if (!in_array($part, $email->getAttachments(), true)) { throw self::refusal( 'unsupported_part', - "The body holds a {$part->getMediaType()}/{$part->getMediaSubtype()} part that is not " - . 'one of its attachments.' + "The body holds a $type part that is not one of its attachments." ); } - } elseif ($part instanceof TextPart && ($expected[$part->getMediaSubtype()] ?? false)) { - $expected[$part->getMediaSubtype()] = false; + } elseif ( + $part instanceof TextPart + && ($expected[$part->getMediaSubtype()] ?? null) !== null + && $part->getBody() === $expected[$part->getMediaSubtype()] + ) { + $expected[$part->getMediaSubtype()] = null; } else { throw self::refusal( 'unsupported_part', - "It can't carry the {$part->getMediaType()}/{$part->getMediaSubtype()} part of the body." + "It can't carry the $type part of the body: a body part must hold the email's own text or HTML." ); } } diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 65decea4..fb613ae1 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -221,6 +221,9 @@ public function testAPartSymfonyGaveAContentIdSendsUnderTheNameTheHtmlRefersTo() ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'); $email->toString(); $this->assertTrue($email->getAttachments()[0]->hasContentId(), 'Rendering gave the part a content ID.'); + if ($email->getAttachments()[0]->getName() !== 'logo') { + $this->markTestSkipped('Symfony Mime before 6.4.37 and 7.4.9 renames the part when the app renders the email.'); + } $this->transport()->send($email); @@ -248,6 +251,21 @@ public function testAMultipartEmailWithAnAttachmentAndAnInlineImageSends(): void $this->assertArrayNotHasKey('content_id', $this->body()['attachments'][0]); } + public function testACustomBodyThatHoldsTheEmailsOwnPartsSends(): void + { + $email = self::shipped()->attach(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf'); + $email->setBody(new MixedPart( + new AlternativePart(new TextPart('On its way.'), new TextPart('

On its way.

', 'utf-8', 'html')), + $email->getAttachments()[0] + )); + + $this->transport()->send($email); + + $this->assertSame('

On its way.

', $this->body()['html']); + $this->assertSame('On its way.', $this->body()['text']); + $this->assertSame(['invoice.pdf'], array_column($this->body()['attachments'], 'filename')); + } + public function testABodyFromAStreamThatCannotRewindSendsInFull(): void { $html = self::streamThatCannotRewind('

On its way.

'); @@ -369,6 +387,13 @@ function () { fn() => self::shipped()->setBody(new MixedPart(new TextPart('On its way.'), new TextPart('Again.'))), 'unsupported_part', ], + 'a text part that differs from the email\'s text' => [ + fn() => self::shipped()->setBody(new AlternativePart( + new TextPart('Something else.'), + new TextPart('

On its way.

', 'utf-8', 'html') + )), + 'unsupported_part', + ], 'an HTML part when the email has no HTML body' => [ fn() => self::shipped()->html(null)->setBody(new AlternativePart( new TextPart('On its way.'), From 8416825e75b6773fb2496881a617b5bc00b9164a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:35:43 +0000 Subject: [PATCH 07/30] fix(mailer): stop mapping X-Track onto capture_engagement 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 --- .changeset/symfony-mailer-transport.md | 2 +- lib/Internal/Mailer/EmailMapper.php | 48 ++++++++----------- lib/Mailer/PostHogTransport.php | 8 +++- .../PostHogTransportConformanceTest.php | 24 +++++----- 4 files changed, 38 insertions(+), 44 deletions(-) diff --git a/.changeset/symfony-mailer-transport.md b/.changeset/symfony-mailer-transport.md index 9ffd8d13..47efe15f 100644 --- a/.changeset/symfony-mailer-transport.md +++ b/.changeset/symfony-mailer-transport.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Send Symfony Mailer email through PostHog: set `MAILER_DSN=posthog+api://@default` and register `PostHog\Mailer\PostHogTransportFactory`. Rendered emails, attachments, inline images, and the PostHog options (distinct ID, template key, idempotency key, engagement capture) map to a transactional send, and an email PostHog cannot carry is refused before any request. On Symfony Mailer 8.2, a `RemoteTemplateEmail` sends a PostHog template. `EmailError::isRetryable()` is now public. +Send Symfony Mailer email through PostHog: set `MAILER_DSN=posthog+api://@default` and register `PostHog\Mailer\PostHogTransportFactory`. Rendered emails, attachments, inline images, and the PostHog options (distinct ID, template key, idempotency key, engagement capture) map to a transactional send, and an email PostHog cannot carry is refused before any request. PostHog adds an open pixel to every email, so an email whose `X-Track` header sets `opens=false` is refused, and any other `X-Track` is dropped. On Symfony Mailer 8.2, a `RemoteTemplateEmail` sends a PostHog template. `EmailError::isRetryable()` is now public. diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index d5c5bef6..3fef774a 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -47,9 +47,10 @@ public function map(Email $email, Envelope $envelope): array $to = $this->onlyRecipient($email); $options = $this->headerOptions($email); $this->refuseEnvelopeMismatch($email, $envelope); + self::refuseOpenTrackingOff($email); // Checked on both forms; the template form doesn't send them. $templateKey = $this->templateKey($options[self::TEMPLATE_KEY] ?? null); - $captureEngagement = self::captureEngagement($email, $options[self::CAPTURE_ENGAGEMENT] ?? null); + $captureEngagement = self::captureEngagement($options[self::CAPTURE_ENGAGEMENT] ?? null); $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; $send = $template === null ? $this->renderedContent($email, $templateKey, $captureEngagement) @@ -236,26 +237,10 @@ private function templateKey(?string $explicit): string return $explicit; } - /** - * Symfony's X-Track must set opens and clicks alike. The X-PostHog-Capture-Engagement header wins over it. - */ - private static function captureEngagement(Email $email, ?string $explicit): ?bool + private static function captureEngagement(?string $explicit): ?bool { - $choices = array_values(array_unique(array_filter( - self::tracking($email), - static fn(?bool $choice): bool => $choice !== null - ), SORT_REGULAR)); - if (count($choices) > 1) { - throw self::refusal( - 'mixed_tracking', - 'It captures opens and clicks together; set them to the same value.' - ); - } - if ($explicit === null) { - return $choices[0] ?? null; - } - return match ($explicit) { + null => null, 'true' => true, 'false' => false, default => throw self::refusal( @@ -265,24 +250,29 @@ private static function captureEngagement(Email $email, ?string $explicit): ?boo }; } - /** - * @return list - */ - private static function tracking(Email $email): array + private static function refuseOpenTrackingOff(Email $email): void + { + if (self::asksForNoOpenTracking($email)) { + throw self::refusal( + 'open_tracking_off', + 'It adds an open pixel to every email; remove opens=false from X-Track.' + ); + } + } + + private static function asksForNoOpenTracking(Email $email): bool { $header = $email->getHeaders()->get(self::TRACKING_HEADER); if ($header === null) { - return []; + return false; } - $flags = ['opens' => null, 'clicks' => null]; foreach (explode(';', $header->getBodyAsString()) as $part) { - [$name, $value] = array_map('trim', explode('=', $part, 2)) + [1 => '']; - if (array_key_exists($name, $flags)) { - $flags[$name] = ['true' => true, 'false' => false][$value] ?? null; + if (array_map('trim', explode('=', $part, 2)) === ['opens', 'false']) { + return true; } } - return array_values($flags); + return false; } /** diff --git a/lib/Mailer/PostHogTransport.php b/lib/Mailer/PostHogTransport.php index 76eac9a7..b4fc35f8 100644 --- a/lib/Mailer/PostHogTransport.php +++ b/lib/Mailer/PostHogTransport.php @@ -23,8 +23,12 @@ * PostHogTransportException that says whether sending again with the same idempotency key is safe. * * The reserved headers `X-PostHog-Distinct-Id`, `X-PostHog-Template-Key`, `X-PostHog-Idempotency-Key` and - * `X-PostHog-Capture-Engagement` set the send's options and never go out. On Symfony Mailer 8.2 and later, - * a RemoteTemplateEmail sends a template made in PostHog by its ID. + * `X-PostHog-Capture-Engagement` set the send's options and never go out. `X-PostHog-Capture-Engagement: false` + * only keeps the email's engagement events off the recipient's person. It doesn't change the email: PostHog + * still adds its open pixel to count opens. It never rewrites the email's links, so it counts no clicks. + * Symfony's `X-Track` header is dropped, and an email whose `X-Track` sets `opens=false` is refused with + * `open_tracking_off`. On Symfony Mailer 8.2 and later, a RemoteTemplateEmail sends a template made in + * PostHog by its ID. */ final class PostHogTransport extends AbstractTransport implements RemoteTemplateSupport { diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index fb613ae1..0ebab658 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -177,7 +177,7 @@ public static function sends(): array ], [], ], - 'the engagement header wins over X-Track' => [ + '14 X-Track leaves engagement capture to its own header' => [ fn() => self::withHeaders(self::shipped(), [ 'X-PostHog-Capture-Engagement' => 'false', 'X-Track' => 'opens=true; clicks=true', @@ -185,9 +185,9 @@ public static function sends(): array self::RENDERED + ['template_key' => 'posthog-php-mailer', 'capture_engagement' => false], [], ], - '14 tracking that agrees' => [ - fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=false']), - self::RENDERED + ['template_key' => 'posthog-php-mailer', 'capture_engagement' => false], + '14 X-Track with clicks off, which PostHog never tracks' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=false']), + self::RENDERED + ['template_key' => 'posthog-php-mailer'], [], ], ]; @@ -357,16 +357,16 @@ function () { fn() => self::withHeaders(self::shipped(), ['X-Metadata-Order' => '981']), 'metadata', ], - '14 mixed tracking, even with the engagement header' => [ + '14 open tracking off, even with engagement capture off' => [ fn() => self::withHeaders(self::shipped(), [ - 'X-PostHog-Capture-Engagement' => 'true', - 'X-Track' => 'opens=true; clicks=false', + 'X-PostHog-Capture-Engagement' => 'false', + 'X-Track' => 'opens=false; clicks=false', ]), - 'mixed_tracking', + 'open_tracking_off', ], - '14 mixed tracking' => [ - fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=false']), - 'mixed_tracking', + '14 open tracking off' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=true']), + 'open_tracking_off', ], 'an AMP alternative set as the body' => [ fn() => self::shipped()->setBody(new AlternativePart( @@ -472,7 +472,7 @@ public static function templateRefusals(): array ['X-PostHog-Capture-Engagement' => 'yes'], 'unsupported_header', ], - 'mixed tracking' => [['X-Track' => 'opens=true; clicks=false'], 'mixed_tracking'], + 'open tracking off' => [['X-Track' => 'opens=false'], 'open_tracking_off'], ]; } From 2ee36ae3a0ec828ce08014b33b004deccf607e7f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:45:42 +0000 Subject: [PATCH 08/30] fix(mailer): refuse opens=false in any X-Track header, not just the first Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 12 +++++------- test/Mailer/PostHogTransportConformanceTest.php | 7 +++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 3fef774a..5774b2bb 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -262,13 +262,11 @@ private static function refuseOpenTrackingOff(Email $email): void private static function asksForNoOpenTracking(Email $email): bool { - $header = $email->getHeaders()->get(self::TRACKING_HEADER); - if ($header === null) { - return false; - } - foreach (explode(';', $header->getBodyAsString()) as $part) { - if (array_map('trim', explode('=', $part, 2)) === ['opens', 'false']) { - return true; + foreach ($email->getHeaders()->all(self::TRACKING_HEADER) as $header) { + foreach (explode(';', $header->getBodyAsString()) as $part) { + if (array_map('trim', explode('=', $part, 2)) === ['opens', 'false']) { + return true; + } } } diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 0ebab658..e0730e00 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -368,6 +368,13 @@ function () { fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=true']), 'open_tracking_off', ], + '14 open tracking off in a second X-Track' => [ + fn() => self::withHeaders( + self::withHeaders(self::shipped(), ['X-Track' => 'opens=true']), + ['X-Track' => 'opens=false'] + ), + 'open_tracking_off', + ], 'an AMP alternative set as the body' => [ fn() => self::shipped()->setBody(new AlternativePart( new TextPart('On its way.'), From 617f4bd450cd9a08e9793e86beb950d00a65a1f8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:46:35 +0000 Subject: [PATCH 09/30] fix(mailer): read X-Track raw and refuse any opens value that isn't on 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 --- lib/Internal/Mailer/EmailMapper.php | 7 +++++-- lib/Mailer/PostHogTransport.php | 13 +++++++------ test/Mailer/PostHogTransportConformanceTest.php | 12 ++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 5774b2bb..c6917535 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -29,6 +29,7 @@ final class EmailMapper private const MAPPED_HEADERS = ['from', 'to', 'cc', 'bcc', 'reply-to', 'subject', 'sender', 'return-path']; private const IGNORED_HEADERS = ['message-id', 'date', 'mime-version', 'x-mailer', 'x-priority']; private const TRACKING_HEADER = 'x-track'; + private const TRACKING_ON = ['true', '1', 'yes', 'on']; private const NORMAL_PRIORITY = 3; private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; @@ -263,8 +264,10 @@ private static function refuseOpenTrackingOff(Email $email): void private static function asksForNoOpenTracking(Email $email): bool { foreach ($email->getHeaders()->all(self::TRACKING_HEADER) as $header) { - foreach (explode(';', $header->getBodyAsString()) as $part) { - if (array_map('trim', explode('=', $part, 2)) === ['opens', 'false']) { + $value = $header instanceof UnstructuredHeader ? $header->getValue() : $header->getBodyAsString(); + foreach (explode(';', strtolower((string) preg_replace('/\s+/', ' ', $value))) as $part) { + [$name, $flag] = array_map('trim', explode('=', $part, 2)) + [1 => '']; + if ($name === 'opens' && !in_array($flag, self::TRACKING_ON, true)) { return true; } } diff --git a/lib/Mailer/PostHogTransport.php b/lib/Mailer/PostHogTransport.php index b4fc35f8..48f00f61 100644 --- a/lib/Mailer/PostHogTransport.php +++ b/lib/Mailer/PostHogTransport.php @@ -23,12 +23,13 @@ * PostHogTransportException that says whether sending again with the same idempotency key is safe. * * The reserved headers `X-PostHog-Distinct-Id`, `X-PostHog-Template-Key`, `X-PostHog-Idempotency-Key` and - * `X-PostHog-Capture-Engagement` set the send's options and never go out. `X-PostHog-Capture-Engagement: false` - * only keeps the email's engagement events off the recipient's person. It doesn't change the email: PostHog - * still adds its open pixel to count opens. It never rewrites the email's links, so it counts no clicks. - * Symfony's `X-Track` header is dropped, and an email whose `X-Track` sets `opens=false` is refused with - * `open_tracking_off`. On Symfony Mailer 8.2 and later, a RemoteTemplateEmail sends a template made in - * PostHog by its ID. + * `X-PostHog-Capture-Engagement` set the send's options and never go out. On a rendered email, + * `X-PostHog-Capture-Engagement: false` only keeps the email's engagement events off the recipient's person. + * It doesn't change the email: PostHog still adds its open pixel to count opens. It never rewrites the + * email's links, so it counts no clicks. Symfony's `X-Track` header is dropped, and an email whose `X-Track` + * turns opens off, such as `opens=false`, is refused with `open_tracking_off`. On Symfony Mailer 8.2 and + * later, a RemoteTemplateEmail sends a template made in PostHog by its ID; the template follows the + * project's engagement capture setting. */ final class PostHogTransport extends AbstractTransport implements RemoteTemplateSupport { diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index e0730e00..911e6255 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -368,6 +368,18 @@ function () { fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=true']), 'open_tracking_off', ], + '14 open tracking off in capitals' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'Opens=FALSE']), + 'open_tracking_off', + ], + '14 open tracking off as 0' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=0']), + 'open_tracking_off', + ], + '14 open tracking off next to a value that is not ASCII' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'note=é; opens=false']), + 'open_tracking_off', + ], '14 open tracking off in a second X-Track' => [ fn() => self::withHeaders( self::withHeaders(self::shipped(), ['X-Track' => 'opens=true']), From 319217b1a5aae3e6945329977ae5c66735f4a2bb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:02:38 +0000 Subject: [PATCH 10/30] style(mailer): wrap the skip message phpcs flags as too long Co-Authored-By: Claude Opus 5.5 --- test/Mailer/PostHogTransportConformanceTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 911e6255..bdad913b 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -222,7 +222,9 @@ public function testAPartSymfonyGaveAContentIdSendsUnderTheNameTheHtmlRefersTo() $email->toString(); $this->assertTrue($email->getAttachments()[0]->hasContentId(), 'Rendering gave the part a content ID.'); if ($email->getAttachments()[0]->getName() !== 'logo') { - $this->markTestSkipped('Symfony Mime before 6.4.37 and 7.4.9 renames the part when the app renders the email.'); + $this->markTestSkipped( + 'Symfony Mime before 6.4.37 and 7.4.9 renames the part when the app renders the email.' + ); } $this->transport()->send($email); From 3c0e3643a5197a0cfe2d082972327fc1c1fb20c6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:58:54 +0000 Subject: [PATCH 11/30] test(mailer): send through a fake PostHog that takes files by hash 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 --- test/Mailer/FakePostHog.php | 190 ++++++++++++++++++ .../PostHogTransportConformanceTest.php | 62 +++--- test/Mailer/PostHogTransportErrorsTest.php | 4 +- test/Mailer/PostHogTransportFactoryTest.php | 5 +- test/Mailer/SendsThroughStubbedHttp.php | 53 +++-- 5 files changed, 259 insertions(+), 55 deletions(-) create mode 100644 test/Mailer/FakePostHog.php diff --git a/test/Mailer/FakePostHog.php b/test/Mailer/FakePostHog.php new file mode 100644 index 00000000..d98e22f0 --- /dev/null +++ b/test/Mailer/FakePostHog.php @@ -0,0 +1,190 @@ +, body: ?string}> */ + public array $requests = []; + /** @var list}|EmailError> the send route's next answers */ + public array $sendReplies = []; + /** @var array the files storage holds, by SHA-256 */ + public array $stored = []; + /** @var list> the emails PostHog queued, in order */ + public array $queued = []; + /** @var array> the headers storage signed for each upload URL */ + private array $signed = []; + /** @var array the idempotency keys of the queued emails */ + private array $keys = []; + + public function __construct(private readonly string $secretKey = self::SECRET_KEY) + { + } + + /** + * @param list $headers + * @return array{int, string, list} + */ + public function __invoke( + string $method, + string $url, + array $headers, + ?string $body, + int $timeoutMs, + int $connectTimeoutMs = 0 + ): array { + $this->requests[] = compact('method', 'url', 'headers', 'body'); + if ($method === 'PUT') { + return $this->store($url, $headers, (string) $body); + } + if (self::header($headers, 'Authorization') !== 'Bearer ' . $this->secretKey) { + return self::error(401, 'authentication_failed', null); + } + if (str_ends_with($url, '/messaging/attachments/uploads/')) { + return $this->negotiate((string) $body); + } + + return $this->send($headers, (string) $body); + } + + /** + * The sends that reached the send route, accepted or not, as their decoded bodies. + * + * @return list> + */ + public function sends(): array + { + $sends = array_filter( + $this->requests, + static fn(array $request): bool => str_ends_with($request['url'], '/messaging/send/email/') + ); + + return array_values(array_map( + static fn(array $request): array => json_decode((string) $request['body'], true, 512, JSON_THROW_ON_ERROR), + $sends + )); + } + + /** + * The value of a header on the nth request to the send route. + */ + public function sendHeader(string $name, int $index = 0): ?string + { + $sends = array_values(array_filter( + $this->requests, + static fn(array $request): bool => str_ends_with($request['url'], '/messaging/send/email/') + )); + + return isset($sends[$index]) ? self::header($sends[$index]['headers'], $name) : null; + } + + /** @return array{int, string, list} */ + private function negotiate(string $body): array + { + $slots = []; + foreach (json_decode($body, true)['files'] ?? [] as $file) { + $sha256 = $file['sha256']; + if (isset($this->stored[$sha256])) { + $slots[] = ['sha256' => $sha256, 'status' => 'present', 'expires_at' => '2026-12-12T12:00:00Z']; + continue; + } + $url = self::STORAGE . $sha256 . self::SIGNATURE; + $this->signed[$url] = [ + 'Content-Length' => (string) $file['size'], + 'x-amz-checksum-sha256' => base64_encode((string) hex2bin($sha256)), + ]; + $slots[] = [ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => $url, + 'method' => 'PUT', + 'headers' => $this->signed[$url], + 'url_expires_at' => '2026-11-13T12:15:00Z', + ]; + } + + return [200, json_encode(['files' => $slots], JSON_THROW_ON_ERROR), []]; + } + + /** + * @param list $headers + * @return array{int, string, list} + */ + private function store(string $url, array $headers, string $body): array + { + if (!isset($this->signed[$url])) { + return [403, 'AccessDenied', []]; + } + foreach ($this->signed[$url] as $name => $value) { + if (self::header($headers, $name) !== $value) { + return [403, 'SignatureDoesNotMatch', []]; + } + } + if (base64_encode(hash('sha256', $body, true)) !== $this->signed[$url]['x-amz-checksum-sha256']) { + return [400, 'BadDigest', []]; + } + $this->stored[hash('sha256', $body)] = $body; + + return [200, '', []]; + } + + /** + * @param list $headers + * @return array{int, string, list} + */ + private function send(array $headers, string $body): array + { + $email = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + foreach ($email['attachments'] ?? [] as $index => $attachment) { + if (!isset($this->stored[$attachment['sha256'] ?? ''])) { + return self::error(400, 'attachment_not_uploaded', "attachments__{$index}__sha256"); + } + } + $reply = array_shift($this->sendReplies) + ?? [202, json_encode(['message_id' => self::MESSAGE_ID, 'replayed' => false]), []]; + if ($reply instanceof EmailError) { + throw $reply; + } + $key = self::header($headers, 'Idempotency-Key') ?? ''; + if ($reply[0] >= 200 && $reply[0] < 300 && !isset($this->keys[$key])) { + $this->keys[$key] = true; + $this->queued[] = $email; + } + + return $reply; + } + + /** @return array{int, string, list} */ + private static function error(int $status, string $code, ?string $attr): array + { + $error = ['type' => 'validation_error', 'code' => $code, 'detail' => "PostHog said $code.", 'attr' => $attr]; + + return [$status, json_encode($error, JSON_THROW_ON_ERROR), []]; + } + + /** @param list $headers */ + private static function header(array $headers, string $name): ?string + { + foreach ($headers as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + + return null; + } +} diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index bdad913b..580ea717 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -26,6 +26,11 @@ final class PostHogTransportConformanceTest extends TestCase use SendsThroughStubbedHttp; private const TEMPLATE_ID = '0190f0c2-7d1e-7a3b-9c2e-5f1a2b3c4d5e'; + /** The files these cases attach, by SHA-256. */ + private const BYTES = [ + 'e5c62df5dab5c87b6a015ef3d43597074d1eec433b15f51aec63b8582d0e4ab4' => "%PDF-1.4\n", + '4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6' => "\x89PNG\r\n\x1a\n", + ]; private const RENDERED = [ 'to' => 'ana@example.com', 'from' => 'Shop ', @@ -81,13 +86,8 @@ public static function sends(): array ['html' => ''] + self::RENDERED + [ 'template_key' => 'posthog-php-mailer', 'attachments' => [ - ['filename' => 'invoice.pdf', 'content' => 'JVBERi0xLjQK', 'content_type' => 'application/pdf'], - [ - 'filename' => 'logo.png', - 'content' => 'iVBORw0KGgo=', - 'content_type' => 'image/png', - 'content_id' => 'logo', - ], + self::file('invoice.pdf', 'application/pdf', 'JVBERi0xLjQK'), + self::logo('logo.png', 'logo'), ], ], [], @@ -117,12 +117,7 @@ public static function sends(): array ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'), ['html' => ''] + self::RENDERED + [ 'template_key' => 'posthog-php-mailer', - 'attachments' => [[ - 'filename' => 'logo', - 'content' => 'iVBORw0KGgo=', - 'content_type' => 'image/png', - 'content_id' => 'logo', - ]], + 'attachments' => [self::logo('logo', 'logo')], ], [], ], @@ -203,8 +198,16 @@ public function testAnEmailSendsOneRequest(Closure $email, array $body, array $h { $sent = $this->transport()->send($email()); - $this->assertCount(1, $this->requests); - $this->assertEquals($body + ['capture_engagement' => true], $this->body()); + $this->assertCount(1, $this->posthog()->sends()); + $this->assertEquals($body, $this->body()); + $this->assertEquals([$body], $this->posthog()->queued); + foreach ($body['attachments'] ?? [] as $attachment) { + $this->assertSame( + self::BYTES[$attachment['sha256']], + $this->posthog()->stored[$attachment['sha256']] ?? null, + "Storage holds {$attachment['filename']}." + ); + } $this->assertMatchesRegularExpression( '#^posthog-php-mailer/[0-9A-Za-z.+-]+$#', $this->header('X-PostHog-Integration') @@ -306,7 +309,7 @@ public function testTheReservedHeadersNeverGoOut(): void ])); $postHogHeaders = array_values(array_filter( - array_map(static fn(string $line): string => explode(':', $line, 2)[0], $this->requests[0]['headers']), + array_map(static fn(string $line): string => explode(':', $line, 2)[0], $this->requests()[0]['headers']), static fn(string $name): bool => stripos($name, 'X-PostHog-') === 0 )); $this->assertSame(['X-PostHog-Integration'], $postHogHeaders); @@ -546,7 +549,25 @@ private function assertRefused(string $code, Closure $send): void } catch (LogicException $refusal) { $this->assertStringStartsWith("PostHog cannot send this email: $code. ", $refusal->getMessage()); } - $this->assertSame([], $this->requests); + $this->assertSame([], $this->requests()); + } + + /** + * The reference to a file that the send carries, by its SHA-256, in place of its bytes. + * + * @return array + */ + private static function file( + string $filename, + string $contentType, + string $base64, + ?string $contentId = null + ): array { + $sha256 = hash('sha256', base64_decode($base64)); + self::assertArrayHasKey($sha256, self::BYTES); + + return ['sha256' => $sha256, 'filename' => $filename, 'content_type' => $contentType] + + ($contentId === null ? [] : ['content_id' => $contentId]); } /** @@ -554,12 +575,7 @@ private function assertRefused(string $code, Closure $send): void */ private static function logo(string $filename, string $contentId): array { - return [ - 'filename' => $filename, - 'content' => 'iVBORw0KGgo=', - 'content_type' => 'image/png', - 'content_id' => $contentId, - ]; + return self::file($filename, 'image/png', 'iVBORw0KGgo=', $contentId); } /** diff --git a/test/Mailer/PostHogTransportErrorsTest.php b/test/Mailer/PostHogTransportErrorsTest.php index ebafb7bc..3e124065 100644 --- a/test/Mailer/PostHogTransportErrorsTest.php +++ b/test/Mailer/PostHogTransportErrorsTest.php @@ -119,7 +119,7 @@ public function testAFailedSendIsATransportExceptionThatSaysWhetherToRetry( $this->assertSame('to', $error->getField()); } } - $this->assertCount($requests, $this->requests); + $this->assertCount($requests, $this->requests()); $this->assertSame($this->header('Idempotency-Key'), $this->header('Idempotency-Key', $requests - 1)); } @@ -134,6 +134,6 @@ public function testAnEmailTheSenderRefusesFailsWithoutARequest(): void $this->assertSame('subject', $error->getField()); $this->assertFalse($error->isRetryable()); } - $this->assertSame([], $this->requests); + $this->assertSame([], $this->requests()); } } diff --git a/test/Mailer/PostHogTransportFactoryTest.php b/test/Mailer/PostHogTransportFactoryTest.php index 4ecbdf99..c9b426a1 100644 --- a/test/Mailer/PostHogTransportFactoryTest.php +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -19,7 +19,8 @@ final class PostHogTransportFactoryTest extends TestCase private function send(string $dsn): void { - $factory = new PostHogTransportFactory(sendRequest: Closure::fromCallable([$this, 'sendRequest'])); + $this->posthog = new FakePostHog('phs_dsn'); + $factory = new PostHogTransportFactory(sendRequest: Closure::fromCallable($this->posthog)); (new Transport([$factory]))->fromString($dsn)->send( (new Email())->from('shop@example.com')->to('ana@example.com')->subject('Hi')->text('Hi') ); @@ -42,7 +43,7 @@ public function testTheDsnSendsWithItsKeyToItsHost(string $dsn, string $appHost) { $this->send($dsn); - $this->assertSame($appHost . '/api/projects/@current/messaging/send/email/', $this->requests[0]['url']); + $this->assertSame($appHost . '/api/projects/@current/messaging/send/email/', $this->requests()[0]['url']); $this->assertSame('Bearer phs_dsn', $this->header('Authorization')); } diff --git a/test/Mailer/SendsThroughStubbedHttp.php b/test/Mailer/SendsThroughStubbedHttp.php index 27b436d9..07db71f5 100644 --- a/test/Mailer/SendsThroughStubbedHttp.php +++ b/test/Mailer/SendsThroughStubbedHttp.php @@ -8,14 +8,11 @@ use PostHog\Mailer\PostHogTransport; /** - * Builds PostHogTransport on the real Emails sender with only its HTTP requests replaced. + * Builds PostHogTransport on the real Emails sender, with FakePostHog in place of the network. */ trait SendsThroughStubbedHttp { - /** @var list, body: ?string}> */ - private array $requests = []; - /** @var list}|EmailError> */ - private array $replies = []; + private ?FakePostHog $posthog = null; private function transport(?string $defaultTemplateKey = null, bool $templates = true): PostHogTransport { @@ -24,46 +21,46 @@ private function transport(?string $defaultTemplateKey = null, bool $templates = private function emails(): Emails { - return new Emails('phs_secret', 'us.i.posthog.com', transport: Closure::fromCallable([$this, 'sendRequest'])); + return new Emails( + FakePostHog::SECRET_KEY, + 'us.i.posthog.com', + transport: Closure::fromCallable($this->posthog()) + ); } - /** - * @param list $headers - * @return array{int, string, list} - */ - public function sendRequest(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array + private function posthog(): FakePostHog { - $this->requests[] = compact('method', 'url', 'headers', 'body'); - $reply = array_shift($this->replies) ?? [202, '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}', []]; - if ($reply instanceof EmailError) { - throw $reply; - } - - return $reply; + return $this->posthog ??= new FakePostHog(); } - /** @param array{int, string, list}|EmailError ...$replies */ + /** @param array{int, string, list}|EmailError ...$replies The send route's next answers. */ private function reply(...$replies): void { - $this->replies = $replies; + $this->posthog()->sendReplies = $replies; } /** + * Every request the transport made, to any route or to storage. + * + * @return list, body: ?string}> + */ + private function requests(): array + { + return $this->posthog()->requests; + } + + /** + * The nth request to the send route, decoded. + * * @return array */ private function body(int $index = 0): array { - return json_decode($this->requests[$index]['body'], true, 512, JSON_THROW_ON_ERROR); + return $this->posthog()->sends()[$index]; } private function header(string $name, int $index = 0): ?string { - foreach ($this->requests[$index]['headers'] as $header) { - if (stripos($header, $name . ':') === 0) { - return trim(substr($header, strlen($name) + 1)); - } - } - - return null; + return $this->posthog()->sendHeader($name, $index); } } From e3a7e062499525aeae4cb3556b423932131cbe96 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:02:53 +0000 Subject: [PATCH 12/30] feat(mailer): map X-Track to tracking and pass sensitive through 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 --- .changeset/symfony-mailer-transport.md | 2 +- lib/Internal/Mailer/EmailMapper.php | 124 ++++++++---- lib/Internal/Mailer/NoRemoteTemplates.php | 10 - lib/Mailer/PostHogTransport.php | 24 +-- test/Mailer/FakePostHog.php | 5 +- .../PostHogTransportConformanceTest.php | 188 +++++++++++++++--- 6 files changed, 258 insertions(+), 95 deletions(-) delete mode 100644 lib/Internal/Mailer/NoRemoteTemplates.php diff --git a/.changeset/symfony-mailer-transport.md b/.changeset/symfony-mailer-transport.md index 47efe15f..f5d7600c 100644 --- a/.changeset/symfony-mailer-transport.md +++ b/.changeset/symfony-mailer-transport.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Send Symfony Mailer email through PostHog: set `MAILER_DSN=posthog+api://@default` and register `PostHog\Mailer\PostHogTransportFactory`. Rendered emails, attachments, inline images, and the PostHog options (distinct ID, template key, idempotency key, engagement capture) map to a transactional send, and an email PostHog cannot carry is refused before any request. PostHog adds an open pixel to every email, so an email whose `X-Track` header sets `opens=false` is refused, and any other `X-Track` is dropped. On Symfony Mailer 8.2, a `RemoteTemplateEmail` sends a PostHog template. `EmailError::isRetryable()` is now public. +Send Symfony Mailer email through PostHog: set `MAILER_DSN=posthog+api://@default` and register `PostHog\Mailer\PostHogTransportFactory`. Rendered emails, attachments, inline images, and the PostHog options (distinct ID, template key, idempotency key, engagement capture, sensitive) map to a transactional send, and an email PostHog cannot carry is refused before any request. Symfony's `X-Track` header sets which opens and clicks PostHog tracks. On Symfony Mailer 8.2, a `RemoteTemplateEmail` sends a PostHog template. `EmailError::isRetryable()` is now public. diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index c6917535..be8da29a 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -3,6 +3,7 @@ namespace PostHog\Internal\Mailer; use ReflectionMethod; +use ValueError; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mailer\RemoteTemplateEmail; @@ -23,15 +24,16 @@ final class EmailMapper private const TEMPLATE_KEY = 'x-posthog-template-key'; private const IDEMPOTENCY_KEY = 'x-posthog-idempotency-key'; private const CAPTURE_ENGAGEMENT = 'x-posthog-capture-engagement'; + private const SENSITIVE = 'x-posthog-sensitive'; private const OPTION_HEADERS = [ - self::DISTINCT_ID, self::TEMPLATE_KEY, self::IDEMPOTENCY_KEY, self::CAPTURE_ENGAGEMENT, + self::DISTINCT_ID, self::TEMPLATE_KEY, self::IDEMPOTENCY_KEY, self::CAPTURE_ENGAGEMENT, self::SENSITIVE, ]; private const MAPPED_HEADERS = ['from', 'to', 'cc', 'bcc', 'reply-to', 'subject', 'sender', 'return-path']; private const IGNORED_HEADERS = ['message-id', 'date', 'mime-version', 'x-mailer', 'x-priority']; private const TRACKING_HEADER = 'x-track'; - private const TRACKING_ON = ['true', '1', 'yes', 'on']; + private const TRACKING_FLAGS = ['true' => true, 'false' => false, 'default' => null]; private const NORMAL_PRIORITY = 3; - private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; + public const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,256}$/D'; public function __construct( private readonly string $integration, @@ -48,13 +50,13 @@ public function map(Email $email, Envelope $envelope): array $to = $this->onlyRecipient($email); $options = $this->headerOptions($email); $this->refuseEnvelopeMismatch($email, $envelope); - self::refuseOpenTrackingOff($email); // Checked on both forms; the template form doesn't send them. $templateKey = $this->templateKey($options[self::TEMPLATE_KEY] ?? null); - $captureEngagement = self::captureEngagement($options[self::CAPTURE_ENGAGEMENT] ?? null); + $tracking = self::tracking($email, $options[self::CAPTURE_ENGAGEMENT] ?? null); + $sensitive = self::flag('X-PostHog-Sensitive', $options[self::SENSITIVE] ?? null); $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; $send = $template === null - ? $this->renderedContent($email, $templateKey, $captureEngagement) + ? $this->renderedContent($email, $templateKey) + ['tracking' => $tracking, 'sensitive' => $sensitive] : $this->templateContent($email, $template->getReference(), $template->getVariables()); return array_filter( @@ -106,7 +108,6 @@ private static function refuseHeader(string $name): void if ( in_array($name, self::MAPPED_HEADERS, true) || in_array($name, self::IGNORED_HEADERS, true) - || str_starts_with($name, 'content-') || $name === self::TRACKING_HEADER ) { return; @@ -138,12 +139,14 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void /** * @return array */ - private function renderedContent(Email $email, string $templateKey, ?bool $captureEngagement): array + private function renderedContent(Email $email, string $templateKey): array { // Read each body once: a stream that can't rewind is empty on a second read. $html = self::text($email->getHtmlBody()); $text = self::text($email->getTextBody()); self::refuseUnsupportedParts($email, $text, $html); + $html = self::utf8($html, $email->getHtmlCharset(), 'HTML'); + $text = self::utf8($text, $email->getTextCharset(), 'text'); return [ 'template_key' => $templateKey, @@ -158,13 +161,12 @@ private function renderedContent(Email $email, string $templateKey, ?bool $captu static fn(DataPart $part): array => self::attachment($part, $html), $email->getAttachments() ), - 'capture_engagement' => $captureEngagement, ]; } /** - * The send carries the email's text, HTML and attachments, so a body set with setBody() may hold only those: at - * most one text and one HTML part, each holding the email's own text or HTML, and the email's own attachments. + * The send carries the email's text, HTML and attachments, so a body set with setBody() must hold exactly those: + * one text and one HTML part for the email's own text and HTML, and each of the email's own attachments. * * A body Symfony generates holds nothing else, so only a custom body is walked. Message::getBody() returns it * without building the generated body. Building it reads a stream body, and older Symfony Mime releases, such @@ -174,18 +176,24 @@ private static function refuseUnsupportedParts(Email $email, ?string $text, ?str { $custom = (new ReflectionMethod(Message::class, 'getBody'))->invoke($email); $expected = ['plain' => $text, 'html' => $html]; - $parts = $custom === null ? [] : [$custom]; + if ($custom === null) { + return; + } + $attachments = $email->getAttachments(); + $parts = [$custom]; while (($part = array_shift($parts)) !== null) { $type = "{$part->getMediaType()}/{$part->getMediaSubtype()}"; if ($part instanceof AbstractMultipartPart) { array_push($parts, ...$part->getParts()); } elseif ($part instanceof DataPart) { - if (!in_array($part, $email->getAttachments(), true)) { + $index = array_search($part, $attachments, true); + if ($index === false) { throw self::refusal( 'unsupported_part', "The body holds a $type part that is not one of its attachments." ); } + unset($attachments[$index]); } elseif ( $part instanceof TextPart && ($expected[$part->getMediaSubtype()] ?? null) !== null @@ -199,6 +207,14 @@ private static function refuseUnsupportedParts(Email $email, ?string $text, ?str ); } } + $missing = array_keys(array_filter($expected, static fn(?string $content): bool => $content !== null)); + if ($missing !== [] || $attachments !== []) { + throw self::refusal( + 'unsupported_part', + 'The body leaves out ' . ($missing === [] ? 'an attachment' : "the email's {$missing[0]} part") + . ' that the email carries, so the email sent would differ from the one Symfony builds.' + ); + } } /** @@ -231,49 +247,79 @@ private function templateKey(?string $explicit): string if (preg_match(self::TEMPLATE_KEY_PATTERN, $explicit) !== 1) { throw self::refusal( 'invalid_template_key', - 'X-PostHog-Template-Key can only have letters, digits, hyphens (-) and underscores (_), at most 400.' + 'X-PostHog-Template-Key can only have letters, digits, hyphens (-) and underscores (_), at most 256.' ); } return $explicit; } - private static function captureEngagement(?string $explicit): ?bool + /** + * What PostHog tracks for the email: `X-Track` sets opens and clicks as Symfony's TrackingHeader does, and + * `X-PostHog-Capture-Engagement` sets the engagement events. A flag left out, or `default`, is left to PostHog. + * + * @return array{opens?: bool, clicks?: bool, engagement?: bool}|null + */ + private static function tracking(Email $email, ?string $engagement): ?array { - return match ($explicit) { + $headers = $email->getHeaders()->all(self::TRACKING_HEADER); + $headers = $headers instanceof \Traversable ? iterator_to_array($headers, false) : $headers; + if (count($headers) > 1) { + throw self::refusal('unsupported_header', 'It takes one X-Track header; remove the others.'); + } + $flags = ['opens' => null, 'clicks' => null]; + $header = $headers[0] ?? null; + $value = $header instanceof UnstructuredHeader ? $header->getValue() : (string) $header?->getBodyAsString(); + foreach (explode(';', $value) as $part) { + if (trim($part) === '') { + continue; + } + $pair = array_map('trim', explode('=', $part, 2)); + if (count($pair) !== 2 || !array_key_exists($pair[0], $flags)) { + throw self::refusal( + 'unsupported_header', + 'X-Track can only set opens and clicks, such as "opens=true; clicks=false".' + ); + } + if (!array_key_exists($pair[1], self::TRACKING_FLAGS)) { + throw self::refusal('unsupported_header', "X-Track's $pair[0] must be true, false or default."); + } + $flags[$pair[0]] = self::TRACKING_FLAGS[$pair[1]]; + } + $flags['engagement'] = self::flag('X-PostHog-Capture-Engagement', $engagement); + $tracking = array_filter($flags, static fn(?bool $flag): bool => $flag !== null); + + return $tracking === [] ? null : $tracking; + } + + private static function flag(string $header, ?string $value): ?bool + { + return match ($value) { null => null, 'true' => true, 'false' => false, - default => throw self::refusal( - 'unsupported_header', - 'X-PostHog-Capture-Engagement must be "true" or "false".' - ), + default => throw self::refusal('unsupported_header', "$header must be \"true\" or \"false\"."), }; } - private static function refuseOpenTrackingOff(Email $email): void + /** + * The body in UTF-8, the only charset PostHog takes, converted from the charset the email declares for it. + */ + private static function utf8(?string $body, ?string $charset, string $part): ?string { - if (self::asksForNoOpenTracking($email)) { - throw self::refusal( - 'open_tracking_off', - 'It adds an open pixel to every email; remove opens=false from X-Track.' - ); + if ($body === null || $charset === null || strcasecmp($charset, 'utf-8') === 0) { + return $body; } - } - - private static function asksForNoOpenTracking(Email $email): bool - { - foreach ($email->getHeaders()->all(self::TRACKING_HEADER) as $header) { - $value = $header instanceof UnstructuredHeader ? $header->getValue() : $header->getBodyAsString(); - foreach (explode(';', strtolower((string) preg_replace('/\s+/', ' ', $value))) as $part) { - [$name, $flag] = array_map('trim', explode('=', $part, 2)) + [1 => '']; - if ($name === 'opens' && !in_array($flag, self::TRACKING_ON, true)) { - return true; - } - } + try { + $converted = mb_convert_encoding($body, 'UTF-8', $charset); + } catch (ValueError) { + $converted = false; + } + if (!is_string($converted)) { + throw self::refusal('unsupported_part', "It can't convert the $part part from $charset to UTF-8."); } - return false; + return $converted; } /** diff --git a/lib/Internal/Mailer/NoRemoteTemplates.php b/lib/Internal/Mailer/NoRemoteTemplates.php deleted file mode 100644 index 85fa1dbc..00000000 --- a/lib/Internal/Mailer/NoRemoteTemplates.php +++ /dev/null @@ -1,10 +0,0 @@ -` and sends nothing. A failed send throws a * PostHogTransportException that says whether sending again with the same idempotency key is safe. * - * The reserved headers `X-PostHog-Distinct-Id`, `X-PostHog-Template-Key`, `X-PostHog-Idempotency-Key` and - * `X-PostHog-Capture-Engagement` set the send's options and never go out. On a rendered email, - * `X-PostHog-Capture-Engagement: false` only keeps the email's engagement events off the recipient's person. - * It doesn't change the email: PostHog still adds its open pixel to count opens. It never rewrites the - * email's links, so it counts no clicks. Symfony's `X-Track` header is dropped, and an email whose `X-Track` - * turns opens off, such as `opens=false`, is refused with `open_tracking_off`. On Symfony Mailer 8.2 and - * later, a RemoteTemplateEmail sends a template made in PostHog by its ID; the template follows the - * project's engagement capture setting. + * The reserved headers `X-PostHog-Distinct-Id`, `X-PostHog-Template-Key` (at most 256 characters), + * `X-PostHog-Idempotency-Key`, `X-PostHog-Capture-Engagement` and `X-PostHog-Sensitive` set the send's options + * and never go out. Symfony's `X-Track` header, such as `opens=true; clicks=false`, sets which opens and clicks + * PostHog tracks, and `X-PostHog-Capture-Engagement: false` turns its engagement events off; a flag left out or + * `default` keeps PostHog's default. `X-PostHog-Sensitive: true` marks an email that carries login links, reset + * tokens or codes. Set `X-PostHog-Idempotency-Key` from a stable ID, such as the order ID and the email's name, + * when Messenger retries the send. On Symfony Mailer 8.2 and later, a RemoteTemplateEmail sends a template made + * in PostHog by its ID; the template sets its own tracking and sensitive setting. */ final class PostHogTransport extends AbstractTransport implements RemoteTemplateSupport { @@ -42,8 +42,8 @@ final class PostHogTransport extends AbstractTransport implements RemoteTemplate * @param string|null $integration The integration that sends, `/`. A framework package * that registers this transport passes its own, such as `laravel/1.4.0`. Defaults to * `posthog-php-mailer/`. - * @param string|null $defaultTemplateKey The template key of an email without `X-PostHog-Template-Key`. - * Defaults to the integration's name. + * @param string|null $defaultTemplateKey The template key of an email without `X-PostHog-Template-Key`, at + * most 256 characters. Defaults to the integration's name. * @param bool $templates Whether a RemoteTemplateEmail may send a template made in PostHog. */ public function __construct( @@ -54,9 +54,9 @@ public function __construct( ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null, ) { - if ($defaultTemplateKey !== null && preg_match('/^[a-zA-Z0-9_-]{1,400}$/D', $defaultTemplateKey) !== 1) { + if ($defaultTemplateKey !== null && preg_match(EmailMapper::TEMPLATE_KEY_PATTERN, $defaultTemplateKey) !== 1) { throw new InvalidArgumentException( - 'The default template key can only have letters, digits, hyphens (-) and underscores (_), at most 400.' + 'The default template key can only have letters, digits, hyphens (-) and underscores (_), at most 256.' ); } parent::__construct($dispatcher, $logger); diff --git a/test/Mailer/FakePostHog.php b/test/Mailer/FakePostHog.php index d98e22f0..4b17141f 100644 --- a/test/Mailer/FakePostHog.php +++ b/test/Mailer/FakePostHog.php @@ -9,7 +9,7 @@ * * The upload route answers `present` for a file storage holds and a signed PUT for any other. Storage takes a PUT * only as signed and only when the bytes match the signed checksum. The send route refuses an attachment storage - * doesn't hold, as PostHog does, and queues one email per idempotency key. + * doesn't hold and click tracking without open tracking, as PostHog does, and queues one email per idempotency key. */ final class FakePostHog { @@ -149,6 +149,9 @@ private function store(string $url, array $headers, string $body): array private function send(array $headers, string $body): array { $email = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if (($email['tracking']['clicks'] ?? null) === true && ($email['tracking']['opens'] ?? null) === false) { + return self::error(400, 'clicks_need_opens', 'tracking'); + } foreach ($email['attachments'] ?? [] as $index => $attachment) { if (!isset($this->stored[$attachment['sha256'] ?? ''])) { return self::error(400, 'attachment_not_uploaded', "attachments__{$index}__sha256"); diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 580ea717..9be2f5af 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -6,9 +6,11 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\Mailer\PostHogTransport; +use PostHog\Mailer\PostHogTransportException; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\Header\TrackingHeader; use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; @@ -102,7 +104,7 @@ public static function sends(): array self::RENDERED + [ 'template_key' => 'order-shipped', 'distinct_id' => 'user_42', - 'capture_engagement' => false, + 'tracking' => ['engagement' => false], ], ['Idempotency-Key' => 'order-981-shipped'], ], @@ -172,17 +174,71 @@ public static function sends(): array ], [], ], - '14 X-Track leaves engagement capture to its own header' => [ + '14 X-Track turns opens and clicks on' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=true']), + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'tracking' => ['opens' => true, 'clicks' => true], + ], + [], + ], + '14 X-Track turns opens and clicks off' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=false']), + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'tracking' => ['opens' => false, 'clicks' => false], + ], + [], + ], + '14 X-Track sets opens and clicks apart' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=false']), + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'tracking' => ['opens' => true, 'clicks' => false], + ], + [], + ], + '14 X-Track leaves a default flag to PostHog' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=default; clicks=false']), + self::RENDERED + ['template_key' => 'posthog-php-mailer', 'tracking' => ['clicks' => false]], + [], + ], + '14 X-Track with both flags default sends no tracking' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=default; clicks=default']), + self::RENDERED + ['template_key' => 'posthog-php-mailer'], + [], + ], + '14 engagement capture joins the X-Track flags' => [ fn() => self::withHeaders(self::shipped(), [ 'X-PostHog-Capture-Engagement' => 'false', 'X-Track' => 'opens=true; clicks=true', ]), - self::RENDERED + ['template_key' => 'posthog-php-mailer', 'capture_engagement' => false], + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'tracking' => ['opens' => true, 'clicks' => true, 'engagement' => false], + ], [], ], - '14 X-Track with clicks off, which PostHog never tracks' => [ - fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=true; clicks=false']), - self::RENDERED + ['template_key' => 'posthog-php-mailer'], + 'a sensitive email' => [ + fn() => self::withHeaders(self::shipped(), ['X-PostHog-Sensitive' => 'true']), + self::RENDERED + ['template_key' => 'posthog-php-mailer', 'sensitive' => true], + [], + ], + 'an email marked not sensitive' => [ + fn() => self::withHeaders(self::shipped(), ['X-PostHog-Sensitive' => 'false']), + self::RENDERED + ['template_key' => 'posthog-php-mailer', 'sensitive' => false], + [], + ], + 'text and HTML in ISO-8859-1 arrive as UTF-8' => [ + fn() => self::shipped()->text("Gr\xFC\xDFe.", 'iso-8859-1') + ->html("

Gr\xFC\xDFe.

", 'iso-8859-1'), + ['html' => '

Grüße.

', 'text' => 'Grüße.'] + self::RENDERED + + ['template_key' => 'posthog-php-mailer'], + [], + ], + 'a template key of 256 characters' => [ + fn() => self::withHeaders(self::shipped(), ['X-PostHog-Template-Key' => str_repeat('k', 256)]), + self::RENDERED + ['template_key' => str_repeat('k', 256)], [], ], ]; @@ -306,6 +362,7 @@ public function testTheReservedHeadersNeverGoOut(): void 'X-PostHog-Template-Key' => 'order-shipped', 'X-PostHog-Idempotency-Key' => 'order-981-shipped', 'X-PostHog-Capture-Engagement' => 'true', + 'X-PostHog-Sensitive' => 'true', ])); $postHogHeaders = array_values(array_filter( @@ -316,6 +373,44 @@ public function testTheReservedHeadersNeverGoOut(): void $this->assertArrayNotHasKey('headers', $this->body()); } + public static function clicksWithoutOpens(): array + { + return [ + 'as a text header' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=true']), + ], + 'as a TrackingHeader' => [ + function () { + if (!class_exists(TrackingHeader::class)) { + self::markTestSkipped('TrackingHeader arrives in Symfony Mailer 8.2.'); + } + $email = self::shipped(); + $email->getHeaders()->add(new TrackingHeader(opens: false, clicks: true)); + return $email; + }, + ], + ]; + } + + /** + * @param Closure(): Email $email + */ + #[DataProvider('clicksWithoutOpens')] + public function testCase14ClicksWithoutOpensGoToPostHogWhichRefusesThem(Closure $email): void + { + try { + $this->transport()->send($email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertSame(400, $error->getStatusCode()); + $this->assertSame('clicks_need_opens', $error->getErrorCode()); + $this->assertSame('tracking', $error->getField()); + $this->assertFalse($error->isRetryable()); + } + $this->assertSame([['opens' => false, 'clicks' => true]], array_column($this->posthog()->sends(), 'tracking')); + $this->assertSame([], $this->posthog()->queued); + } + public function testCase07TheDefaultTemplateKeyComesFromConfig(): void { $this->transport(defaultTemplateKey: 'transactional')->send(self::shipped()); @@ -362,35 +457,58 @@ function () { fn() => self::withHeaders(self::shipped(), ['X-Metadata-Order' => '981']), 'metadata', ], - '14 open tracking off, even with engagement capture off' => [ - fn() => self::withHeaders(self::shipped(), [ - 'X-PostHog-Capture-Engagement' => 'false', - 'X-Track' => 'opens=false; clicks=false', - ]), - 'open_tracking_off', + '14 a second X-Track' => [ + fn() => self::withHeaders( + self::withHeaders(self::shipped(), ['X-Track' => 'opens=true']), + ['X-Track' => 'opens=false'] + ), + 'unsupported_header', ], - '14 open tracking off' => [ - fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=false; clicks=true']), - 'open_tracking_off', + '14 an X-Track flag that is not true, false or default' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=0']), + 'unsupported_header', ], - '14 open tracking off in capitals' => [ - fn() => self::withHeaders(self::shipped(), ['X-Track' => 'Opens=FALSE']), - 'open_tracking_off', + '14 an X-Track flag in capitals' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=FALSE']), + 'unsupported_header', ], - '14 open tracking off as 0' => [ - fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=0']), - 'open_tracking_off', + '14 an X-Track flag name it does not know' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'open=false']), + 'unsupported_header', ], - '14 open tracking off next to a value that is not ASCII' => [ - fn() => self::withHeaders(self::shipped(), ['X-Track' => 'note=é; opens=false']), - 'open_tracking_off', + '14 an X-Track part without a value' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens']), + 'unsupported_header', ], - '14 open tracking off in a second X-Track' => [ - fn() => self::withHeaders( - self::withHeaders(self::shipped(), ['X-Track' => 'opens=true']), - ['X-Track' => 'opens=false'] - ), - 'open_tracking_off', + 'a sensitive header that is not true or false' => [ + fn() => self::withHeaders(self::shipped(), ['X-PostHog-Sensitive' => 'yes']), + 'unsupported_header', + ], + '08 a template key over 256 characters' => [ + fn() => self::withHeaders(self::shipped(), ['X-PostHog-Template-Key' => str_repeat('k', 257)]), + 'invalid_template_key', + ], + 'a Content-Language header' => [ + fn() => self::withHeaders(self::shipped(), ['Content-Language' => 'fr']), + 'unsupported_header', + ], + 'a body in a charset that cannot convert to UTF-8' => [ + fn() => self::shipped()->text('On its way.', 'x-no-such-charset'), + 'unsupported_part', + ], + 'a custom body that leaves out the HTML' => [ + fn() => self::shipped()->setBody(new TextPart('On its way.')), + 'unsupported_part', + ], + 'a custom body that leaves out an attachment' => [ + function () { + $email = self::shipped()->attach(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf'); + return $email->setBody(new AlternativePart( + new TextPart('On its way.'), + new TextPart('

On its way.

', 'utf-8', 'html') + )); + }, + 'unsupported_part', ], 'an AMP alternative set as the body' => [ fn() => self::shipped()->setBody(new AlternativePart( @@ -474,7 +592,12 @@ public function testATemplateEmailChecksButDoesNotSendTheRenderedOptions(): void $this->requireRemoteTemplates(); $email = self::withHeaders( (new RemoteTemplateEmail())->from('shop@example.com')->to('ana@example.com')->template(self::TEMPLATE_ID), - ['X-PostHog-Template-Key' => 'order-shipped', 'X-PostHog-Capture-Engagement' => 'false'] + [ + 'X-PostHog-Template-Key' => 'order-shipped', + 'X-PostHog-Capture-Engagement' => 'false', + 'X-PostHog-Sensitive' => 'true', + 'X-Track' => 'opens=false; clicks=false', + ] ); $this->transport()->send($email); @@ -496,7 +619,8 @@ public static function templateRefusals(): array ['X-PostHog-Capture-Engagement' => 'yes'], 'unsupported_header', ], - 'open tracking off' => [['X-Track' => 'opens=false'], 'open_tracking_off'], + 'an X-Track flag it does not know' => [['X-Track' => 'open=false'], 'unsupported_header'], + 'a sensitive header that is not true or false' => [['X-PostHog-Sensitive' => 'yes'], 'unsupported_header'], ]; } From c3dd2b141ff6d5e0c58eecdccdb90056dc8fb607 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:02:53 +0000 Subject: [PATCH 13/30] fix(mailer): declare RemoteTemplateSupport where Composer's classmap 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 --- .github/workflows/php.yml | 4 ++++ api/public-api.json | 2 +- lib/Internal/Mailer/RemoteTemplateSupport.php | 23 ++++++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 56e083a7..9b80ec6f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -162,6 +162,10 @@ jobs: composer require --dev --no-update "symfony/mailer:$SYMFONY" "symfony/mime:$SYMFONY" "symfony/framework-bundle:$SYMFONY" composer update --with-all-dependencies --prefer-dist --no-progress + # Apps that deploy with an authoritative classmap load only the classes Composer found. + - name: Use an authoritative classmap + run: composer dump-autoload --classmap-authoritative + - name: Run the Symfony Mailer tests run: ./vendor/bin/phpunit --bootstrap vendor/autoload.php --configuration phpunit.xml --no-coverage test/Mailer diff --git a/api/public-api.json b/api/public-api.json index 4116544b..0ddf5208 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -4169,7 +4169,7 @@ "readonly": false, "extends": "Symfony\\Component\\Mailer\\Transport\\AbstractTransport", "implements": [ - "PostHog\\Internal\\Mailer\\NoRemoteTemplates", + "PostHog\\Internal\\Mailer\\RemoteTemplateSupport", "Stringable", "Symfony\\Component\\Mailer\\Transport\\TransportInterface" ], diff --git a/lib/Internal/Mailer/RemoteTemplateSupport.php b/lib/Internal/Mailer/RemoteTemplateSupport.php index ce6d87c0..1284845d 100644 --- a/lib/Internal/Mailer/RemoteTemplateSupport.php +++ b/lib/Internal/Mailer/RemoteTemplateSupport.php @@ -5,9 +5,20 @@ use Symfony\Component\Mailer\Transport\RemoteTemplateTransportInterface; // PostHogTransport sends a RemoteTemplateEmail on Symfony Mailer 8.2 and later, and stays loadable before 8.2. -class_alias( - interface_exists(RemoteTemplateTransportInterface::class) - ? RemoteTemplateTransportInterface::class - : NoRemoteTemplates::class, - RemoteTemplateSupport::class -); +// Both branches declare the interface, so Composer's classmap finds it. +if (interface_exists(RemoteTemplateTransportInterface::class)) { + /** + * @internal + */ + interface RemoteTemplateSupport extends RemoteTemplateTransportInterface + { + } +} else { + /** + * @internal + */ + // phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses -- one name, declared once per Symfony line. + interface RemoteTemplateSupport + { + } +} From 879370f1e215a9d5eef3656d7affe0a65c292406 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:03:44 +0000 Subject: [PATCH 14/30] test(mailer): a failed attachment upload says whether to retry 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 --- test/Mailer/FakePostHog.php | 8 ++- test/Mailer/PostHogTransportErrorsTest.php | 61 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/test/Mailer/FakePostHog.php b/test/Mailer/FakePostHog.php index 4b17141f..b07f1feb 100644 --- a/test/Mailer/FakePostHog.php +++ b/test/Mailer/FakePostHog.php @@ -22,6 +22,10 @@ final class FakePostHog public array $requests = []; /** @var list}|EmailError> the send route's next answers */ public array $sendReplies = []; + /** @var list}> the upload route's next answers, in place of negotiating */ + public array $uploadReplies = []; + /** @var list}> storage's next answers, in place of storing */ + public array $storageReplies = []; /** @var array the files storage holds, by SHA-256 */ public array $stored = []; /** @var list> the emails PostHog queued, in order */ @@ -49,13 +53,13 @@ public function __invoke( ): array { $this->requests[] = compact('method', 'url', 'headers', 'body'); if ($method === 'PUT') { - return $this->store($url, $headers, (string) $body); + return array_shift($this->storageReplies) ?? $this->store($url, $headers, (string) $body); } if (self::header($headers, 'Authorization') !== 'Bearer ' . $this->secretKey) { return self::error(401, 'authentication_failed', null); } if (str_ends_with($url, '/messaging/attachments/uploads/')) { - return $this->negotiate((string) $body); + return array_shift($this->uploadReplies) ?? $this->negotiate((string) $body); } return $this->send($headers, (string) $body); diff --git a/test/Mailer/PostHogTransportErrorsTest.php b/test/Mailer/PostHogTransportErrorsTest.php index 3e124065..541e9129 100644 --- a/test/Mailer/PostHogTransportErrorsTest.php +++ b/test/Mailer/PostHogTransportErrorsTest.php @@ -123,6 +123,67 @@ public function testAFailedSendIsATransportExceptionThatSaysWhetherToRetry( $this->assertSame($this->header('Idempotency-Key'), $this->header('Idempotency-Key', $requests - 1)); } + public static function uploadFailures(): array + { + $sdkRetries = 3; + $quota = self::answer(429, 'upload_quota_exceeded'); + + return [ + 'upload quota exceeded' => [[$quota], [], 429, 'upload_quota_exceeded', false, 1], + 'storage behind a failing gateway' => [ + [], + array_fill(0, $sdkRetries, [502, 'Bad Gateway', []]), + 502, + 'attachment_upload_failed', + true, + $sdkRetries, + ], + 'storage refuses the file' => [ + [], + [[403, 'AccessDenied', []]], + 403, + 'attachment_upload_failed', + false, + 1, + ], + ]; + } + + /** + * @param list}> $uploadReplies + * @param list}> $storageReplies + */ + #[DataProvider('uploadFailures')] + public function testAFailedAttachmentUploadSaysWhetherToRetryAndSendsNothing( + array $uploadReplies, + array $storageReplies, + int $status, + string $code, + bool $retryable, + int $attempts + ): void { + $this->posthog()->uploadReplies = $uploadReplies; + $this->posthog()->storageReplies = $storageReplies; + $email = self::email()->attach("%PDF-1.4\n", 'invoice.pdf', 'application/pdf'); + + try { + $this->transport()->send($email); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertSame($status, $error->getStatusCode()); + $this->assertSame($code, $error->getErrorCode()); + $this->assertSame($retryable, $error->isRetryable()); + } + $route = $storageReplies === [] ? 'POST' : 'PUT'; + $this->assertCount($attempts, array_filter( + $this->requests(), + static fn(array $request): bool => $request['method'] === $route + && ($route === 'PUT' || str_ends_with($request['url'], '/uploads/')) + )); + $this->assertSame([], $this->posthog()->sends(), 'no email was sent'); + $this->assertSame([], $this->posthog()->stored); + } + public function testAnEmailTheSenderRefusesFailsWithoutARequest(): void { try { From 3ef0a99dcbe9ee6a35acdbf6750e31b5c6e9c9bf Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:19:17 +0000 Subject: [PATCH 15/30] fix(mailer): refuse a Sender, Return-Path or body part the send can't 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 41 ++++++++++++++++--- .../PostHogTransportConformanceTest.php | 33 +++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index be8da29a..7f71f0c7 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -128,7 +128,18 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void $envelopeRecipients = self::addresses($envelope->getRecipients()); sort($recipients); sort($envelopeRecipients); - if ($from?->getAddress() !== $envelope->getSender()->getAddress() || $recipients !== $envelopeRecipients) { + // Symfony bounces to Sender, then Return-Path, unless the envelope names another sender. The send has one + // sender, the From address, so each of them must be that address. + $senders = array_filter([$envelope->getSender(), $email->getSender(), $email->getReturnPath()]); + foreach ($senders as $sender) { + if ($from?->getAddress() !== $sender->getAddress()) { + throw self::refusal( + 'envelope_mismatch', + 'It sends from the From address only; the envelope sender, Sender and Return-Path must be it.' + ); + } + } + if ($recipients !== $envelopeRecipients) { throw self::refusal( 'envelope_mismatch', 'It sends from the From address to the To, Cc and Bcc addresses only.' @@ -194,11 +205,7 @@ private static function refuseUnsupportedParts(Email $email, ?string $text, ?str ); } unset($attachments[$index]); - } elseif ( - $part instanceof TextPart - && ($expected[$part->getMediaSubtype()] ?? null) !== null - && $part->getBody() === $expected[$part->getMediaSubtype()] - ) { + } elseif ($part instanceof TextPart && self::isOwnBody($email, $part, $expected)) { $expected[$part->getMediaSubtype()] = null; } else { throw self::refusal( @@ -217,6 +224,28 @@ private static function refuseUnsupportedParts(Email $email, ?string $text, ?str } } + /** + * Whether a custom body part is the email's own text or HTML, as Symfony would build it: the same content in the + * same charset, shown as the body rather than attached. + * + * @param array{plain: ?string, html: ?string} $expected the text and HTML no part has matched yet + */ + private static function isOwnBody(Email $email, TextPart $part, array $expected): bool + { + $subtype = $part->getMediaSubtype(); + $charset = match ($subtype) { + 'plain' => $email->getTextCharset(), + 'html' => $email->getHtmlCharset(), + default => null, + }; + $partCharset = $part->getPreparedHeaders()->getHeaderParameter('Content-Type', 'charset'); + + return ($expected[$subtype] ?? null) !== null + && $part->getBody() === $expected[$subtype] + && strtolower((string) $partCharset) === strtolower((string) $charset) + && in_array($part->getDisposition(), [null, 'inline'], true); + } + /** * @param array $variables * @return array diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 9be2f5af..46f16d5e 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -536,6 +536,24 @@ function () { )), 'unsupported_part', ], + 'a text part in another charset than the email\'s text' => [ + fn() => self::shipped()->setBody(new AlternativePart( + new TextPart('On its way.', 'iso-8859-1'), + new TextPart('

On its way.

', 'utf-8', 'html') + )), + 'unsupported_part', + ], + 'the email\'s HTML sent as an attachment' => [ + fn() => self::shipped()->setBody(new MixedPart( + new TextPart('On its way.'), + (new TextPart('

On its way.

', 'utf-8', 'html'))->setDisposition('attachment') + )), + 'unsupported_part', + ], + '13 a Return-Path other than the From address' => [ + fn() => self::shipped()->sender('shop@example.com')->returnPath('bounce@example.com'), + 'envelope_mismatch', + ], 'an HTML part when the email has no HTML body' => [ fn() => self::shipped()->html(null)->setBody(new AlternativePart( new TextPart('On its way.'), @@ -567,6 +585,21 @@ public function testCase13AnEnvelopeThatDiffersFromTheHeadersIsRefused(): void $this->assertRefused('envelope_mismatch', fn() => $this->transport()->send(self::shipped(), $envelope)); } + public function testCase13ASenderOtherThanTheFromIsRefusedEvenWithAMatchingEnvelope(): void + { + $envelope = new Envelope(new Address('shop@example.com'), [new Address('ana@example.com')]); + $email = self::shipped()->sender('ops@example.com'); + + $this->assertRefused('envelope_mismatch', fn() => $this->transport()->send($email, $envelope)); + } + + public function testASenderAndReturnPathOfTheFromAddressSend(): void + { + $this->transport()->send(self::shipped()->sender('shop@example.com')->returnPath('shop@example.com')); + + $this->assertSame('Shop ', $this->body()['from']); + } + public function testCase15ARemoteTemplateEmailSendsTheTemplate(): void { $this->requireRemoteTemplates(); From 21a75c766ef9a7703a84120dfba305798f9d77e7 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:52:34 +0000 Subject: [PATCH 16/30] fix(mailer): refuse a repeated option, an attachment's own headers, and 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 21 ++++++++++-- .../PostHogTransportConformanceTest.php | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 7f71f0c7..e35d9788 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -85,6 +85,9 @@ private function headerOptions(Email $email): array foreach ($email->getHeaders()->all() as $header) { $name = strtolower($header->getName()); if (in_array($name, self::OPTION_HEADERS, true)) { + if (isset($options[$name])) { + throw self::refusal('unsupported_header', "It takes one {$header->getName()} header."); + } // The raw value: getBodyAsString() MIME-encodes a value that isn't ASCII, such as a distinct ID. $options[$name] = $header instanceof UnstructuredHeader ? $header->getValue() @@ -291,8 +294,7 @@ private function templateKey(?string $explicit): string */ private static function tracking(Email $email, ?string $engagement): ?array { - $headers = $email->getHeaders()->all(self::TRACKING_HEADER); - $headers = $headers instanceof \Traversable ? iterator_to_array($headers, false) : $headers; + $headers = iterator_to_array($email->getHeaders()->all(self::TRACKING_HEADER), false); if (count($headers) > 1) { throw self::refusal('unsupported_header', 'It takes one X-Track header; remove the others.'); } @@ -356,7 +358,13 @@ private static function utf8(?string $body, ?string $charset, string $part): ?st */ private static function attachment(DataPart $part, ?string $html): array { - $attachment = ['filename' => $part->getFilename(), 'content' => $part->getBody()]; + if (array_diff($part->getHeaders()->getNames(), ['content-id']) !== []) { + throw self::refusal( + 'unsupported_part', + "It can't carry the headers set on the attachment {$part->getFilename()}; remove them." + ); + } + $attachment = ['filename' => $part->getFilename() ?? $part->getName(), 'content' => $part->getBody()]; $attachment['content_type'] = $part->getContentType(); $contentId = self::contentId($part, $html); if ($contentId !== null) { @@ -375,6 +383,13 @@ private static function contentId(DataPart $part, ?string $html): ?string { $name = $part->getName(); if ($name !== null && self::refersTo($html, $name)) { + $contentId = $part->hasContentId() ? $part->getContentId() : $name; + if ($contentId !== $name && self::refersTo($html, $contentId)) { + throw self::refusal( + 'unsupported_part', + "The HTML refers to one inline part as both cid:$name and cid:$contentId; use one." + ); + } return $name; } if ($part->hasContentId() && self::refersTo($html, $part->getContentId())) { diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 46f16d5e..f00b7e9d 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -312,6 +312,17 @@ public function testAMultipartEmailWithAnAttachmentAndAnInlineImageSends(): void $this->assertArrayNotHasKey('content_id', $this->body()['attachments'][0]); } + public function testAnInlinePartWithANameButNoFilenameSendsItsNameAsTheFilename(): void + { + $email = self::shipped()->html('') + ->addPart((new DataPart(base64_decode('iVBORw0KGgo='), null, 'image/png'))->setName('logo')->asInline()); + + $this->transport()->send($email); + + $this->assertSame('logo', $this->body()['attachments'][0]['filename']); + $this->assertSame('logo', $this->body()['attachments'][0]['content_id']); + } + public function testACustomBodyThatHoldsTheEmailsOwnPartsSends(): void { $email = self::shipped()->attach(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf'); @@ -437,6 +448,28 @@ public static function refusals(): array ->asInline()), 'inline_without_cid', ], + '10 an inline part the HTML refers to by its name and by its content ID' => [ + fn() => self::shipped()->html('') + ->addPart((new DataPart(base64_decode('iVBORw0KGgo='), 'logo', 'image/png')) + ->setContentId('logo@shop.example')->asInline()), + 'unsupported_part', + ], + 'an attachment with a header of its own' => [ + function () { + $invite = new DataPart("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", 'invite.ics', 'text/calendar'); + $invite->getHeaders() + ->addParameterizedHeader('Content-Type', 'text/calendar', ['method' => 'REQUEST']); + return self::shipped()->addPart($invite); + }, + 'unsupported_part', + ], + 'a second X-PostHog-Distinct-Id' => [ + fn() => self::withHeaders( + self::withHeaders(self::shipped(), ['X-PostHog-Distinct-Id' => 'user-1']), + ['X-PostHog-Distinct-Id' => 'user-2'] + ), + 'unsupported_header', + ], 'an engagement header that is not true or false' => [ fn() => self::withHeaders(self::shipped(), ['X-PostHog-Capture-Engagement' => 'yes']), 'unsupported_header', From e473b903f25e3a034b11b2d6a3537a98358e4732 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:00:19 +0000 Subject: [PATCH 17/30] fix(mailer): send through the app's http_client 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- .changeset/symfony-mailer-transport.md | 2 +- .github/workflows/php.yml | 2 +- api/public-api.json | 48 ----- composer.json | 1 + composer.lock | 187 +++++++++++++++++++- lib/Mailer/PostHogTransportFactory.php | 82 +++++++-- test/Mailer/FakePostHog.php | 15 ++ test/Mailer/MailerKernel.php | 12 +- test/Mailer/PostHogTransportFactoryTest.php | 65 ++++++- test/Mailer/RecordedRequests.php | 16 +- 10 files changed, 343 insertions(+), 87 deletions(-) diff --git a/.changeset/symfony-mailer-transport.md b/.changeset/symfony-mailer-transport.md index f5d7600c..f16cdd27 100644 --- a/.changeset/symfony-mailer-transport.md +++ b/.changeset/symfony-mailer-transport.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Send Symfony Mailer email through PostHog: set `MAILER_DSN=posthog+api://@default` and register `PostHog\Mailer\PostHogTransportFactory`. Rendered emails, attachments, inline images, and the PostHog options (distinct ID, template key, idempotency key, engagement capture, sensitive) map to a transactional send, and an email PostHog cannot carry is refused before any request. Symfony's `X-Track` header sets which opens and clicks PostHog tracks. On Symfony Mailer 8.2, a `RemoteTemplateEmail` sends a PostHog template. `EmailError::isRetryable()` is now public. +Send Symfony Mailer email through PostHog: set `MAILER_DSN=posthog+api://@default` and register `PostHog\Mailer\PostHogTransportFactory`. Rendered emails, attachments, inline images, and the PostHog options (distinct ID, template key, idempotency key, engagement capture, sensitive) map to a transactional send, and an email PostHog cannot carry is refused before any request. Sends go through the app's `http_client` service when it has one. Symfony's `X-Track` header sets which opens and clicks PostHog tracks. On Symfony Mailer 8.2, a `RemoteTemplateEmail` sends a PostHog template. `EmailError::isRetryable()` is now public. diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 9b80ec6f..5ff8be70 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -159,7 +159,7 @@ jobs: composer config minimum-stability "$STABILITY" if [ "$ALLOW_INSECURE" = "true" ]; then composer config policy.advisories.block false; fi composer config prefer-stable true - composer require --dev --no-update "symfony/mailer:$SYMFONY" "symfony/mime:$SYMFONY" "symfony/framework-bundle:$SYMFONY" + composer require --dev --no-update "symfony/mailer:$SYMFONY" "symfony/mime:$SYMFONY" "symfony/framework-bundle:$SYMFONY" "symfony/http-client:$SYMFONY" composer update --with-all-dependencies --prefer-dist --no-progress # Apps that deploy with an authoritative classmap load only the classes Composer found. diff --git a/api/public-api.json b/api/public-api.json index 0ddf5208..9c96694a 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -4340,54 +4340,6 @@ "constants": [], "properties": [], "methods": { - "__construct": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [ - { - "name": "dispatcher", - "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", - "byReference": false, - "variadic": false, - "optional": true, - "default": null, - "defaultConstant": null, - "hasDefault": true - }, - { - "name": "client", - "type": "?Symfony\\Contracts\\HttpClient\\HttpClientInterface", - "byReference": false, - "variadic": false, - "optional": true, - "default": null, - "defaultConstant": null, - "hasDefault": true - }, - { - "name": "logger", - "type": "?Psr\\Log\\LoggerInterface", - "byReference": false, - "variadic": false, - "optional": true, - "default": null, - "defaultConstant": null, - "hasDefault": true - }, - { - "name": "sendRequest", - "type": "?Closure", - "byReference": false, - "variadic": false, - "optional": true, - "default": null, - "defaultConstant": null, - "hasDefault": true - } - ] - }, "create": { "static": false, "abstract": false, diff --git a/composer.json b/composer.json index 5d85b9df..c40500ab 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "open-telemetry/api": "^1.0", "open-telemetry/sdk": "^1.0", "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", "symfony/mailer": "^6.4|^7.0|^8.0" }, "suggest": { diff --git a/composer.lock b/composer.lock index 807760b6..adffd9b8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bc30ca739c922d6ec8ac0162d99e235", + "content-hash": "71512986a47bad59ce29c69da465d890", "packages": [ { "name": "psr/clock", @@ -4401,6 +4401,191 @@ "time": "2026-11-04T07:46:24+00:00", "published-time": "2026-11-04T11:27:38+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "ed012bfca0f7b45d97016efcdf9b0639c52a1722" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/ed012bfca0f7b45d97016efcdf9b0639c52a1722", + "reference": "ed012bfca0f7b45d97016efcdf9b0639c52a1722", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:28:32+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "f9f94260f787446c6fd430b2ae1969fe41ae1ba8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/f9f94260f787446c6fd430b2ae1969fe41ae1ba8", + "reference": "f9f94260f787446c6fd430b2ae1969fe41ae1ba8", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-04T15:04:25+00:00", + "published-time": "2026-11-04T11:25:37+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.4.23", diff --git a/lib/Mailer/PostHogTransportFactory.php b/lib/Mailer/PostHogTransportFactory.php index 589df2c8..ac253e8e 100644 --- a/lib/Mailer/PostHogTransportFactory.php +++ b/lib/Mailer/PostHogTransportFactory.php @@ -3,13 +3,13 @@ namespace PostHog\Mailer; use Closure; +use PostHog\EmailError; use PostHog\Emails; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; use Symfony\Component\Mailer\Transport\AbstractTransportFactory; use Symfony\Component\Mailer\Transport\Dsn; use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -19,32 +19,31 @@ * Cloud, or your own PostHog host. The `default_template_key` option sets the template key of emails * without `X-PostHog-Template-Key`. Register this class with the `mailer.transport_factory` service tag. * + * It sends through the app's `http_client` service when Symfony passes one, so the app's proxy, TLS and + * mock settings apply; without one, it sends with curl. + * * Symfony's own PostHog bridge, once installed, handles the same scheme, so this factory steps aside. */ final class PostHogTransportFactory extends AbstractTransportFactory { private const SCHEME = 'posthog+api'; private const BRIDGE_FACTORY = 'Symfony\Component\Mailer\Bridge\PostHog\Transport\PostHogTransportFactory'; - - /** - * @param Closure|null $sendRequest @internal Replaces the HTTP requests, for tests. - */ - public function __construct( - ?EventDispatcherInterface $dispatcher = null, - ?HttpClientInterface $client = null, - ?LoggerInterface $logger = null, - private readonly ?Closure $sendRequest = null, - ) { - parent::__construct($dispatcher, $client, $logger); - } + private const MAX_ANSWER_BYTES = 1_048_576; public function create(Dsn $dsn): TransportInterface { if (!in_array($dsn->getScheme(), $this->getSupportedSchemes(), true)) { throw new UnsupportedSchemeException($dsn, 'posthog', $this->getSupportedSchemes()); } + $emails = new Emails( + $this->getUser($dsn), + 'default', + appHost: self::appHost($dsn), + transport: $this->sendThroughClient() + ); + return new PostHogTransport( - new Emails($this->getUser($dsn), 'default', appHost: self::appHost($dsn), transport: $this->sendRequest), + $emails, defaultTemplateKey: $dsn->getOption('default_template_key'), dispatcher: $this->dispatcher, logger: $this->logger @@ -56,6 +55,59 @@ protected function getSupportedSchemes(): array return class_exists(self::BRIDGE_FACTORY) ? [] : [self::SCHEME]; } + private function sendThroughClient(): ?Closure + { + $client = $this->client; + if ($client === null) { + return null; + } + + return static function ( + string $method, + string $url, + array $headers, + ?string $body, + int $timeoutMs, + int $connectTimeoutMs + ) use ($client): array { + try { + $response = $client->request($method, $url, [ + 'headers' => $headers, + 'body' => $body ?? '', + 'timeout' => $connectTimeoutMs / 1000, + 'max_duration' => $timeoutMs / 1000, + 'max_redirects' => 0, + ]); + $answer = ''; + foreach ($client->stream($response) as $chunk) { + $answer .= $chunk->getContent(); + if (strlen($answer) > self::MAX_ANSWER_BYTES) { + $response->cancel(); + $limit = self::MAX_ANSWER_BYTES; + throw new EmailError( + 'response_too_large', + "PostHog answered with more than $limit bytes, so the SDK stopped reading." + ); + } + } + $lines = []; + foreach ($response->getHeaders(false) as $name => $values) { + foreach ($values as $value) { + $lines[] = "$name: $value"; + } + } + + return [$response->getStatusCode(), $answer, $lines]; + } catch (TransportExceptionInterface $error) { + throw new EmailError( + 'network_error', + 'The request to PostHog failed: ' . $error->getMessage(), + previous: $error + ); + } + }; + } + private static function appHost(Dsn $dsn): string { $host = $dsn->getHost() === 'default' ? 'us.posthog.com' : $dsn->getHost(); diff --git a/test/Mailer/FakePostHog.php b/test/Mailer/FakePostHog.php index b07f1feb..22698a87 100644 --- a/test/Mailer/FakePostHog.php +++ b/test/Mailer/FakePostHog.php @@ -3,6 +3,8 @@ namespace PostHog\Test\Mailer; use PostHog\EmailError; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; /** * PostHog's send and upload routes and its file storage, in place of the network. @@ -65,6 +67,19 @@ public function __invoke( return $this->send($headers, (string) $body); } + /** + * This fake behind Symfony's HTTP client, as an app's `http_client` service reaches it. + */ + public function httpClient(): MockHttpClient + { + return new MockHttpClient(function (string $method, string $url, array $options): MockResponse { + $body = $options['body'] ?? ''; + [$status, $answer, $headers] = $this($method, $url, $options['headers'], $body === '' ? null : $body, 0); + + return new MockResponse($answer, ['http_code' => $status, 'response_headers' => $headers]); + }); + } + /** * The sends that reached the send route, accepted or not, as their decoded bodies. * diff --git a/test/Mailer/MailerKernel.php b/test/Mailer/MailerKernel.php index 4bc8d73c..c8928444 100644 --- a/test/Mailer/MailerKernel.php +++ b/test/Mailer/MailerKernel.php @@ -2,7 +2,6 @@ namespace PostHog\Test\Mailer; -use Closure; use PostHog\Mailer\PostHogTransportFactory; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; @@ -10,10 +9,9 @@ use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Mailer\MailerInterface; -use function Symfony\Component\DependencyInjection\Loader\Configurator\service; - /** - * A Symfony app that registers the factory the way the docs do and sets MAILER_DSN to PostHog. + * A Symfony app that registers the factory the way the docs do, sets MAILER_DSN to PostHog, and answers its HTTP + * client's requests with RecordedRequests. */ final class MailerKernel extends Kernel { @@ -36,15 +34,13 @@ protected function configureContainer(ContainerConfigurator $container): void 'test' => true, 'http_method_override' => false, 'mailer' => ['dsn' => 'posthog+api://phs_kernel@default'], + 'http_client' => ['mock_response_factory' => RecordedRequests::class], ]); $services = $container->services(); $services->alias('test.mailer', MailerInterface::class)->public(); - $services->set('posthog.test.send_request', Closure::class) - ->factory([Closure::class, 'fromCallable']) - ->args([[RecordedRequests::class, 'send']]); + $services->set(RecordedRequests::class); $services->set(PostHogTransportFactory::class) ->parent('mailer.transport_factory.abstract') - ->arg('$sendRequest', service('posthog.test.send_request')) ->tag('mailer.transport_factory'); } } diff --git a/test/Mailer/PostHogTransportFactoryTest.php b/test/Mailer/PostHogTransportFactoryTest.php index c9b426a1..cc3306aa 100644 --- a/test/Mailer/PostHogTransportFactoryTest.php +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -2,12 +2,16 @@ namespace PostHog\Test\Mailer; -use Closure; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; +use PostHog\Mailer\PostHogTransportException; use PostHog\Mailer\PostHogTransportFactory; +use Symfony\Component\Clock\Clock; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; use Symfony\Component\Mailer\Transport; use Symfony\Component\Mailer\Transport\Dsn; @@ -17,13 +21,16 @@ final class PostHogTransportFactoryTest extends TestCase { use SendsThroughStubbedHttp; - private function send(string $dsn): void + private function send(string $dsn, ?Email $email = null): void { $this->posthog = new FakePostHog('phs_dsn'); - $factory = new PostHogTransportFactory(sendRequest: Closure::fromCallable($this->posthog)); - (new Transport([$factory]))->fromString($dsn)->send( - (new Email())->from('shop@example.com')->to('ana@example.com')->subject('Hi')->text('Hi') - ); + $factory = new PostHogTransportFactory(client: $this->posthog->httpClient()); + (new Transport([$factory]))->fromString($dsn)->send($email ?? self::email()); + } + + private static function email(): Email + { + return (new Email())->from('shop@example.com')->to('ana@example.com')->subject('Hi')->text('Hi'); } public static function hosts(): array @@ -54,6 +61,52 @@ public function testTheDsnSetsTheDefaultTemplateKey(): void $this->assertSame('transactional', $this->body()['template_key']); } + public function testAFileUploadsThroughTheAppsHttpClient(): void + { + $pdf = "%PDF-1.4\n"; + + $this->send('posthog+api://phs_dsn@default', self::email()->attach($pdf, 'invoice.pdf', 'application/pdf')); + + $this->assertSame([hash('sha256', $pdf) => $pdf], $this->posthog->stored); + $this->assertCount(1, $this->posthog->queued); + } + + public function testAFailureOfTheAppsHttpClientIsANetworkErrorToRetry(): void + { + $previousClock = Clock::get(); + Clock::set(new MockClock('2026-11-13 12:00:00')); + $unreachable = new MockHttpClient( + static fn(): MockResponse => new MockResponse('', ['error' => 'Could not resolve host: us.posthog.com']) + ); + $transport = (new Transport([new PostHogTransportFactory(client: $unreachable)])) + ->fromString('posthog+api://phs_dsn@default'); + + try { + $transport->send(self::email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertSame('network_error', $error->getErrorCode()); + $this->assertTrue($error->isRetryable()); + $this->assertSame(3, $unreachable->getRequestsCount()); + } finally { + Clock::set($previousClock); + } + } + + public function testAnAnswerOverTheLimitStopsTheRead(): void + { + $flood = new MockHttpClient(new MockResponse(str_repeat('x', 1_048_577), ['http_code' => 202])); + $transport = (new Transport([new PostHogTransportFactory(client: $flood)])) + ->fromString('posthog+api://phs_dsn@default'); + + try { + $transport->send(self::email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertSame('response_too_large', $error->getErrorCode()); + } + } + public function testAnotherSchemeIsNotPostHogs(): void { $factory = new PostHogTransportFactory(); diff --git a/test/Mailer/RecordedRequests.php b/test/Mailer/RecordedRequests.php index 48f09d0b..b8fd4e66 100644 --- a/test/Mailer/RecordedRequests.php +++ b/test/Mailer/RecordedRequests.php @@ -2,22 +2,24 @@ namespace PostHog\Test\Mailer; +use Symfony\Component\HttpClient\Response\MockResponse; + /** - * The HTTP requests MailerKernel's PostHog transport made, in place of the network. + * MailerKernel's HTTP client in place of the network: it records each request and PostHog accepts every send. */ final class RecordedRequests { - /** @var list, body: ?string}> */ + /** @var list, body: string}> */ public static array $requests = []; /** - * @param list $headers - * @return array{int, string, list} + * @param array{headers: list, body?: string} $options */ - public static function send(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array + public function __invoke(string $method, string $url, array $options): MockResponse { - self::$requests[] = compact('method', 'url', 'headers', 'body'); + self::$requests[] = ['method' => $method, 'url' => $url, 'headers' => $options['headers'], + 'body' => $options['body'] ?? '']; - return [202, '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}', []]; + return new MockResponse('{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}', ['http_code' => 202]); } } From 3678781dab47fff974357f85aad69a59150dd936 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:02:39 +0000 Subject: [PATCH 18/30] fix(mailer): refuse a bad integration when the transport is built 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 11 +++----- lib/Internal/Integration.php | 26 ++++++++++++++++++ lib/Mailer/PostHogTransport.php | 4 +++ .../PostHogTransportConformanceTest.php | 27 +++++++++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 lib/Internal/Integration.php diff --git a/lib/Emails.php b/lib/Emails.php index 4d80f1ce..8c527cf7 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -7,6 +7,7 @@ use DateTimeZone; use InvalidArgumentException; use JsonException; +use PostHog\Internal\Integration; use Symfony\Component\Clock\Clock; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\MonotonicClock; @@ -61,7 +62,6 @@ final class Emails private const IDEMPOTENCY_KEY_PATTERN = '/^[\x21-\x7e]{1,255}$/D'; private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; private const HEADER_TOKEN_PATTERN = '/^[\x21-\x7e]+$/D'; - private const INTEGRATION_PATTERN = '#^[a-z0-9-]{1,40}/[0-9A-Za-z.+-]{1,40}$#D'; private const TIMESTAMP_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/D'; private const SHARED_FIELDS = ['to', 'distinct_id', 'idempotency_key', 'max_retries', 'integration']; @@ -395,13 +395,8 @@ private static function integration(mixed $integration): ?string if ($integration === null) { return null; } - if (!is_string($integration) || preg_match(self::INTEGRATION_PATTERN, $integration) !== 1) { - throw self::invalid( - 'invalid_integration', - "integration must be /, such as 'laravel/1.4.0': a name of lowercase letters, digits " - . 'and hyphens, and a version of letters, digits, dots, plus signs and hyphens, at most 40 each.', - 'integration' - ); + if (!Integration::isValid($integration)) { + throw self::invalid('invalid_integration', Integration::RULE, 'integration'); } return $integration; diff --git a/lib/Internal/Integration.php b/lib/Internal/Integration.php new file mode 100644 index 00000000..59312eac --- /dev/null +++ b/lib/Internal/Integration.php @@ -0,0 +1,26 @@ +/` an integration sends as `X-PostHog-Integration`, checked by Emails and PostHogTransport. + * + * @internal + */ +final class Integration +{ + public const RULE = "integration must be /, such as 'laravel/1.4.0': a name of lowercase " + . 'letters, digits and hyphens, and a version of letters, digits, dots, plus signs and hyphens, ' + . 'at most 40 each.'; + + private const PATTERN = '#^[a-z0-9-]{1,40}/[0-9A-Za-z.+-]{1,40}$#D'; + + private function __construct() + { + } + + public static function isValid(mixed $integration): bool + { + return is_string($integration) && preg_match(self::PATTERN, $integration) === 1; + } +} diff --git a/lib/Mailer/PostHogTransport.php b/lib/Mailer/PostHogTransport.php index 74958a03..481e1871 100644 --- a/lib/Mailer/PostHogTransport.php +++ b/lib/Mailer/PostHogTransport.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use PostHog\EmailError; use PostHog\Emails; +use PostHog\Internal\Integration; use PostHog\Internal\Mailer\EmailMapper; use PostHog\Internal\Mailer\RemoteTemplateSupport; use PostHog\PostHog; @@ -54,6 +55,9 @@ public function __construct( ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null, ) { + if ($integration !== null && !Integration::isValid($integration)) { + throw new InvalidArgumentException(ucfirst(Integration::RULE)); + } if ($defaultTemplateKey !== null && preg_match(EmailMapper::TEMPLATE_KEY_PATTERN, $defaultTemplateKey) !== 1) { throw new InvalidArgumentException( 'The default template key can only have letters, digits, hyphens (-) and underscores (_), at most 256.' diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index f00b7e9d..c75bf74b 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -3,6 +3,7 @@ namespace PostHog\Test\Mailer; use Closure; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\Mailer\PostHogTransport; @@ -429,6 +430,32 @@ public function testCase07TheDefaultTemplateKeyComesFromConfig(): void $this->assertSame('transactional', $this->body()['template_key']); } + public static function badSettings(): array + { + return [ + 'an integration name in capitals' => [['integration' => 'Laravel/11.0']], + 'an integration without a version' => [['integration' => 'laravel/']], + 'an integration without a name' => [['integration' => '/1.0']], + 'a default template key with spaces' => [['defaultTemplateKey' => 'order shipped']], + ]; + } + + /** @param array $settings */ + #[DataProvider('badSettings')] + public function testABadSettingFailsWhenTheTransportIsBuiltBeforeAnySend(array $settings): void + { + $this->expectException(InvalidArgumentException::class); + + new PostHogTransport($this->emails(), ...$settings); + } + + public function testAFrameworkPackagesIntegrationGoesOutOnTheSend(): void + { + (new PostHogTransport($this->emails(), integration: 'laravel/11.0.0'))->send(self::shipped()); + + $this->assertSame('laravel/11.0.0', $this->header('X-PostHog-Integration')); + } + public static function refusals(): array { return [ From 097fbf8daddfef180657a97b1f3ed911da3292cb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:02:39 +0000 Subject: [PATCH 19/30] refactor(mailer): name the mapper's steps instead of commenting them Ticket: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index e35d9788..be3ed3d2 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -9,6 +9,7 @@ use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Header\HeaderInterface; use Symfony\Component\Mime\Header\UnstructuredHeader; use Symfony\Component\Mime\Message; use Symfony\Component\Mime\Part\AbstractMultipartPart; @@ -50,7 +51,6 @@ public function map(Email $email, Envelope $envelope): array $to = $this->onlyRecipient($email); $options = $this->headerOptions($email); $this->refuseEnvelopeMismatch($email, $envelope); - // Checked on both forms; the template form doesn't send them. $templateKey = $this->templateKey($options[self::TEMPLATE_KEY] ?? null); $tracking = self::tracking($email, $options[self::CAPTURE_ENGAGEMENT] ?? null); $sensitive = self::flag('X-PostHog-Sensitive', $options[self::SENSITIVE] ?? null); @@ -88,10 +88,7 @@ private function headerOptions(Email $email): array if (isset($options[$name])) { throw self::refusal('unsupported_header', "It takes one {$header->getName()} header."); } - // The raw value: getBodyAsString() MIME-encodes a value that isn't ASCII, such as a distinct ID. - $options[$name] = $header instanceof UnstructuredHeader - ? $header->getValue() - : $header->getBodyAsString(); + $options[$name] = self::unencodedValue($header); continue; } self::refuseHeader($name); @@ -131,8 +128,6 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void $envelopeRecipients = self::addresses($envelope->getRecipients()); sort($recipients); sort($envelopeRecipients); - // Symfony bounces to Sender, then Return-Path, unless the envelope names another sender. The send has one - // sender, the From address, so each of them must be that address. $senders = array_filter([$envelope->getSender(), $email->getSender(), $email->getReturnPath()]); foreach ($senders as $sender) { if ($from?->getAddress() !== $sender->getAddress()) { @@ -155,9 +150,8 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void */ private function renderedContent(Email $email, string $templateKey): array { - // Read each body once: a stream that can't rewind is empty on a second read. - $html = self::text($email->getHtmlBody()); - $text = self::text($email->getTextBody()); + $html = self::readOnce($email->getHtmlBody()); + $text = self::readOnce($email->getTextBody()); self::refuseUnsupportedParts($email, $text, $html); $html = self::utf8($html, $email->getHtmlCharset(), 'HTML'); $text = self::utf8($text, $email->getTextCharset(), 'text'); @@ -455,7 +449,12 @@ private static function mailbox(Address $address): string /** * @param string|resource|null $body */ - private static function text(mixed $body): ?string + private static function unencodedValue(HeaderInterface $header): string + { + return $header instanceof UnstructuredHeader ? $header->getValue() : $header->getBodyAsString(); + } + + private static function readOnce(mixed $body): ?string { if (!is_resource($body)) { return $body; From f903c76b85eca34385d966718030db884fbc612a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:29:20 +0000 Subject: [PATCH 20/30] fix(mailer): retry a busy PostHog through the app's http_client 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Mailer/PostHogTransportFactory.php | 12 ++++++------ test/Mailer/PostHogTransportFactoryTest.php | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/Mailer/PostHogTransportFactory.php b/lib/Mailer/PostHogTransportFactory.php index ac253e8e..26a7837a 100644 --- a/lib/Mailer/PostHogTransportFactory.php +++ b/lib/Mailer/PostHogTransportFactory.php @@ -78,6 +78,12 @@ private function sendThroughClient(): ?Closure 'max_duration' => $timeoutMs / 1000, 'max_redirects' => 0, ]); + $lines = []; + foreach ($response->getHeaders(false) as $name => $values) { + foreach ($values as $value) { + $lines[] = "$name: $value"; + } + } $answer = ''; foreach ($client->stream($response) as $chunk) { $answer .= $chunk->getContent(); @@ -90,12 +96,6 @@ private function sendThroughClient(): ?Closure ); } } - $lines = []; - foreach ($response->getHeaders(false) as $name => $values) { - foreach ($values as $value) { - $lines[] = "$name: $value"; - } - } return [$response->getStatusCode(), $answer, $lines]; } catch (TransportExceptionInterface $error) { diff --git a/test/Mailer/PostHogTransportFactoryTest.php b/test/Mailer/PostHogTransportFactoryTest.php index cc3306aa..95a9d016 100644 --- a/test/Mailer/PostHogTransportFactoryTest.php +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -93,6 +93,26 @@ public function testAFailureOfTheAppsHttpClientIsANetworkErrorToRetry(): void } } + public function testABusyPostHogIsRetriedThroughTheAppsHttpClient(): void + { + $previousClock = Clock::get(); + Clock::set(new MockClock('2026-11-13 12:00:00')); + $this->posthog = new FakePostHog('phs_dsn'); + $busy = ['type' => 'server_error', 'code' => 'service_unavailable', 'detail' => 'Busy.', 'attr' => null]; + $this->posthog->sendReplies = [[503, json_encode($busy), ['Retry-After: 1']]]; + $transport = (new Transport([new PostHogTransportFactory(client: $this->posthog->httpClient())])) + ->fromString('posthog+api://phs_dsn@default'); + + try { + $transport->send(self::email()); + } finally { + Clock::set($previousClock); + } + + $this->assertCount(2, $this->posthog->sends()); + $this->assertCount(1, $this->posthog->queued); + } + public function testAnAnswerOverTheLimitStopsTheRead(): void { $flood = new MockHttpClient(new MockResponse(str_repeat('x', 1_048_577), ['http_code' => 202])); From d16e84029fe6314fec117140f7c992d334698da3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:29:20 +0000 Subject: [PATCH 21/30] fix(mailer): match the bridge on body part headers and content IDs 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: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 67 ++++++++++++++++--- .../PostHogTransportConformanceTest.php | 49 ++++++++++++++ 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index be3ed3d2..94be8a30 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -10,9 +10,11 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\IdentificationHeader; use Symfony\Component\Mime\Header\UnstructuredHeader; use Symfony\Component\Mime\Message; use Symfony\Component\Mime\Part\AbstractMultipartPart; +use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\TextPart; @@ -191,6 +193,12 @@ private static function refuseUnsupportedParts(Email $email, ?string $text, ?str $parts = [$custom]; while (($part = array_shift($parts)) !== null) { $type = "{$part->getMediaType()}/{$part->getMediaSubtype()}"; + if (self::hasOwnHeaders($part)) { + throw self::refusal( + 'unsupported_part', + "It can't carry the headers set on the body's $type part; remove them." + ); + } if ($part instanceof AbstractMultipartPart) { array_push($parts, ...$part->getParts()); } elseif ($part instanceof DataPart) { @@ -237,12 +245,37 @@ private static function isOwnBody(Email $email, TextPart $part, array $expected) }; $partCharset = $part->getPreparedHeaders()->getHeaderParameter('Content-Type', 'charset'); + $body = $part->getBody(); + return ($expected[$subtype] ?? null) !== null - && $part->getBody() === $expected[$subtype] + && ($body === $expected[$subtype] + || ($subtype === 'html' && $body === self::withContentIds($email, $expected['html']))) && strtolower((string) $partCharset) === strtolower((string) $charset) && in_array($part->getDisposition(), [null, 'inline'], true); } + private static function hasOwnHeaders(AbstractPart $part): bool + { + return array_diff($part->getHeaders()->getNames(), ['content-id']) !== []; + } + + /** + * The email's HTML with each `cid:` an inline part answers turned into `cid:`, as Symfony + * writes it when it builds the email's own body. + */ + private static function withContentIds(Email $email, string $html): string + { + $replacements = []; + foreach ($email->getAttachments() as $part) { + $name = $part->getName(); + if ($name !== null && $part->hasContentId() && self::refersTo($html, $name)) { + $replacements["cid:$name"] = 'cid:' . $part->getContentId(); + } + } + + return strtr($html, $replacements); + } + /** * @param array $variables * @return array @@ -352,7 +385,7 @@ private static function utf8(?string $body, ?string $charset, string $part): ?st */ private static function attachment(DataPart $part, ?string $html): array { - if (array_diff($part->getHeaders()->getNames(), ['content-id']) !== []) { + if (self::hasOwnHeaders($part)) { throw self::refusal( 'unsupported_part', "It can't carry the headers set on the attachment {$part->getFilename()}; remove them." @@ -376,30 +409,46 @@ private static function attachment(DataPart $part, ?string $html): array private static function contentId(DataPart $part, ?string $html): ?string { $name = $part->getName(); + $ownId = self::ownContentId($part); if ($name !== null && self::refersTo($html, $name)) { - $contentId = $part->hasContentId() ? $part->getContentId() : $name; - if ($contentId !== $name && self::refersTo($html, $contentId)) { + if ($ownId !== null && $ownId !== $name && self::refersTo($html, $ownId)) { throw self::refusal( 'unsupported_part', - "The HTML refers to one inline part as both cid:$name and cid:$contentId; use one." + "The HTML refers to one inline part as both cid:$name and cid:$ownId; use one." ); } return $name; } - if ($part->hasContentId() && self::refersTo($html, $part->getContentId())) { - return $part->getContentId(); + if ($ownId !== null && self::refersTo($html, $ownId)) { + return $ownId; } if ($part->getDisposition() !== 'inline') { return null; } - if (!$part->hasContentId()) { + if ($ownId === null) { throw self::refusal( 'inline_without_cid', 'An inline attachment needs a content ID, or a name the HTML refers to as cid:.' ); } - return $part->getContentId(); + return $ownId; + } + + /** + * The content ID the part was given, by setContentId() or by a Content-ID header of its own, or null. + */ + private static function ownContentId(DataPart $part): ?string + { + if ($part->hasContentId()) { + return $part->getContentId(); + } + $header = $part->getHeaders()->get('Content-ID'); + if ($header === null) { + return null; + } + + return $header instanceof IdentificationHeader ? $header->getId() : trim($header->getBodyAsString(), '<> '); } /** diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index c75bf74b..b10f9eec 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -339,6 +339,34 @@ public function testACustomBodyThatHoldsTheEmailsOwnPartsSends(): void $this->assertSame(['invoice.pdf'], array_column($this->body()['attachments'], 'filename')); } + public function testAnAttachmentWhoseContentIdIsSetAsAHeaderSendsThatId(): void + { + $logo = new DataPart(base64_decode('iVBORw0KGgo='), 'logo.png', 'image/png'); + $logo->getHeaders()->addIdHeader('Content-ID', 'logo@shop.example'); + $email = self::shipped()->html('')->addPart($logo); + + $this->transport()->send($email); + + $this->assertSame('logo@shop.example', $this->body()['attachments'][0]['content_id']); + } + + public function testTheEmailsOwnRenderedBodySetAsItsBodySends(): void + { + $email = self::shipped()->html('

On its way.

') + ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png'); + $email->setBody($email->getBody()); + if ($email->getAttachments()[0]->getName() !== 'logo') { + $this->markTestSkipped( + 'Symfony Mime before 6.4.37 and 7.4.9 renames the part when the app renders the email.' + ); + } + + $this->transport()->send($email); + + $this->assertSame('

On its way.

', $this->body()['html']); + $this->assertSame('logo', $this->body()['attachments'][0]['content_id']); + } + public function testABodyFromAStreamThatCannotRewindSendsInFull(): void { $html = self::streamThatCannotRewind('

On its way.

'); @@ -610,6 +638,27 @@ function () { )), 'unsupported_part', ], + 'a body part with a header of its own' => [ + function () { + $text = new TextPart('On its way.'); + $text->getHeaders()->addTextHeader('X-Note', 'kept by Symfony'); + return self::shipped()->setBody( + new AlternativePart($text, new TextPart('

On its way.

', 'utf-8', 'html')) + ); + }, + 'unsupported_part', + ], + 'a multipart body with a header of its own' => [ + function () { + $body = new AlternativePart( + new TextPart('On its way.'), + new TextPart('

On its way.

', 'utf-8', 'html') + ); + $body->getHeaders()->addTextHeader('X-Note', 'kept by Symfony'); + return self::shipped()->setBody($body); + }, + 'unsupported_part', + ], '13 a Return-Path other than the From address' => [ fn() => self::shipped()->sender('shop@example.com')->returnPath('bounce@example.com'), 'envelope_mismatch', From fe3456c13c26d21c1c1e39c6f880d2022f568d12 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:17:41 +0000 Subject: [PATCH 22/30] fix(mailer): match the bridge on Email-built HTML, unnamed files and 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. 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 --- lib/Internal/Mailer/EmailMapper.php | 46 ++++++++++++++----- .../PostHogTransportConformanceTest.php | 44 ++++++++++++++++++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 94be8a30..6f924cef 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -13,6 +13,7 @@ use Symfony\Component\Mime\Header\IdentificationHeader; use Symfony\Component\Mime\Header\UnstructuredHeader; use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Mime\Part\AbstractMultipartPart; use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\DataPart; @@ -130,7 +131,11 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void $envelopeRecipients = self::addresses($envelope->getRecipients()); sort($recipients); sort($envelopeRecipients); - $senders = array_filter([$envelope->getSender(), $email->getSender(), $email->getReturnPath()]); + $senders = [$envelope->getSender(), $email->getSender()]; + foreach ($email->getHeaders()->all('return-path') as $returnPath) { + $senders[] = $returnPath->getBody(); + } + $senders = array_filter($senders); foreach ($senders as $sender) { if ($from?->getAddress() !== $sender->getAddress()) { throw self::refusal( @@ -249,7 +254,7 @@ private static function isOwnBody(Email $email, TextPart $part, array $expected) return ($expected[$subtype] ?? null) !== null && ($body === $expected[$subtype] - || ($subtype === 'html' && $body === self::withContentIds($email, $expected['html']))) + || ($subtype === 'html' && $body === self::htmlAsEmailBuildsIt($email, $expected['html']))) && strtolower((string) $partCharset) === strtolower((string) $charset) && in_array($part->getDisposition(), [null, 'inline'], true); } @@ -260,20 +265,25 @@ private static function hasOwnHeaders(AbstractPart $part): bool } /** - * The email's HTML with each `cid:` an inline part answers turned into `cid:`, as Symfony - * writes it when it builds the email's own body. + * The email's HTML as Symfony writes it when it builds the email's own body, which turns some `cid:` + * references into `cid:`. A copy of the email builds it, so the app's attachments keep their names. */ - private static function withContentIds(Email $email, string $html): string + private static function htmlAsEmailBuildsIt(Email $email, string $html): ?string { - $replacements = []; + $copy = (new Email())->html($html, $email->getHtmlCharset() ?? 'utf-8'); foreach ($email->getAttachments() as $part) { - $name = $part->getName(); - if ($name !== null && $part->hasContentId() && self::refersTo($html, $name)) { - $replacements["cid:$name"] = 'cid:' . $part->getContentId(); + $copy->addPart(clone $part); + } + $parts = [$copy->getBody()]; + while (($part = array_shift($parts)) !== null) { + if ($part instanceof AbstractMultipartPart) { + array_push($parts, ...$part->getParts()); + } elseif ($part instanceof TextPart && !$part instanceof DataPart && $part->getMediaSubtype() === 'html') { + return $part->getBody(); } } - return strtr($html, $replacements); + return null; } /** @@ -385,13 +395,14 @@ private static function utf8(?string $body, ?string $charset, string $part): ?st */ private static function attachment(DataPart $part, ?string $html): array { + $filename = $part->getFilename() ?? $part->getName() ?? self::unnamed($part->getContentType()); if (self::hasOwnHeaders($part)) { throw self::refusal( 'unsupported_part', - "It can't carry the headers set on the attachment {$part->getFilename()}; remove them." + "It can't carry the headers set on the attachment $filename; remove them." ); } - $attachment = ['filename' => $part->getFilename() ?? $part->getName(), 'content' => $part->getBody()]; + $attachment = ['filename' => $filename, 'content' => $part->getBody()]; $attachment['content_type'] = $part->getContentType(); $contentId = self::contentId($part, $html); if ($contentId !== null) { @@ -401,6 +412,17 @@ private static function attachment(DataPart $part, ?string $html): array return $attachment; } + /** + * The filename PostHog needs for a part with neither a filename nor a name: `attachment.` for its + * type, or `attachment` when the type has no known extension. + */ + private static function unnamed(string $contentType): string + { + $extension = MimeTypes::getDefault()->getExtensions($contentType)[0] ?? null; + + return $extension === null ? 'attachment' : "attachment.$extension"; + } + /** * The content ID the HTML refers to, `cid:` before `cid:`, so a part sends inline under the * reference the HTML uses. Symfony gives a referenced part a generated content ID when it renders the body, but diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index b10f9eec..56267faa 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -367,6 +367,42 @@ public function testTheEmailsOwnRenderedBodySetAsItsBodySends(): void $this->assertSame('logo', $this->body()['attachments'][0]['content_id']); } + public function testABodyThatEmailBuiltKeepsTheReferencesItDoesNotRewrite(): void + { + $badge = (new DataPart(base64_decode('iVBORw0KGgo='), 'badge', 'image/png')) + ->setContentId('badge@shop.example')->asInline(); + $email = self::shipped()->html('
Paid
') + ->embed(base64_decode('iVBORw0KGgo='), 'logo', 'image/png') + ->addPart($badge); + $email->setBody($email->getBody()); + if ($email->getAttachments()[0]->getName() !== 'logo') { + $this->markTestSkipped( + 'Symfony Mime before 6.4.37 and 7.4.9 renames the part when the app renders the email.' + ); + } + + $this->transport()->send($email); + + $this->assertSame( + '
Paid
', + $this->body()['html'] + ); + $this->assertSame(['logo', 'badge'], array_column($this->body()['attachments'], 'content_id')); + } + + public function testAnAttachmentWithNoNameSendsAFilenameFromItsType(): void + { + $email = self::shipped()->attach(base64_decode('JVBERi0xLjQK'), null, 'application/pdf') + ->attach('bytes', null, 'application/x-posthog-unknown'); + + $this->transport()->send($email); + + $this->assertSame( + ['attachment.pdf', 'attachment'], + array_column($this->body()['attachments'], 'filename') + ); + } + public function testABodyFromAStreamThatCannotRewindSendsInFull(): void { $html = self::streamThatCannotRewind('

On its way.

'); @@ -663,6 +699,14 @@ function () { fn() => self::shipped()->sender('shop@example.com')->returnPath('bounce@example.com'), 'envelope_mismatch', ], + 'a second Return-Path other than the From address' => [ + function () { + $email = self::shipped()->returnPath('shop@example.com'); + $email->getHeaders()->addPathHeader('Return-Path', 'bounce@example.com'); + return $email; + }, + 'envelope_mismatch', + ], 'an HTML part when the email has no HTML body' => [ fn() => self::shipped()->html(null)->setBody(new AlternativePart( new TextPart('On its way.'), From 4081bc8e690c2dbae3ada49df1d7957b3a6c6f68 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:19:38 +0000 Subject: [PATCH 23/30] fix(mailer): keep the app client's retries and credentials off PostHog 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 --- lib/Mailer/PostHogTransportFactory.php | 54 +++++++++++++--- test/Mailer/PostHogTransportFactoryTest.php | 69 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/lib/Mailer/PostHogTransportFactory.php b/lib/Mailer/PostHogTransportFactory.php index 26a7837a..b021c3db 100644 --- a/lib/Mailer/PostHogTransportFactory.php +++ b/lib/Mailer/PostHogTransportFactory.php @@ -5,6 +5,7 @@ use Closure; use PostHog\EmailError; use PostHog\Emails; +use Symfony\Component\HttpClient\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; use Symfony\Component\Mailer\Transport\AbstractTransportFactory; use Symfony\Component\Mailer\Transport\Dsn; @@ -20,7 +21,8 @@ * without `X-PostHog-Template-Key`. Register this class with the `mailer.transport_factory` service tag. * * It sends through the app's `http_client` service when Symfony passes one, so the app's proxy, TLS and - * mock settings apply; without one, it sends with curl. + * mock settings apply; without one, it sends with curl. The SDK still owns retries and credentials: the + * client's own retries are off for these requests, and its default credentials never go out with them. * * Symfony's own PostHog bridge, once installed, handles the same scheme, so this factory steps aside. */ @@ -61,6 +63,7 @@ private function sendThroughClient(): ?Closure if ($client === null) { return null; } + $retriesOff = true; return static function ( string $method, @@ -69,15 +72,30 @@ private function sendThroughClient(): ?Closure ?string $body, int $timeoutMs, int $connectTimeoutMs - ) use ($client): array { + ) use ( + $client, + &$retriesOff + ): array { + $options = [ + 'headers' => self::withoutDefaultCredentials($headers), + 'auth_basic' => null, + 'auth_bearer' => null, + 'body' => $body ?? '', + 'timeout' => $connectTimeoutMs / 1000, + 'max_duration' => $timeoutMs / 1000, + 'max_redirects' => 0, + ]; try { - $response = $client->request($method, $url, [ - 'headers' => $headers, - 'body' => $body ?? '', - 'timeout' => $connectTimeoutMs / 1000, - 'max_duration' => $timeoutMs / 1000, - 'max_redirects' => 0, - ]); + try { + $response = $client->request($method, $url, $options + ($retriesOff ? ['max_retries' => 0] : [])); + } catch (InvalidArgumentException $error) { + if (!$retriesOff || !str_contains($error->getMessage(), '"max_retries"')) { + throw $error; + } + // Only RetryableHttpClient knows max_retries; a client without it has no retries to turn off. + $retriesOff = false; + $response = $client->request($method, $url, $options); + } $lines = []; foreach ($response->getHeaders(false) as $name => $values) { foreach ($values as $value) { @@ -108,6 +126,24 @@ private function sendThroughClient(): ?Closure }; } + /** + * The request's headers, with an empty Authorization when it has none of its own, so the client's default + * Authorization header stays off a storage upload. + * + * @param list $headers + * @return array> + */ + private static function withoutDefaultCredentials(array $headers): array + { + foreach ($headers as $header) { + if (stripos($header, 'authorization:') === 0) { + return $headers; + } + } + + return [...$headers, 'authorization' => []]; + } + private static function appHost(Dsn $dsn): string { $host = $dsn->getHost() === 'default' ? 'us.posthog.com' : $dsn->getHost(); diff --git a/test/Mailer/PostHogTransportFactoryTest.php b/test/Mailer/PostHogTransportFactoryTest.php index 95a9d016..031a4654 100644 --- a/test/Mailer/PostHogTransportFactoryTest.php +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -11,6 +11,9 @@ use Symfony\Component\Clock\Clock; use Symfony\Component\Clock\MockClock; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\NativeHttpClient; +use Symfony\Component\HttpClient\RetryableHttpClient; +use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; use Symfony\Component\Mailer\Transport; @@ -71,6 +74,72 @@ public function testAFileUploadsThroughTheAppsHttpClient(): void $this->assertCount(1, $this->posthog->queued); } + public static function appCredentials(): array + { + return [ + 'a bearer token' => [['auth_bearer' => 'app-token'], 'app-token'], + 'basic auth' => [['auth_basic' => 'app:secret'], base64_encode('app:secret')], + 'an Authorization header' => [['headers' => ['Authorization' => 'Token app-token']], 'app-token'], + ]; + } + + /** + * @param array $defaults + */ + #[DataProvider('appCredentials')] + public function testTheAppsHttpClientCredentialsStayOffEveryRequest(array $defaults, string $credential): void + { + $this->posthog = new FakePostHog('phs_dsn'); + $client = $this->posthog->httpClient()->withOptions($defaults); + $factory = new PostHogTransportFactory(client: $client); + + (new Transport([$factory]))->fromString('posthog+api://phs_dsn@default') + ->send(self::email()->attach("%PDF-1.4\n", 'invoice.pdf', 'application/pdf')); + + $this->assertCount(1, $this->posthog->queued); + foreach ($this->posthog->requests as $request) { + $this->assertStringNotContainsString($credential, implode("\n", $request['headers']), $request['url']); + } + } + + public function testTheAppsRetryingHttpClientLeavesRetriesToTheSdk(): void + { + $previousClock = Clock::get(); + Clock::set(new MockClock('2026-11-13 12:00:00')); + $busy = new MockHttpClient(static fn(): MockResponse => new MockResponse('', ['http_code' => 503])); + $client = new RetryableHttpClient($busy, new GenericRetryStrategy(delayMs: 1), 3); + $transport = (new Transport([new PostHogTransportFactory(client: $client)])) + ->fromString('posthog+api://phs_dsn@default'); + + try { + $transport->send(self::email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertTrue($error->isRetryable()); + $this->assertSame(3, $busy->getRequestsCount(), 'Only the SDK retries the send.'); + } finally { + Clock::set($previousClock); + } + } + + public function testAClientWithoutRetriesOfItsOwnSendsAsItIs(): void + { + $previousClock = Clock::get(); + Clock::set(new MockClock('2026-11-13 12:00:00')); + $transport = (new Transport([new PostHogTransportFactory(client: new NativeHttpClient())])) + ->fromString('posthog+api://phs_dsn@127.0.0.1:1'); + + try { + $transport->send(self::email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertSame('network_error', $error->getErrorCode()); + $this->assertStringNotContainsString('max_retries', $error->getMessage()); + } finally { + Clock::set($previousClock); + } + } + public function testAFailureOfTheAppsHttpClientIsANetworkErrorToRetry(): void { $previousClock = Clock::get(); From f1166c5131e5c0a20fcdb2d1776dc5808f22508f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:21:21 +0000 Subject: [PATCH 24/30] refactor(mailer): read X-Track with unencodedValue and put readOnce's docblock back Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 --- lib/Internal/Mailer/EmailMapper.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 6f924cef..7b226029 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -337,7 +337,7 @@ private static function tracking(Email $email, ?string $engagement): ?array } $flags = ['opens' => null, 'clicks' => null]; $header = $headers[0] ?? null; - $value = $header instanceof UnstructuredHeader ? $header->getValue() : (string) $header?->getBodyAsString(); + $value = $header === null ? '' : self::unencodedValue($header); foreach (explode(';', $value) as $part) { if (trim($part) === '') { continue; @@ -517,14 +517,14 @@ private static function mailbox(Address $address): string . " <{$address->getAddress()}>"; } - /** - * @param string|resource|null $body - */ private static function unencodedValue(HeaderInterface $header): string { return $header instanceof UnstructuredHeader ? $header->getValue() : $header->getBodyAsString(); } + /** + * @param string|resource|null $body + */ private static function readOnce(mixed $body): ?string { if (!is_resource($body)) { From 9c601d060cd9c2f465b44d99494944313c88fc2c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:27:18 +0000 Subject: [PATCH 25/30] fix(mailer): clear the app client's defaults through its decorators 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 --- lib/Mailer/PostHogTransportFactory.php | 60 ++++++++------------- test/Mailer/PostHogTransportFactoryTest.php | 51 +++++++++++++----- 2 files changed, 61 insertions(+), 50 deletions(-) diff --git a/lib/Mailer/PostHogTransportFactory.php b/lib/Mailer/PostHogTransportFactory.php index b021c3db..a57adb46 100644 --- a/lib/Mailer/PostHogTransportFactory.php +++ b/lib/Mailer/PostHogTransportFactory.php @@ -59,11 +59,10 @@ protected function getSupportedSchemes(): array private function sendThroughClient(): ?Closure { - $client = $this->client; - if ($client === null) { + if ($this->client === null) { return null; } - $retriesOff = true; + $client = self::forTheSdk($this->client); return static function ( string $method, @@ -72,30 +71,15 @@ private function sendThroughClient(): ?Closure ?string $body, int $timeoutMs, int $connectTimeoutMs - ) use ( - $client, - &$retriesOff - ): array { - $options = [ - 'headers' => self::withoutDefaultCredentials($headers), - 'auth_basic' => null, - 'auth_bearer' => null, - 'body' => $body ?? '', - 'timeout' => $connectTimeoutMs / 1000, - 'max_duration' => $timeoutMs / 1000, - 'max_redirects' => 0, - ]; + ) use ($client): array { try { - try { - $response = $client->request($method, $url, $options + ($retriesOff ? ['max_retries' => 0] : [])); - } catch (InvalidArgumentException $error) { - if (!$retriesOff || !str_contains($error->getMessage(), '"max_retries"')) { - throw $error; - } - // Only RetryableHttpClient knows max_retries; a client without it has no retries to turn off. - $retriesOff = false; - $response = $client->request($method, $url, $options); - } + $response = $client->request($method, $url, [ + 'headers' => $headers, + 'body' => $body ?? '', + 'timeout' => $connectTimeoutMs / 1000, + 'max_duration' => $timeoutMs / 1000, + 'max_redirects' => 0, + ]); $lines = []; foreach ($response->getHeaders(false) as $name => $values) { foreach ($values as $value) { @@ -127,21 +111,21 @@ private function sendThroughClient(): ?Closure } /** - * The request's headers, with an empty Authorization when it has none of its own, so the client's default - * Authorization header stays off a storage upload. - * - * @param list $headers - * @return array> + * The app's client without its default credentials or its own retries. The send and the storage upload carry + * their own credentials, and the SDK retries within its Retry-After cap. withOptions() reaches the client that + * holds the defaults through every Symfony decorator, such as ScopingHttpClient and TraceableHttpClient. */ - private static function withoutDefaultCredentials(array $headers): array + private static function forTheSdk(HttpClientInterface $client): HttpClientInterface { - foreach ($headers as $header) { - if (stripos($header, 'authorization:') === 0) { - return $headers; - } + $client = $client->withOptions( + ['headers' => ['authorization' => []], 'auth_basic' => null, 'auth_bearer' => null] + ); + try { + return $client->withOptions(['max_retries' => 0]); + } catch (InvalidArgumentException) { + // Only RetryableHttpClient knows max_retries; a client without it has no retries to turn off. + return $client; } - - return [...$headers, 'authorization' => []]; } private static function appHost(Dsn $dsn): string diff --git a/test/Mailer/PostHogTransportFactoryTest.php b/test/Mailer/PostHogTransportFactoryTest.php index 031a4654..81697522 100644 --- a/test/Mailer/PostHogTransportFactoryTest.php +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -2,6 +2,7 @@ namespace PostHog\Test\Mailer; +use Closure; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -13,6 +14,8 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Component\HttpClient\RetryableHttpClient; +use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; @@ -77,20 +80,38 @@ public function testAFileUploadsThroughTheAppsHttpClient(): void public static function appCredentials(): array { return [ - 'a bearer token' => [['auth_bearer' => 'app-token'], 'app-token'], - 'basic auth' => [['auth_basic' => 'app:secret'], base64_encode('app:secret')], - 'an Authorization header' => [['headers' => ['Authorization' => 'Token app-token']], 'app-token'], + 'a bearer token' => [ + static fn(MockHttpClient $posthog) => $posthog->withOptions(['auth_bearer' => 'app-token']), + 'app-token', + ], + 'basic auth' => [ + static fn(MockHttpClient $posthog) => $posthog->withOptions(['auth_basic' => 'app:secret']), + base64_encode('app:secret'), + ], + 'an Authorization header' => [ + static fn(MockHttpClient $posthog) => $posthog->withOptions( + ['headers' => ['Authorization' => 'Token app-token']] + ), + 'app-token', + ], + 'an Authorization header under a scope that matches every URL' => [ + static fn(MockHttpClient $posthog) => new ScopingHttpClient( + $posthog->withOptions(['headers' => ['Authorization' => 'Token app-token']]), + ['https://' => ['headers' => ['X-App' => 'shop']]] + ), + 'app-token', + ], ]; } /** - * @param array $defaults + * @param Closure(MockHttpClient): HttpClientInterface $appClient */ #[DataProvider('appCredentials')] - public function testTheAppsHttpClientCredentialsStayOffEveryRequest(array $defaults, string $credential): void + public function testTheAppsHttpClientCredentialsStayOffEveryRequest(Closure $appClient, string $credential): void { $this->posthog = new FakePostHog('phs_dsn'); - $client = $this->posthog->httpClient()->withOptions($defaults); + $client = $appClient($this->posthog->httpClient()); $factory = new PostHogTransportFactory(client: $client); (new Transport([$factory]))->fromString('posthog+api://phs_dsn@default') @@ -106,7 +127,11 @@ public function testTheAppsRetryingHttpClientLeavesRetriesToTheSdk(): void { $previousClock = Clock::get(); Clock::set(new MockClock('2026-11-13 12:00:00')); - $busy = new MockHttpClient(static fn(): MockResponse => new MockResponse('', ['http_code' => 503])); + $requests = 0; + $busy = new MockHttpClient(static function () use (&$requests): MockResponse { + $requests++; + return new MockResponse('', ['http_code' => 503]); + }); $client = new RetryableHttpClient($busy, new GenericRetryStrategy(delayMs: 1), 3); $transport = (new Transport([new PostHogTransportFactory(client: $client)])) ->fromString('posthog+api://phs_dsn@default'); @@ -116,7 +141,7 @@ public function testTheAppsRetryingHttpClientLeavesRetriesToTheSdk(): void $this->fail('The send did not fail.'); } catch (PostHogTransportException $error) { $this->assertTrue($error->isRetryable()); - $this->assertSame(3, $busy->getRequestsCount(), 'Only the SDK retries the send.'); + $this->assertSame(3, $requests, 'Only the SDK retries the send.'); } finally { Clock::set($previousClock); } @@ -144,9 +169,11 @@ public function testAFailureOfTheAppsHttpClientIsANetworkErrorToRetry(): void { $previousClock = Clock::get(); Clock::set(new MockClock('2026-11-13 12:00:00')); - $unreachable = new MockHttpClient( - static fn(): MockResponse => new MockResponse('', ['error' => 'Could not resolve host: us.posthog.com']) - ); + $requests = 0; + $unreachable = new MockHttpClient(static function () use (&$requests): MockResponse { + $requests++; + return new MockResponse('', ['error' => 'Could not resolve host: us.posthog.com']); + }); $transport = (new Transport([new PostHogTransportFactory(client: $unreachable)])) ->fromString('posthog+api://phs_dsn@default'); @@ -156,7 +183,7 @@ public function testAFailureOfTheAppsHttpClientIsANetworkErrorToRetry(): void } catch (PostHogTransportException $error) { $this->assertSame('network_error', $error->getErrorCode()); $this->assertTrue($error->isRetryable()); - $this->assertSame(3, $unreachable->getRequestsCount()); + $this->assertSame(3, $requests); } finally { Clock::set($previousClock); } From ec1bca30aa2bc167f4072c3694471edfb028e23a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:32:10 +0000 Subject: [PATCH 26/30] fix(mailer): keep a matching scope's credentials and retries off PostHog 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 --- lib/Mailer/PostHogTransportFactory.php | 46 ++++++++++++++++----- test/Mailer/PostHogTransportFactoryTest.php | 40 ++++++++++++++++++ 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/lib/Mailer/PostHogTransportFactory.php b/lib/Mailer/PostHogTransportFactory.php index a57adb46..b901922c 100644 --- a/lib/Mailer/PostHogTransportFactory.php +++ b/lib/Mailer/PostHogTransportFactory.php @@ -62,7 +62,7 @@ private function sendThroughClient(): ?Closure if ($this->client === null) { return null; } - $client = self::forTheSdk($this->client); + [$client, $retryOption] = self::forTheSdk($this->client); return static function ( string $method, @@ -71,15 +71,20 @@ private function sendThroughClient(): ?Closure ?string $body, int $timeoutMs, int $connectTimeoutMs - ) use ($client): array { + ) use ( + $client, + $retryOption + ): array { try { $response = $client->request($method, $url, [ - 'headers' => $headers, + 'headers' => self::withoutDefaultCredentials($headers), + 'auth_basic' => null, + 'auth_bearer' => null, 'body' => $body ?? '', 'timeout' => $connectTimeoutMs / 1000, 'max_duration' => $timeoutMs / 1000, 'max_redirects' => 0, - ]); + ] + $retryOption); $lines = []; foreach ($response->getHeaders(false) as $name => $values) { foreach ($values as $value) { @@ -111,23 +116,44 @@ private function sendThroughClient(): ?Closure } /** - * The app's client without its default credentials or its own retries. The send and the storage upload carry - * their own credentials, and the SDK retries within its Retry-After cap. withOptions() reaches the client that - * holds the defaults through every Symfony decorator, such as ScopingHttpClient and TraceableHttpClient. + * The app's client without its default credentials or its own retries, and the request option that keeps a + * matching ScopingHttpClient scope from setting them again. The send and the storage upload carry their own + * credentials, and the SDK retries within its Retry-After cap. withOptions() reaches the client that holds the + * defaults through every Symfony decorator; a request option wins over a scope's. + * + * @return array{HttpClientInterface, array} */ - private static function forTheSdk(HttpClientInterface $client): HttpClientInterface + private static function forTheSdk(HttpClientInterface $client): array { $client = $client->withOptions( ['headers' => ['authorization' => []], 'auth_basic' => null, 'auth_bearer' => null] ); try { - return $client->withOptions(['max_retries' => 0]); + return [$client->withOptions(['max_retries' => 0]), ['max_retries' => 0]]; } catch (InvalidArgumentException) { // Only RetryableHttpClient knows max_retries; a client without it has no retries to turn off. - return $client; + return [$client, []]; } } + /** + * The request's headers, with an empty Authorization when it has none of its own, so a scope's Authorization + * header stays off a storage upload. + * + * @param list $headers + * @return array> + */ + private static function withoutDefaultCredentials(array $headers): array + { + foreach ($headers as $header) { + if (stripos($header, 'authorization:') === 0) { + return $headers; + } + } + + return [...$headers, 'authorization' => []]; + } + private static function appHost(Dsn $dsn): string { $host = $dsn->getHost() === 'default' ? 'us.posthog.com' : $dsn->getHost(); diff --git a/test/Mailer/PostHogTransportFactoryTest.php b/test/Mailer/PostHogTransportFactoryTest.php index 81697522..70b71494 100644 --- a/test/Mailer/PostHogTransportFactoryTest.php +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -101,6 +101,20 @@ public static function appCredentials(): array ), 'app-token', ], + 'a bearer token on a scope that matches every URL' => [ + static fn(MockHttpClient $posthog) => new ScopingHttpClient( + $posthog, + ['https://' => ['auth_bearer' => 'app-token']] + ), + 'app-token', + ], + 'an Authorization header on a scope that matches every URL' => [ + static fn(MockHttpClient $posthog) => new ScopingHttpClient( + $posthog, + ['https://' => ['headers' => ['Authorization' => 'Token app-token']]] + ), + 'app-token', + ], ]; } @@ -147,6 +161,32 @@ public function testTheAppsRetryingHttpClientLeavesRetriesToTheSdk(): void } } + public function testAScopeThatSetsRetriesStillLeavesThemToTheSdk(): void + { + $previousClock = Clock::get(); + Clock::set(new MockClock('2026-11-13 12:00:00')); + $requests = 0; + $busy = new MockHttpClient(static function () use (&$requests): MockResponse { + $requests++; + return new MockResponse('', ['http_code' => 503]); + }); + $client = new ScopingHttpClient( + new RetryableHttpClient($busy, new GenericRetryStrategy(delayMs: 1), 3), + ['https://' => ['max_retries' => 3]] + ); + $transport = (new Transport([new PostHogTransportFactory(client: $client)])) + ->fromString('posthog+api://phs_dsn@default'); + + try { + $transport->send(self::email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertSame(3, $requests, 'Only the SDK retries the send.'); + } finally { + Clock::set($previousClock); + } + } + public function testAClientWithoutRetriesOfItsOwnSendsAsItIs(): void { $previousClock = Clock::get(); From a0f38cef4b54bb0f165e86dcb383ed3a6980ba6b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:33:56 +0000 Subject: [PATCH 27/30] fix(mailer): refuse a Message whose conversion to an Email would change 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 --- lib/Internal/Mailer/EmailMapper.php | 19 +++++---- lib/Mailer/PostHogTransport.php | 3 +- .../PostHogTransportConformanceTest.php | 41 +++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 7b226029..14bdc278 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -13,6 +13,7 @@ use Symfony\Component\Mime\Header\IdentificationHeader; use Symfony\Component\Mime\Header\UnstructuredHeader; use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\MessageConverter; use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Mime\Part\AbstractMultipartPart; use Symfony\Component\Mime\Part\AbstractPart; @@ -49,8 +50,9 @@ public function __construct( /** * @return array The options of Emails::send(). */ - public function map(Email $email, Envelope $envelope): array + public function map(Message $message, Envelope $envelope): array { + $email = MessageConverter::toEmail($message); $to = $this->onlyRecipient($email); $options = $this->headerOptions($email); $this->refuseEnvelopeMismatch($email, $envelope); @@ -59,7 +61,8 @@ public function map(Email $email, Envelope $envelope): array $sensitive = self::flag('X-PostHog-Sensitive', $options[self::SENSITIVE] ?? null); $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; $send = $template === null - ? $this->renderedContent($email, $templateKey) + ['tracking' => $tracking, 'sensitive' => $sensitive] + ? $this->renderedContent($email, $message, $templateKey) + + ['tracking' => $tracking, 'sensitive' => $sensitive] : $this->templateContent($email, $template->getReference(), $template->getVariables()); return array_filter( @@ -155,11 +158,11 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void /** * @return array */ - private function renderedContent(Email $email, string $templateKey): array + private function renderedContent(Email $email, Message $message, string $templateKey): array { $html = self::readOnce($email->getHtmlBody()); $text = self::readOnce($email->getTextBody()); - self::refuseUnsupportedParts($email, $text, $html); + self::refuseUnsupportedParts($email, $message, $text, $html); $html = self::utf8($html, $email->getHtmlCharset(), 'HTML'); $text = self::utf8($text, $email->getTextCharset(), 'text'); @@ -181,15 +184,17 @@ private function renderedContent(Email $email, string $templateKey): array /** * The send carries the email's text, HTML and attachments, so a body set with setBody() must hold exactly those: - * one text and one HTML part for the email's own text and HTML, and each of the email's own attachments. + * one text and one HTML part for the email's own text and HTML, and each of the email's own attachments. The + * same holds for the body of a Message that isn't an Email, which MessageConverter rebuilds without the parts' + * own headers and dispositions. * * A body Symfony generates holds nothing else, so only a custom body is walked. Message::getBody() returns it * without building the generated body. Building it reads a stream body, and older Symfony Mime releases, such * as 6.4.0, rename each part the HTML refers to after its generated content ID. */ - private static function refuseUnsupportedParts(Email $email, ?string $text, ?string $html): void + private static function refuseUnsupportedParts(Email $email, Message $message, ?string $text, ?string $html): void { - $custom = (new ReflectionMethod(Message::class, 'getBody'))->invoke($email); + $custom = (new ReflectionMethod(Message::class, 'getBody'))->invoke($message); $expected = ['plain' => $text, 'html' => $html]; if ($custom === null) { return; diff --git a/lib/Mailer/PostHogTransport.php b/lib/Mailer/PostHogTransport.php index 481e1871..3dc2b686 100644 --- a/lib/Mailer/PostHogTransport.php +++ b/lib/Mailer/PostHogTransport.php @@ -13,7 +13,6 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractTransport; -use Symfony\Component\Mime\MessageConverter; /** * Sends Symfony Mailer emails through PostHog's transactional email API, one send per email. @@ -78,7 +77,7 @@ public function __toString(): string protected function doSend(SentMessage $message): void { - $send = $this->mapper->map(MessageConverter::toEmail($message->getOriginalMessage()), $message->getEnvelope()); + $send = $this->mapper->map($message->getOriginalMessage(), $message->getEnvelope()); try { $result = $this->emails->send($send); } catch (EmailError $error) { diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 56267faa..3df7cf8a 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -15,6 +15,7 @@ use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Message; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\Multipart\AlternativePart; use Symfony\Component\Mime\Part\Multipart\MixedPart; @@ -731,6 +732,46 @@ public function testAnEmailPostHogCannotCarryIsRefusedBeforeAnyRequest(Closure $ $this->assertRefused($code, fn() => $this->transport()->send($email())); } + public function testAMessageThatIsNotAnEmailSendsItsTextAndHtml(): void + { + $body = new AlternativePart(new TextPart('On its way.'), new TextPart('

On its way.

', 'utf-8', 'html')); + + $this->transport()->send(new Message(clone self::shipped()->getHeaders(), $body)); + + $this->assertSame('

On its way.

', $this->body()['html']); + $this->assertSame('On its way.', $this->body()['text']); + } + + public static function messagesConversionWouldChange(): array + { + $text = new TextPart('On its way.'); + $text->getHeaders()->addTextHeader('Content-Language', 'de'); + $attached = (new TextPart('

On its way.

', 'utf-8', 'html'))->setDisposition('attachment'); + $body = new AlternativePart(new TextPart('On its way.'), new TextPart('

On its way.

', 'utf-8', 'html')); + $body->getHeaders()->addTextHeader('X-Note', 'kept by Symfony'); + + return [ + 'a text part with a header of its own' => [ + fn() => new Message(clone self::shipped()->getHeaders(), $text), + ], + 'an HTML part sent as an attachment' => [ + fn() => new Message(clone self::shipped()->getHeaders(), $attached), + ], + 'a multipart body with a header of its own' => [ + fn() => new Message(clone self::shipped()->getHeaders(), $body), + ], + ]; + } + + /** + * @param Closure(): Message $message + */ + #[DataProvider('messagesConversionWouldChange')] + public function testAMessageThatConversionToAnEmailWouldChangeIsRefused(Closure $message): void + { + $this->assertRefused('unsupported_part', fn() => $this->transport()->send($message())); + } + public function testCase13AnEnvelopeThatDiffersFromTheHeadersIsRefused(): void { $envelope = new Envelope(new Address('bounce@example.com'), [new Address('ana@example.com')]); From d8ed3a54196baa8272bbfa0f80b7ae936dbc8119 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:38:41 +0000 Subject: [PATCH 28/30] fix(mailer): check a converted Message without reading its body again 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 --- lib/Internal/Mailer/EmailMapper.php | 48 +++++++++++++++---- .../PostHogTransportConformanceTest.php | 13 +++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 14bdc278..1fba59da 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -52,6 +52,9 @@ public function __construct( */ public function map(Message $message, Envelope $envelope): array { + if (!$message instanceof Email) { + self::refuseWhatConversionDrops($message); + } $email = MessageConverter::toEmail($message); $to = $this->onlyRecipient($email); $options = $this->headerOptions($email); @@ -61,8 +64,7 @@ public function map(Message $message, Envelope $envelope): array $sensitive = self::flag('X-PostHog-Sensitive', $options[self::SENSITIVE] ?? null); $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; $send = $template === null - ? $this->renderedContent($email, $message, $templateKey) - + ['tracking' => $tracking, 'sensitive' => $sensitive] + ? $this->renderedContent($email, $templateKey) + ['tracking' => $tracking, 'sensitive' => $sensitive] : $this->templateContent($email, $template->getReference(), $template->getVariables()); return array_filter( @@ -158,11 +160,11 @@ private function refuseEnvelopeMismatch(Email $email, Envelope $envelope): void /** * @return array */ - private function renderedContent(Email $email, Message $message, string $templateKey): array + private function renderedContent(Email $email, string $templateKey): array { $html = self::readOnce($email->getHtmlBody()); $text = self::readOnce($email->getTextBody()); - self::refuseUnsupportedParts($email, $message, $text, $html); + self::refuseUnsupportedParts($email, $text, $html); $html = self::utf8($html, $email->getHtmlCharset(), 'HTML'); $text = self::utf8($text, $email->getTextCharset(), 'text'); @@ -184,17 +186,15 @@ private function renderedContent(Email $email, Message $message, string $templat /** * The send carries the email's text, HTML and attachments, so a body set with setBody() must hold exactly those: - * one text and one HTML part for the email's own text and HTML, and each of the email's own attachments. The - * same holds for the body of a Message that isn't an Email, which MessageConverter rebuilds without the parts' - * own headers and dispositions. + * one text and one HTML part for the email's own text and HTML, and each of the email's own attachments. * * A body Symfony generates holds nothing else, so only a custom body is walked. Message::getBody() returns it * without building the generated body. Building it reads a stream body, and older Symfony Mime releases, such * as 6.4.0, rename each part the HTML refers to after its generated content ID. */ - private static function refuseUnsupportedParts(Email $email, Message $message, ?string $text, ?string $html): void + private static function refuseUnsupportedParts(Email $email, ?string $text, ?string $html): void { - $custom = (new ReflectionMethod(Message::class, 'getBody'))->invoke($message); + $custom = (new ReflectionMethod(Message::class, 'getBody'))->invoke($email); $expected = ['plain' => $text, 'html' => $html]; if ($custom === null) { return; @@ -239,6 +239,36 @@ private static function refuseUnsupportedParts(Email $email, Message $message, ? } } + /** + * MessageConverter turns a Message that isn't an Email into one with the same text, HTML and attachments, but + * it rebuilds the text and HTML without their parts' own headers and dispositions, and drops the headers of + * the multipart parts. It already holds a stream body it read, so this walk reads no body again. + */ + private static function refuseWhatConversionDrops(Message $message): void + { + $parts = [$message->getBody()]; + while (($part = array_shift($parts)) !== null) { + if ($part instanceof DataPart) { + continue; + } + $type = "{$part->getMediaType()}/{$part->getMediaSubtype()}"; + if (self::hasOwnHeaders($part)) { + throw self::refusal( + 'unsupported_part', + "It can't carry the headers set on the body's $type part; remove them." + ); + } + if ($part instanceof AbstractMultipartPart) { + array_push($parts, ...$part->getParts()); + } elseif ($part instanceof TextPart && !in_array($part->getDisposition(), [null, 'inline'], true)) { + throw self::refusal( + 'unsupported_part', + "It can't carry the $type part of the body as an attachment; the text and HTML go out as the body." + ); + } + } + } + /** * Whether a custom body part is the email's own text or HTML, as Symfony would build it: the same content in the * same charset, shown as the body rather than attached. diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index 3df7cf8a..da1442d2 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -742,6 +742,19 @@ public function testAMessageThatIsNotAnEmailSendsItsTextAndHtml(): void $this->assertSame('On its way.', $this->body()['text']); } + public function testAMessageWithBodiesFromStreamsThatCannotRewindSends(): void + { + $body = new AlternativePart( + new TextPart(self::streamThatCannotRewind('On its way.')), + new TextPart(self::streamThatCannotRewind('

On its way.

'), 'utf-8', 'html') + ); + + $this->transport()->send(new Message(clone self::shipped()->getHeaders(), $body)); + + $this->assertSame('

On its way.

', $this->body()['html']); + $this->assertSame('On its way.', $this->body()['text']); + } + public static function messagesConversionWouldChange(): array { $text = new TextPart('On its way.'); From 1fe29756af462747f71e5f278e63a80e2a77f2b4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:42:09 +0000 Subject: [PATCH 29/30] fix(mailer): refuse a Message attachment that conversion would turn into 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 --- lib/Internal/Mailer/EmailMapper.php | 22 ++++++++++------ .../PostHogTransportConformanceTest.php | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 1fba59da..7e5ab48c 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -18,6 +18,7 @@ use Symfony\Component\Mime\Part\AbstractMultipartPart; use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\AlternativePart; use Symfony\Component\Mime\Part\TextPart; /** @@ -240,17 +241,17 @@ private static function refuseUnsupportedParts(Email $email, ?string $text, ?str } /** - * MessageConverter turns a Message that isn't an Email into one with the same text, HTML and attachments, but - * it rebuilds the text and HTML without their parts' own headers and dispositions, and drops the headers of - * the multipart parts. It already holds a stream body it read, so this walk reads no body again. + * MessageConverter turns a Message that isn't an Email into one with the same text, HTML and attachments. It + * takes the text and HTML from the body, the first part of a mixed or related part, and both parts of an + * alternative part, and rebuilds them without their own headers and dispositions; it keeps every later part + * of a mixed or related part as an attachment. A DataPart is a TextPart, so one in a text or HTML position + * would lose its filename and go out as the body. This walk reads no body, since the converter already read + * a stream body that may not rewind. */ private static function refuseWhatConversionDrops(Message $message): void { $parts = [$message->getBody()]; while (($part = array_shift($parts)) !== null) { - if ($part instanceof DataPart) { - continue; - } $type = "{$part->getMediaType()}/{$part->getMediaSubtype()}"; if (self::hasOwnHeaders($part)) { throw self::refusal( @@ -258,9 +259,14 @@ private static function refuseWhatConversionDrops(Message $message): void "It can't carry the headers set on the body's $type part; remove them." ); } - if ($part instanceof AbstractMultipartPart) { + if ($part instanceof AlternativePart) { array_push($parts, ...$part->getParts()); - } elseif ($part instanceof TextPart && !in_array($part->getDisposition(), [null, 'inline'], true)) { + } elseif ($part instanceof AbstractMultipartPart) { + array_push($parts, ...array_slice($part->getParts(), 0, 1)); + } elseif ( + $part instanceof TextPart + && ($part instanceof DataPart || !in_array($part->getDisposition(), [null, 'inline'], true)) + ) { throw self::refusal( 'unsupported_part', "It can't carry the $type part of the body as an attachment; the text and HTML go out as the body." diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index da1442d2..d7f46f9e 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -755,6 +755,19 @@ public function testAMessageWithBodiesFromStreamsThatCannotRewindSends(): void $this->assertSame('On its way.', $this->body()['text']); } + public function testAMessageWithAnAttachmentAfterItsBodySendsTheAttachment(): void + { + $body = new MixedPart( + new AlternativePart(new TextPart('On its way.'), new TextPart('

On its way.

', 'utf-8', 'html')), + new DataPart(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf') + ); + + $this->transport()->send(new Message(clone self::shipped()->getHeaders(), $body)); + + $this->assertSame('

On its way.

', $this->body()['html']); + $this->assertSame(['invoice.pdf'], array_column($this->body()['attachments'], 'filename')); + } + public static function messagesConversionWouldChange(): array { $text = new TextPart('On its way.'); @@ -773,6 +786,18 @@ public static function messagesConversionWouldChange(): array 'a multipart body with a header of its own' => [ fn() => new Message(clone self::shipped()->getHeaders(), $body), ], + 'an attachment as the body' => [ + fn() => new Message( + clone self::shipped()->getHeaders(), + new DataPart('Invoice 981', 'invoice.txt', 'text/plain') + ), + ], + 'an attachment as the first part of a mixed body' => [ + fn() => new Message(clone self::shipped()->getHeaders(), new MixedPart( + new DataPart('Invoice 981', 'invoice.txt', 'text/plain'), + new DataPart(base64_decode('JVBERi0xLjQK'), 'invoice.pdf', 'application/pdf') + )), + ], ]; } From d11b9d0962aa6bd291a95a2612856d05182db7a0 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:01:54 +0000 Subject: [PATCH 30/30] fix(mailer): stop reading attachments that would make the email too large 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 --- lib/Internal/Mailer/EmailMapper.php | 59 +++++++++++++++++-- .../PostHogTransportConformanceTest.php | 36 +++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/lib/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php index 7e5ab48c..99a8182f 100644 --- a/lib/Internal/Mailer/EmailMapper.php +++ b/lib/Internal/Mailer/EmailMapper.php @@ -40,6 +40,8 @@ final class EmailMapper private const TRACKING_FLAGS = ['true' => true, 'false' => false, 'default' => null]; private const NORMAL_PRIORITY = 3; public const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,256}$/D'; + /** The SDK's limit on an email after Base64 encoding; the SDK makes the exact check. */ + private const MAX_MESSAGE_BYTES = 40_000_000; public function __construct( private readonly string $integration, @@ -168,6 +170,11 @@ private function renderedContent(Email $email, string $templateKey): array self::refuseUnsupportedParts($email, $text, $html); $html = self::utf8($html, $email->getHtmlCharset(), 'HTML'); $text = self::utf8($text, $email->getTextCharset(), 'text'); + $attachments = []; + $base64Bytes = 0; + foreach ($email->getAttachments() as $part) { + $attachments[] = self::attachment($part, $html, $base64Bytes); + } return [ 'template_key' => $templateKey, @@ -178,10 +185,7 @@ private function renderedContent(Email $email, string $templateKey): array 'reply_to' => self::addresses($email->getReplyTo()), 'cc' => self::addresses($email->getCc()), 'bcc' => self::addresses($email->getBcc()), - 'attachments' => array_map( - static fn(DataPart $part): array => self::attachment($part, $html), - $email->getAttachments() - ), + 'attachments' => $attachments, ]; } @@ -434,7 +438,7 @@ private static function utf8(?string $body, ?string $charset, string $part): ?st /** * @return array */ - private static function attachment(DataPart $part, ?string $html): array + private static function attachment(DataPart $part, ?string $html, int &$base64Bytes): array { $filename = $part->getFilename() ?? $part->getName() ?? self::unnamed($part->getContentType()); if (self::hasOwnHeaders($part)) { @@ -443,7 +447,7 @@ private static function attachment(DataPart $part, ?string $html): array "It can't carry the headers set on the attachment $filename; remove them." ); } - $attachment = ['filename' => $filename, 'content' => $part->getBody()]; + $attachment = ['filename' => $filename, 'content' => self::content($part, $filename, $base64Bytes)]; $attachment['content_type'] = $part->getContentType(); $contentId = self::contentId($part, $html); if ($contentId !== null) { @@ -453,6 +457,49 @@ private static function attachment(DataPart $part, ?string $html): array return $attachment; } + /** + * The part's bytes, read in chunks, so a file too large for the email stops the read before it fills PHP's + * memory. $base64Bytes counts the files read so far, after Base64 encoding, as the SDK's limit does. Only the + * base64 and 8bit encodings stream; Symfony reads a quoted-printable body whole. + */ + private static function content(DataPart $part, string $filename, int &$base64Bytes): string + { + $encoding = $part->getPreparedHeaders()->getHeaderBody('Content-Transfer-Encoding'); + if ($encoding !== 'base64' && $encoding !== '8bit') { + $content = $part->getBody(); + $base64Bytes += self::base64Bytes($content); + return $content; + } + $content = ''; + $encoded = ''; + foreach ($part->bodyToIterable() as $chunk) { + if ($encoding === '8bit') { + $content .= $chunk; + } else { + $encoded .= str_replace(["\r", "\n"], '', $chunk); + $whole = strlen($encoded) - strlen($encoded) % 4; + $content .= base64_decode(substr($encoded, 0, $whole)); + $encoded = substr($encoded, $whole); + } + if ($base64Bytes + self::base64Bytes($content) > self::MAX_MESSAGE_BYTES) { + throw self::refusal( + 'message_too_large', + "The files, up to $filename, make the email larger than 40 MB after Base64 encoding, about 29 MB " + . 'of files; send the larger files as links.' + ); + } + } + $content .= base64_decode($encoded); + $base64Bytes += self::base64Bytes($content); + + return $content; + } + + private static function base64Bytes(string $content): int + { + return 4 * intdiv(strlen($content) + 2, 3); + } + /** * The filename PostHog needs for a part with neither a filename nor a name: `attachment.` for its * type, or `attachment` when the type has no known extension. diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php index d7f46f9e..94023fa4 100644 --- a/test/Mailer/PostHogTransportConformanceTest.php +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -5,6 +5,8 @@ use Closure; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use PostHog\Mailer\PostHogTransport; use PostHog\Mailer\PostHogTransportException; @@ -404,6 +406,40 @@ public function testAnAttachmentWithNoNameSendsAFilenameFromItsType(): void ); } + public function testAFileFromAPathSendsItsBytes(): void + { + $path = tempnam(sys_get_temp_dir(), 'posthog-mailer-'); + file_put_contents($path, random_bytes(70_000)); + + try { + $this->transport()->send(self::shipped()->attachFromPath($path, 'report.bin', 'application/octet-stream')); + + $this->assertSame([hash_file('sha256', $path) => file_get_contents($path)], $this->posthog->stored); + } finally { + unlink($path); + } + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAFileTooLargeForTheEmailIsRefusedBeforeItFillsMemory(): void + { + $path = tempnam(sys_get_temp_dir(), 'posthog-mailer-'); + $file = fopen($path, 'w'); + ftruncate($file, 200_000_000); + fclose($file); + $limit = ini_set('memory_limit', '128M'); + + try { + $this->assertRefused('message_too_large', fn() => $this->transport()->send( + self::shipped()->attachFromPath($path, 'export.csv', 'text/csv') + )); + } finally { + ini_set('memory_limit', (string) $limit); + unlink($path); + } + } + public function testABodyFromAStreamThatCannotRewindSendsInFull(): void { $html = self::streamThatCannotRewind('

On its way.

');