diff --git a/.changeset/symfony-mailer-transport.md b/.changeset/symfony-mailer-transport.md new file mode 100644 index 00000000..f16cdd27 --- /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, 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 c115f396..5ff8be70 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -117,6 +117,58 @@ 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 + # 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.*" + 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 }} + 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/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. + - 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 + coverage: runs-on: ubuntu-latest needs: diff --git a/api/public-api.json b/api/public-api.json index 689550f8..9c96694a 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,204 @@ } } }, + "PostHog\\Mailer\\PostHogTransport": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": "Symfony\\Component\\Mailer\\Transport\\AbstractTransport", + "implements": [ + "PostHog\\Internal\\Mailer\\RemoteTemplateSupport", + "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": { + "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 6d5f948b..c40500ab 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,13 @@ "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/http-client": "^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 f3d13f01..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": "bee35757e908de67add72579d422ee47", + "content-hash": "71512986a47bad59ce29c69da465d890", "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,2200 @@ "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-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", + "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 97be9071..3371feda 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -126,6 +126,27 @@ 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 or the file storage throttled the request, the service was + * unavailable, or the file storage failed to take an attachment. The client already retried these up + * to its retry limit. + */ + public function isRetryable(): bool + { + if ($this->errorCode === 'attachment_upload_failed' && $this->statusCode !== null) { + return in_array($this->statusCode, [429, 500, 502, 503, 504], true); + } + + return match ($this->statusCode) { + null => $this->errorCode === 'network_error', + 409 => $this->errorCode === 'idempotency_key_in_flight', + 429 => !in_array($this->errorCode, ['quota_limited', 'upload_quota_exceeded'], true), + 503 => true, + default => false, + }; + } + private static function nameFor(?int $status, string $code): string { if ($code === 'attachment_upload_failed') { diff --git a/lib/Emails.php b/lib/Emails.php index e87bd8bc..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; @@ -1015,28 +1010,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 ($error->getErrorCode() === 'attachment_upload_failed') { - return in_array($status, [429, 500, 502, 503, 504], true); - } - if ($status === 409) { - return $error->getErrorCode() === 'idempotency_key_in_flight'; - } - if ($status === 429) { - return !in_array($error->getErrorCode(), ['quota_limited', 'upload_quota_exceeded'], true); - } - - 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/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/Internal/Mailer/EmailMapper.php b/lib/Internal/Mailer/EmailMapper.php new file mode 100644 index 00000000..99a8182f --- /dev/null +++ b/lib/Internal/Mailer/EmailMapper.php @@ -0,0 +1,632 @@ + 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, + private readonly ?string $defaultTemplateKey, + private readonly bool $templates, + ) { + } + + /** + * @return array The options of Emails::send(). + */ + 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); + $this->refuseEnvelopeMismatch($email, $envelope); + $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); + $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; + $send = $template === null + ? $this->renderedContent($email, $templateKey) + ['tracking' => $tracking, 'sensitive' => $sensitive] + : $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)) { + if (isset($options[$name])) { + throw self::refusal('unsupported_header', "It takes one {$header->getName()} header."); + } + $options[$name] = self::unencodedValue($header); + 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) + || $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); + $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( + '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.' + ); + } + } + + /** + * @return array + */ + private function renderedContent(Email $email, string $templateKey): array + { + $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'); + $attachments = []; + $base64Bytes = 0; + foreach ($email->getAttachments() as $part) { + $attachments[] = self::attachment($part, $html, $base64Bytes); + } + + return [ + 'template_key' => $templateKey, + 'subject' => $email->getSubject(), + 'html' => $html, + 'text' => $text, + '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' => $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 + * 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 + { + $custom = (new ReflectionMethod(Message::class, 'getBody'))->invoke($email); + $expected = ['plain' => $text, 'html' => $html]; + if ($custom === null) { + return; + } + $attachments = $email->getAttachments(); + $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) { + $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 && self::isOwnBody($email, $part, $expected)) { + $expected[$part->getMediaSubtype()] = null; + } else { + throw self::refusal( + 'unsupported_part', + "It can't carry the $type part of the body: a body part must hold the email's own text or HTML." + ); + } + } + $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.' + ); + } + } + + /** + * 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) { + $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 AlternativePart) { + array_push($parts, ...$part->getParts()); + } 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." + ); + } + } + } + + /** + * 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'); + + $body = $part->getBody(); + + return ($expected[$subtype] ?? null) !== null + && ($body === $expected[$subtype] + || ($subtype === 'html' && $body === self::htmlAsEmailBuildsIt($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 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 htmlAsEmailBuildsIt(Email $email, string $html): ?string + { + $copy = (new Email())->html($html, $email->getHtmlCharset() ?? 'utf-8'); + foreach ($email->getAttachments() as $part) { + $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 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 256.' + ); + } + + return $explicit; + } + + /** + * 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 + { + $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.'); + } + $flags = ['opens' => null, 'clicks' => null]; + $header = $headers[0] ?? null; + $value = $header === null ? '' : self::unencodedValue($header); + 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', "$header must be \"true\" or \"false\"."), + }; + } + + /** + * 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 ($body === null || $charset === null || strcasecmp($charset, 'utf-8') === 0) { + return $body; + } + 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 $converted; + } + + /** + * @return 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)) { + throw self::refusal( + 'unsupported_part', + "It can't carry the headers set on the attachment $filename; remove them." + ); + } + $attachment = ['filename' => $filename, 'content' => self::content($part, $filename, $base64Bytes)]; + $attachment['content_type'] = $part->getContentType(); + $contentId = self::contentId($part, $html); + if ($contentId !== null) { + $attachment['content_id'] = $contentId; + } + + 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. + */ + 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 + * 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 + { + $name = $part->getName(); + $ownId = self::ownContentId($part); + if ($name !== null && self::refersTo($html, $name)) { + 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:$ownId; use one." + ); + } + return $name; + } + if ($ownId !== null && self::refersTo($html, $ownId)) { + return $ownId; + } + if ($part->getDisposition() !== 'inline') { + return null; + } + 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 $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(), '<> '); + } + + /** + * 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 + { + 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; + } + + /** + * @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)); + } + + /** + * 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 + { + $name = $address->getName(); + if ($name === '') { + return $address->getAddress(); + } + + return (strpbrk($name, '()<>[]:;@\\,."') === false ? $name : $address->getEncodedName()) + . " <{$address->getAddress()}>"; + } + + 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)) { + 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/RemoteTemplateSupport.php b/lib/Internal/Mailer/RemoteTemplateSupport.php new file mode 100644 index 00000000..1284845d --- /dev/null +++ b/lib/Internal/Mailer/RemoteTemplateSupport.php @@ -0,0 +1,24 @@ +` 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` (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 +{ + 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`, 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( + private readonly Emails $emails, + ?string $integration = null, + ?string $defaultTemplateKey = null, + bool $templates = true, + ?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.' + ); + } + 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($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..b901922c --- /dev/null +++ b/lib/Mailer/PostHogTransportFactory.php @@ -0,0 +1,164 @@ +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( + $emails, + 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 function sendThroughClient(): ?Closure + { + if ($this->client === null) { + return null; + } + [$client, $retryOption] = self::forTheSdk($this->client); + + return static function ( + string $method, + string $url, + array $headers, + ?string $body, + int $timeoutMs, + int $connectTimeoutMs + ) use ( + $client, + $retryOption + ): array { + try { + $response = $client->request($method, $url, [ + '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) { + $lines[] = "$name: $value"; + } + } + $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." + ); + } + } + + return [$response->getStatusCode(), $answer, $lines]; + } catch (TransportExceptionInterface $error) { + throw new EmailError( + 'network_error', + 'The request to PostHog failed: ' . $error->getMessage(), + previous: $error + ); + } + }; + } + + /** + * 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): array + { + $client = $client->withOptions( + ['headers' => ['authorization' => []], 'auth_basic' => null, 'auth_bearer' => null] + ); + try { + 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, []]; + } + } + + /** + * 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(); + $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/FakePostHog.php b/test/Mailer/FakePostHog.php new file mode 100644 index 00000000..22698a87 --- /dev/null +++ b/test/Mailer/FakePostHog.php @@ -0,0 +1,212 @@ +, body: ?string}> */ + 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 */ + 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 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 array_shift($this->uploadReplies) ?? $this->negotiate((string) $body); + } + + 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. + * + * @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); + 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"); + } + } + $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/MailerKernel.php b/test/Mailer/MailerKernel.php new file mode 100644 index 00000000..c8928444 --- /dev/null +++ b/test/Mailer/MailerKernel.php @@ -0,0 +1,46 @@ +extension('framework', [ + 'secret' => 'test', + '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(RecordedRequests::class); + $services->set(PostHogTransportFactory::class) + ->parent('mailer.transport_factory.abstract') + ->tag('mailer.transport_factory'); + } +} diff --git a/test/Mailer/PostHogTransportConformanceTest.php b/test/Mailer/PostHogTransportConformanceTest.php new file mode 100644 index 00000000..94023fa4 --- /dev/null +++ b/test/Mailer/PostHogTransportConformanceTest.php @@ -0,0 +1,1017 @@ + "%PDF-1.4\n", + '4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6' => "\x89PNG\r\n\x1a\n", + ]; + private const RENDERED = [ + 'to' => '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' => [ + self::file('invoice.pdf', 'application/pdf', 'JVBERi0xLjQK'), + self::logo('logo.png', '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', + 'tracking' => ['engagement' => false], + ], + ['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' => [self::logo('logo', 'logo')], + ], + [], + ], + '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')], + ], + [], + ], + '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')) + ->asInline()->setContentId('logo@shop.example') + ), + self::RENDERED + [ + 'template_key' => 'posthog-php-mailer', + 'attachments' => [self::logo('logo.png', 'logo@shop.example')], + ], + [], + ], + '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', + 'tracking' => ['opens' => true, 'clicks' => true, 'engagement' => false], + ], + [], + ], + '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)], + [], + ], + ]; + } + + /** + * @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->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') + ); + foreach ($headers as $name => $value) { + $this->assertSame($value, $this->header($name)); + } + $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.'); + 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('', $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 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 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'); + $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 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 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 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.

'); + $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(), [ + 'X-PostHog-Distinct-Id' => 'user_42', + '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( + 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 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()); + + $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 [ + '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='), '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', + ], + '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', + ], + '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 a second X-Track' => [ + fn() => self::withHeaders( + self::withHeaders(self::shipped(), ['X-Track' => 'opens=true']), + ['X-Track' => 'opens=false'] + ), + 'unsupported_header', + ], + '14 an X-Track flag that is not true, false or default' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=0']), + 'unsupported_header', + ], + '14 an X-Track flag in capitals' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens=FALSE']), + 'unsupported_header', + ], + '14 an X-Track flag name it does not know' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'open=false']), + 'unsupported_header', + ], + '14 an X-Track part without a value' => [ + fn() => self::withHeaders(self::shipped(), ['X-Track' => 'opens']), + 'unsupported_header', + ], + '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( + 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', + ], + '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', + ], + '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', + ], + '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', + ], + '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.'), + new TextPart('

On its way.

', 'utf-8', 'html') + )), + 'unsupported_part', + ], + '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 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 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 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.'); + $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), + ], + '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') + )), + ], + ]; + } + + /** + * @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')]); + + $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(); + $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 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-Sensitive' => 'true', + 'X-Track' => 'opens=false; clicks=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', + ], + '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'], + ]; + } + + /** + * @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(); + $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()); + } + + /** + * 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]); + } + + /** + * @return array + */ + private static function logo(string $filename, string $contentId): array + { + return self::file($filename, 'image/png', 'iVBORw0KGgo=', $contentId); + } + + /** + * @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..541e9129 --- /dev/null +++ b/test/Mailer/PostHogTransportErrorsTest.php @@ -0,0 +1,200 @@ +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 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 { + $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..70b71494 --- /dev/null +++ b/test/Mailer/PostHogTransportFactoryTest.php @@ -0,0 +1,289 @@ +posthog = new FakePostHog('phs_dsn'); + $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 + { + 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 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 static function appCredentials(): array + { + return [ + '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', + ], + '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', + ], + ]; + } + + /** + * @param Closure(MockHttpClient): HttpClientInterface $appClient + */ + #[DataProvider('appCredentials')] + public function testTheAppsHttpClientCredentialsStayOffEveryRequest(Closure $appClient, string $credential): void + { + $this->posthog = new FakePostHog('phs_dsn'); + $client = $appClient($this->posthog->httpClient()); + $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')); + $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'); + + try { + $transport->send(self::email()); + $this->fail('The send did not fail.'); + } catch (PostHogTransportException $error) { + $this->assertTrue($error->isRetryable()); + $this->assertSame(3, $requests, 'Only the SDK retries the send.'); + } finally { + Clock::set($previousClock); + } + } + + 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(); + 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(); + Clock::set(new MockClock('2026-11-13 12:00:00')); + $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'); + + 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, $requests); + } finally { + Clock::set($previousClock); + } + } + + 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])); + $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(); + + $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..b8fd4e66 --- /dev/null +++ b/test/Mailer/RecordedRequests.php @@ -0,0 +1,25 @@ +, body: string}> */ + public static array $requests = []; + + /** + * @param array{headers: list, body?: string} $options + */ + public function __invoke(string $method, string $url, array $options): MockResponse + { + self::$requests[] = ['method' => $method, 'url' => $url, 'headers' => $options['headers'], + 'body' => $options['body'] ?? '']; + + return new MockResponse('{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}', ['http_code' => 202]); + } +} diff --git a/test/Mailer/SendsThroughStubbedHttp.php b/test/Mailer/SendsThroughStubbedHttp.php new file mode 100644 index 00000000..07db71f5 --- /dev/null +++ b/test/Mailer/SendsThroughStubbedHttp.php @@ -0,0 +1,66 @@ +emails(), defaultTemplateKey: $defaultTemplateKey, templates: $templates); + } + + private function emails(): Emails + { + return new Emails( + FakePostHog::SECRET_KEY, + 'us.i.posthog.com', + transport: Closure::fromCallable($this->posthog()) + ); + } + + private function posthog(): FakePostHog + { + return $this->posthog ??= new FakePostHog(); + } + + /** @param array{int, string, list}|EmailError ...$replies The send route's next answers. */ + private function reply(...$replies): void + { + $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 $this->posthog()->sends()[$index]; + } + + private function header(string $name, int $index = 0): ?string + { + return $this->posthog()->sendHeader($name, $index); + } +} 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']); + } +}