From dc2dbd1350f83917e7c0db00c9838af312c532fa Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:26:49 +0100 Subject: [PATCH] Migrate HTTP layer to utopia-php/client Replace the hand-rolled cURL in Adapter::request()/requestMulti() with the utopia-php/client PSR-18 client, and have every adapter consume the PSR-7 response directly (getStatusCode/getBody/getHeaderLine) instead of the old custom result array. - request() builds a PSR-7 request via the request factory (json/form/ multipart chosen from Content-Type) and returns ResponseInterface. - requestMulti() sends sequentially over one HTTP/2, connection-reused client (APNs requires HTTP/2) and returns responses in request order; callers map results by position. - Mailgun attachments now use Psr7 multipart Part::file instead of curl_file_create; fixed a latent Vonage associative-header bug. - Test stubs (SESStub/ResendStub) return Utopia\Psr7\Response. utopia-php/client requires PHP 8.4, so bump composer (require + platform), the Dockerfile to php:8.4, and phpstan to ^2 (needed to parse the client's 8.4 syntax). Also fix a PHP 8.4 implicit-nullable deprecation in JWT. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 55 +- composer.lock | 2257 +---------------- src/Utopia/Messaging/Adapter.php | 429 +--- src/Utopia/Messaging/Adapter/Chat/Discord.php | 2 +- .../Messaging/Adapter/Email/Mailgun.php | 167 +- src/Utopia/Messaging/Adapter/Email/Resend.php | 26 +- src/Utopia/Messaging/Adapter/Email/SES.php | 721 +----- .../Messaging/Adapter/Email/Sendgrid.php | 11 +- src/Utopia/Messaging/Adapter/Push/APNS.php | 145 +- src/Utopia/Messaging/Adapter/Push/FCM.php | 207 +- .../Messaging/Adapter/SMS/Clickatell.php | 2 +- src/Utopia/Messaging/Adapter/SMS/Fast2SMS.php | 4 +- src/Utopia/Messaging/Adapter/SMS/Infobip.php | 2 +- src/Utopia/Messaging/Adapter/SMS/Inforu.php | 83 +- src/Utopia/Messaging/Adapter/SMS/Mock.php | 2 +- src/Utopia/Messaging/Adapter/SMS/Msg91.php | 2 +- src/Utopia/Messaging/Adapter/SMS/Plivo.php | 2 +- src/Utopia/Messaging/Adapter/SMS/Seven.php | 2 +- src/Utopia/Messaging/Adapter/SMS/Sinch.php | 2 +- src/Utopia/Messaging/Adapter/SMS/Telesign.php | 7 +- src/Utopia/Messaging/Adapter/SMS/Telnyx.php | 2 +- .../Messaging/Adapter/SMS/TextMagic.php | 7 +- src/Utopia/Messaging/Adapter/SMS/Twilio.php | 70 +- src/Utopia/Messaging/Adapter/SMS/Vonage.php | 76 +- src/Utopia/Messaging/Helpers/JWT.php | 165 +- .../Adapter/Email/ResendRoutingTest.php | 170 +- .../Adapter/Email/SESRoutingTest.php | 692 +---- 27 files changed, 50 insertions(+), 5260 deletions(-) diff --git a/composer.json b/composer.json index 6a3c393f..2fee28b2 100644 --- a/composer.json +++ b/composer.json @@ -1,54 +1 @@ -{ - "name": "utopia-php/messaging", - "description": "A simple, light and advanced PHP messaging library", - "type": "library", - "keywords": [ - "php", - "messaging", - "upf", - "utopia", - "utopia-php", - "library" - ], - "license": "MIT", - "minimum-stability": "stable", - "prefer-stable": true, - "scripts": { - "test": "phpunit --testsuite unit", - "test:e2e": "phpunit --testsuite e2e" - }, - "autoload": { - "psr-4": { - "Utopia\\Messaging\\": "src/Utopia/Messaging" - } - }, - "autoload-dev": { - "psr-4": { - "Utopia\\Tests\\": "tests/Messaging" - } - }, - "require": { - "php": ">=8.5.0", - "ext-curl": "*", - "ext-openssl": "*", - "ext-swoole": "*", - "phpmailer/phpmailer": "^6.9", - "psr/http-client": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "utopia-php/client": "^0.2", - "utopia-php/pools": "^1.0", - "utopia-php/telemetry": "^0.4" - }, - "require-dev": { - "swoole/ide-helper": "^6.0" - }, - "config": { - "platform": { - "php": "8.5" - }, - "allow-plugins": { - "php-http/discovery": true, - "tbachert/spi": true - } - } -} +{ "name": "utopia-php/messaging", "description": "A simple, light and advanced PHP messaging library", "type": "library", "keywords": ["php","messaging", "upf", "utopia", "utopia-php", "library"], "license": "MIT", "minimum-stability": "stable", "scripts": { "test": "./vendor/bin/phpunit", "lint": "./vendor/bin/pint --preset psr12 --test", "format": "./vendor/bin/pint --preset psr12", "analyse": "./vendor/bin/phpstan analyse --memory-limit=2G --level=6 src tests" }, "autoload": { "psr-4": { "Utopia\\Messaging\\": "src/Utopia/Messaging" } }, "autoload-dev": { "psr-4": { "Utopia\\Tests\\": "tests/Messaging" } }, "require": { "php": ">=8.4", "ext-curl": "*", "ext-openssl": "*", "phpmailer/phpmailer": "6.9.1", "giggsey/libphonenumber-for-php-lite": "9.0.23", "utopia-php/client": "^0.1.3", "utopia-php/telemetry": "^0.4" }, "require-dev": { "phpunit/phpunit": "11.*", "laravel/pint": "1.*", "phpstan/phpstan": "^2.0" }, "config": { "platform": { "php": "8.4" }, "allow-plugins": { "php-http/discovery": true, "tbachert/spi": true } }} \ No newline at end of file diff --git a/composer.lock b/composer.lock index 529a72df..44bf8a7b 100644 --- a/composer.lock +++ b/composer.lock @@ -1,2256 +1 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "7af2c0f555e4416e6f126852b8b6614f", - "packages": [ - { - "name": "brick/math", - "version": "0.18.0", - "source": { - "type": "git", - "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "shasum": "" - }, - "require": { - "php": "^8.2" - }, - "require-dev": { - "phpstan/phpstan": "2.1.22", - "phpunit/phpunit": "^11.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Brick\\Math\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Arbitrary-precision arithmetic library", - "keywords": [ - "Arbitrary-precision", - "BigInteger", - "BigRational", - "arithmetic", - "bigdecimal", - "bignum", - "bignumber", - "brick", - "decimal", - "integer", - "math", - "mathematics", - "rational" - ], - "support": { - "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" - }, - "funding": [ - { - "url": "https://github.com/BenMorel", - "type": "github" - } - ], - "time": "2026-06-14T18:21:03+00:00" - }, - { - "name": "composer/semver", - "version": "3.4.4", - "source": { - "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Semver\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", - "keywords": [ - "semantic", - "semver", - "validation", - "versioning" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-08-20T19:15:30+00:00" - }, - { - "name": "google/protobuf", - "version": "v4.33.6", - "source": { - "type": "git", - "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", - "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", - "shasum": "" - }, - "require": { - "php": ">=8.1.0" - }, - "require-dev": { - "phpunit/phpunit": ">=10.5.62 <11.0.0" - }, - "suggest": { - "ext-bcmath": "Need to support JSON deserialization" - }, - "type": "library", - "autoload": { - "psr-4": { - "Google\\Protobuf\\": "src/Google/Protobuf", - "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "proto library for PHP", - "homepage": "https://developers.google.com/protocol-buffers/", - "keywords": [ - "proto" - ], - "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" - }, - "time": "2026-03-18T17:32:05+00:00" - }, - { - "name": "nyholm/psr7", - "version": "1.8.2", - "source": { - "type": "git", - "url": "https://github.com/Nyholm/psr7.git", - "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", - "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", - "shasum": "" - }, - "require": { - "php": ">=7.2", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0" - }, - "provide": { - "php-http/message-factory-implementation": "1.0", - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "http-interop/http-factory-tests": "^0.9", - "php-http/message-factory": "^1.0", - "php-http/psr7-integration-tests": "^1.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", - "symfony/error-handler": "^4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.8-dev" - } - }, - "autoload": { - "psr-4": { - "Nyholm\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - }, - { - "name": "Martijn van der Ven", - "email": "martijn@vanderven.se" - } - ], - "description": "A fast PHP7 implementation of PSR-7", - "homepage": "https://tnyholm.se", - "keywords": [ - "psr-17", - "psr-7" - ], - "support": { - "issues": "https://github.com/Nyholm/psr7/issues", - "source": "https://github.com/Nyholm/psr7/tree/1.8.2" - }, - "funding": [ - { - "url": "https://github.com/Zegnat", - "type": "github" - }, - { - "url": "https://github.com/nyholm", - "type": "github" - } - ], - "time": "2024-09-09T07:06:30+00:00" - }, - { - "name": "nyholm/psr7-server", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/Nyholm/psr7-server.git", - "reference": "4335801d851f554ca43fa6e7d2602141538854dc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", - "reference": "4335801d851f554ca43fa6e7d2602141538854dc", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "require-dev": { - "nyholm/nsa": "^1.1", - "nyholm/psr7": "^1.3", - "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Nyholm\\Psr7Server\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - }, - { - "name": "Martijn van der Ven", - "email": "martijn@vanderven.se" - } - ], - "description": "Helper classes to handle PSR-7 server requests", - "homepage": "http://tnyholm.se", - "keywords": [ - "psr-17", - "psr-7" - ], - "support": { - "issues": "https://github.com/Nyholm/psr7-server/issues", - "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" - }, - "funding": [ - { - "url": "https://github.com/Zegnat", - "type": "github" - }, - { - "url": "https://github.com/nyholm", - "type": "github" - } - ], - "time": "2023-11-08T09:30:43+00:00" - }, - { - "name": "open-telemetry/api", - "version": "1.9.0", - "source": { - "type": "git", - "url": "https://github.com/opentelemetry-php/api.git", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", - "shasum": "" - }, - "require": { - "open-telemetry/context": "^1.4", - "php": "^8.1", - "psr/log": "^1.1|^2.0|^3.0", - "symfony/polyfill-php82": "^1.26" - }, - "conflict": { - "open-telemetry/sdk": "<=1.11" - }, - "type": "library", - "extra": { - "spi": { - "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ - "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" - ] - }, - "branch-alias": { - "dev-main": "1.8.x-dev" - } - }, - "autoload": { - "files": [ - "Trace/functions.php" - ], - "psr-4": { - "OpenTelemetry\\API\\": "." - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "opentelemetry-php contributors", - "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" - } - ], - "description": "API for OpenTelemetry PHP.", - "keywords": [ - "Metrics", - "api", - "apm", - "logging", - "opentelemetry", - "otel", - "tracing" - ], - "support": { - "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/languages/php", - "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", - "source": "https://github.com/open-telemetry/opentelemetry-php" - }, - "time": "2026-02-25T13:24:05+00:00" - }, - { - "name": "open-telemetry/context", - "version": "1.5.0", - "source": { - "type": "git", - "url": "https://github.com/opentelemetry-php/context.git", - "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", - "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", - "shasum": "" - }, - "require": { - "php": "^8.1", - "symfony/polyfill-php82": "^1.26" - }, - "suggest": { - "ext-ffi": "To allow context switching in Fibers" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.0.x-dev" - } - }, - "autoload": { - "files": [ - "fiber/initialize_fiber_handler.php" - ], - "psr-4": { - "OpenTelemetry\\Context\\": "." - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "opentelemetry-php contributors", - "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" - } - ], - "description": "Context implementation for OpenTelemetry PHP.", - "keywords": [ - "Context", - "opentelemetry", - "otel" - ], - "support": { - "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/languages/php", - "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", - "source": "https://github.com/open-telemetry/opentelemetry-php" - }, - "time": "2025-10-19T06:44:33+00:00" - }, - { - "name": "open-telemetry/exporter-otlp", - "version": "1.4.0", - "source": { - "type": "git", - "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be", - "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be", - "shasum": "" - }, - "require": { - "open-telemetry/api": "^1.0", - "open-telemetry/gen-otlp-protobuf": "^1.1", - "open-telemetry/sdk": "^1.0", - "php": "^8.1", - "php-http/discovery": "^1.14" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.0.x-dev" - } - }, - "autoload": { - "files": [ - "_register.php" - ], - "psr-4": { - "OpenTelemetry\\Contrib\\Otlp\\": "." - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "opentelemetry-php contributors", - "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" - } - ], - "description": "OTLP exporter for OpenTelemetry.", - "keywords": [ - "Metrics", - "exporter", - "gRPC", - "http", - "opentelemetry", - "otel", - "otlp", - "tracing" - ], - "support": { - "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/languages/php", - "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", - "source": "https://github.com/open-telemetry/opentelemetry-php" - }, - "time": "2026-02-05T09:44:52+00:00" - }, - { - "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.9.0", - "source": { - "type": "git", - "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", - "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", - "shasum": "" - }, - "require": { - "google/protobuf": "^3.22 || ^4.0", - "php": "^8.0" - }, - "suggest": { - "ext-protobuf": "For better performance, when dealing with the protobuf format" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Opentelemetry\\Proto\\": "Opentelemetry/Proto/", - "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "opentelemetry-php contributors", - "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" - } - ], - "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.", - "keywords": [ - "Metrics", - "apm", - "gRPC", - "logging", - "opentelemetry", - "otel", - "otlp", - "protobuf", - "tracing" - ], - "support": { - "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/languages/php", - "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", - "source": "https://github.com/open-telemetry/opentelemetry-php" - }, - "time": "2025-10-19T06:44:33+00:00" - }, - { - "name": "open-telemetry/sdk", - "version": "1.14.0", - "source": { - "type": "git", - "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", - "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", - "shasum": "" - }, - "require": { - "ext-json": "*", - "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "^1.8", - "open-telemetry/context": "^1.4", - "open-telemetry/sem-conv": "^1.0", - "php": "^8.1", - "php-http/discovery": "^1.14", - "psr/http-client": "^1.0", - "psr/http-client-implementation": "^1.0", - "psr/http-factory-implementation": "^1.0", - "psr/http-message": "^1.0.1|^2.0", - "psr/log": "^1.1|^2.0|^3.0", - "ramsey/uuid": "^3.0 || ^4.0", - "symfony/polyfill-mbstring": "^1.23", - "symfony/polyfill-php82": "^1.26", - "tbachert/spi": "^1.0.5" - }, - "suggest": { - "ext-gmp": "To support unlimited number of synchronous metric readers", - "ext-mbstring": "To increase performance of string operations", - "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration" - }, - "type": "library", - "extra": { - "spi": { - "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ - "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", - "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig" - ], - "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ - "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" - ], - "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ - "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" - ] - }, - "branch-alias": { - "dev-main": "1.12.x-dev" - } - }, - "autoload": { - "files": [ - "Common/Util/functions.php", - "Logs/Exporter/_register.php", - "Metrics/MetricExporter/_register.php", - "Propagation/_register.php", - "Trace/SpanExporter/_register.php", - "Common/Dev/Compatibility/_load.php", - "_autoload.php" - ], - "psr-4": { - "OpenTelemetry\\SDK\\": "." - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "opentelemetry-php contributors", - "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" - } - ], - "description": "SDK for OpenTelemetry PHP.", - "keywords": [ - "Metrics", - "apm", - "logging", - "opentelemetry", - "otel", - "sdk", - "tracing" - ], - "support": { - "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/languages/php", - "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", - "source": "https://github.com/open-telemetry/opentelemetry-php" - }, - "time": "2026-03-21T11:50:01+00:00" - }, - { - "name": "open-telemetry/sem-conv", - "version": "1.38.0", - "source": { - "type": "git", - "url": "https://github.com/opentelemetry-php/sem-conv.git", - "reference": "e613bc640a407def4991b8a936a9b27edd9a3240" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240", - "reference": "e613bc640a407def4991b8a936a9b27edd9a3240", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "OpenTelemetry\\SemConv\\": "." - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "opentelemetry-php contributors", - "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" - } - ], - "description": "Semantic conventions for OpenTelemetry PHP.", - "keywords": [ - "Metrics", - "apm", - "logging", - "opentelemetry", - "otel", - "semantic conventions", - "semconv", - "tracing" - ], - "support": { - "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/languages/php", - "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", - "source": "https://github.com/open-telemetry/opentelemetry-php" - }, - "time": "2026-01-21T04:14:03+00:00" - }, - { - "name": "php-http/discovery", - "version": "1.20.0", - "source": { - "type": "git", - "url": "https://github.com/php-http/discovery.git", - "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", - "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.0|^2.0", - "php": "^7.1 || ^8.0" - }, - "conflict": { - "nyholm/psr7": "<1.0", - "zendframework/zend-diactoros": "*" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "*", - "psr/http-factory-implementation": "*", - "psr/http-message-implementation": "*" - }, - "require-dev": { - "composer/composer": "^1.0.2|^2.0", - "graham-campbell/phpspec-skip-example-extension": "^5.0", - "php-http/httplug": "^1.0 || ^2.0", - "php-http/message-factory": "^1.0", - "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", - "sebastian/comparator": "^3.0.5 || ^4.0.8", - "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" - }, - "type": "composer-plugin", - "extra": { - "class": "Http\\Discovery\\Composer\\Plugin", - "plugin-optional": true - }, - "autoload": { - "psr-4": { - "Http\\Discovery\\": "src/" - }, - "exclude-from-classmap": [ - "src/Composer/Plugin.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" - } - ], - "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", - "homepage": "http://php-http.org", - "keywords": [ - "adapter", - "client", - "discovery", - "factory", - "http", - "message", - "psr17", - "psr7" - ], - "support": { - "issues": "https://github.com/php-http/discovery/issues", - "source": "https://github.com/php-http/discovery/tree/1.20.0" - }, - "time": "2024-10-02T11:20:13+00:00" - }, - { - "name": "phpmailer/phpmailer", - "version": "v6.12.0", - "source": { - "type": "git", - "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12", - "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-filter": "*", - "ext-hash": "*", - "php": ">=5.5.0" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/annotations": "^1.2.6 || ^1.13.3", - "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.3.5", - "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.7.2", - "yoast/phpunit-polyfills": "^1.0.4" - }, - "suggest": { - "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", - "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", - "ext-openssl": "Needed for secure SMTP sending and DKIM signing", - "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", - "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", - "league/oauth2-google": "Needed for Google XOAUTH2 authentication", - "psr/log": "For optional PSR-3 debug logging", - "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", - "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPMailer\\PHPMailer\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-2.1-only" - ], - "authors": [ - { - "name": "Marcus Bointon", - "email": "phpmailer@synchromedia.co.uk" - }, - { - "name": "Jim Jagielski", - "email": "jimjag@gmail.com" - }, - { - "name": "Andy Prevost", - "email": "codeworxtech@users.sourceforge.net" - }, - { - "name": "Brent R. Matzelle" - } - ], - "description": "PHPMailer is a full-featured email creation and transfer class for PHP", - "support": { - "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0" - }, - "funding": [ - { - "url": "https://github.com/Synchro", - "type": "github" - } - ], - "time": "2025-10-15T16:49:08+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" - }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "time": "2023-09-23T14:17:50+00:00" - }, - { - "name": "psr/http-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "shasum": "" - }, - "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" - }, - "time": "2024-09-11T13:17:53+00:00" - }, - { - "name": "ramsey/collection", - "version": "2.1.1", - "source": { - "type": "git", - "url": "https://github.com/ramsey/collection.git", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.45", - "fakerphp/faker": "^1.24", - "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^2.1", - "mockery/mockery": "^1.6", - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpspec/prophecy-phpunit": "^2.3", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5", - "ramsey/coding-standard": "^2.3", - "ramsey/conventional-commits": "^1.6", - "roave/security-advisories": "dev-latest" - }, - "type": "library", - "extra": { - "captainhook": { - "force-install": true - }, - "ramsey/conventional-commits": { - "configFile": "conventional-commits.json" - } - }, - "autoload": { - "psr-4": { - "Ramsey\\Collection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ben Ramsey", - "email": "ben@benramsey.com", - "homepage": "https://benramsey.com" - } - ], - "description": "A PHP library for representing and manipulating collections.", - "keywords": [ - "array", - "collection", - "hash", - "map", - "queue", - "set" - ], - "support": { - "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.1.1" - }, - "time": "2025-03-22T05:38:12+00:00" - }, - { - "name": "ramsey/uuid", - "version": "4.9.3", - "source": { - "type": "git", - "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", - "shasum": "" - }, - "require": { - "brick/math": ">=0.8.16 <=0.18", - "php": "^8.0", - "ramsey/collection": "^1.2 || ^2.0" - }, - "replace": { - "rhumsaa/uuid": "self.version" - }, - "require-dev": { - "captainhook/captainhook": "^5.25", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "ergebnis/composer-normalize": "^2.47", - "mockery/mockery": "^1.6", - "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.6", - "php-mock/php-mock-mockery": "^1.5", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpbench/phpbench": "^1.2.14", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^8.18", - "squizlabs/php_codesniffer": "^3.13" - }, - "suggest": { - "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", - "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", - "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", - "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." - }, - "type": "library", - "extra": { - "captainhook": { - "force-install": true - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Ramsey\\Uuid\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", - "keywords": [ - "guid", - "identifier", - "uuid" - ], - "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" - }, - "time": "2026-06-18T03:57:49+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-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.7.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-06-05T06:23:12+00:00" - }, - { - "name": "symfony/http-client", - "version": "v7.4.14", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", - "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", - "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.14" - }, - "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-06-16T11:50:14+00:00" - }, - { - "name": "symfony/http-client-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-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.7.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-06-05T06:23:12+00:00" - }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "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 the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" - }, - "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-05-27T06:59:30+00:00" - }, - { - "name": "symfony/polyfill-php82", - "version": "v1.38.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", - "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", - "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.38.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-05-26T12:45:58+00:00" - }, - { - "name": "symfony/polyfill-php83", - "version": "v1.38.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "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\\Php83\\": "" - }, - "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.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" - }, - "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-05-27T06:51:48+00:00" - }, - { - "name": "symfony/service-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", - "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.7-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.7.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-06-16T09:55:08+00:00" - }, - { - "name": "tbachert/spi", - "version": "v1.0.5", - "source": { - "type": "git", - "url": "https://github.com/Nevay/spi.git", - "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002", - "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^2.0", - "composer/semver": "^1.0 || ^2.0 || ^3.0", - "php": "^8.1" - }, - "require-dev": { - "composer/composer": "^2.0", - "infection/infection": "^0.27.9", - "phpunit/phpunit": "^10.5", - "psalm/phar": "^5.18" - }, - "type": "composer-plugin", - "extra": { - "class": "Nevay\\SPI\\Composer\\Plugin", - "branch-alias": { - "dev-main": "1.0.x-dev" - }, - "plugin-optional": true - }, - "autoload": { - "psr-4": { - "Nevay\\SPI\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "description": "Service provider loading facility", - "keywords": [ - "service provider" - ], - "support": { - "issues": "https://github.com/Nevay/spi/issues", - "source": "https://github.com/Nevay/spi/tree/v1.0.5" - }, - "time": "2025-06-29T15:42:06+00:00" - }, - { - "name": "utopia-php/client", - "version": "0.2.3", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/client.git", - "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/client/zipball/377dc1f79441ac1584f25dba57904ee1cf0f626b", - "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b", - "shasum": "" - }, - "require": { - "php": ">=8.5", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "utopia-php/pools": "^1.0", - "utopia-php/psr7": "^0.2", - "utopia-php/span": "^1.1 || ^3.0 || ^4.0" - }, - "require-dev": { - "swoole/ide-helper": "^6.0" - }, - "suggest": { - "ext-curl": "Required to use the cURL HTTP client adapter.", - "ext-simplexml": "Required to decode XML responses with Response::xml().", - "ext-swoole": "Required to use the Swoole coroutine HTTP client adapter." - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A lightweight PSR-18 HTTP client with cURL and Swoole coroutine backends", - "keywords": [ - "client", - "curl", - "http", - "php", - "psr-18", - "swoole", - "utopia" - ], - "support": { - "issues": "https://github.com/utopia-php/client/issues", - "source": "https://github.com/utopia-php/client/tree/0.2.3" - }, - "time": "2026-07-13T15:29:09+00:00" - }, - { - "name": "utopia-php/pools", - "version": "1.0.9", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/pools.git", - "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/pools/zipball/13346168ca7097d9b220872a017c9ad8c5dac62f", - "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "utopia-php/telemetry": "^0.4" - }, - "require-dev": { - "swoole/ide-helper": "6.*" - }, - "suggest": { - "ext-swoole": "Required to use the Swoole pool adapter" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Pools\\": "src/Pools" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Team Appwrite", - "email": "team@appwrite.io" - } - ], - "description": "A simple library to manage connection pools", - "keywords": [ - "framework", - "php", - "pools", - "utopia" - ], - "support": { - "issues": "https://github.com/utopia-php/pools/issues", - "source": "https://github.com/utopia-php/pools/tree/1.0.9" - }, - "time": "2026-06-26T10:48:18+00:00" - }, - { - "name": "utopia-php/psr7", - "version": "0.2.0", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/psr7.git", - "reference": "115753c36194d53abe5587d383810724ac3a782d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/psr7/zipball/115753c36194d53abe5587d383810724ac3a782d", - "reference": "115753c36194d53abe5587d383810724ac3a782d", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0" - }, - "suggest": { - "ext-simplexml": "Required to decode XML responses with Response::xml()." - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Psr7\\": "src/Psr7/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PSR-7 HTTP message implementations and PSR-17 factories for Utopia", - "keywords": [ - "http", - "php", - "psr-17", - "psr-7", - "utopia" - ], - "support": { - "issues": "https://github.com/utopia-php/psr7/issues", - "source": "https://github.com/utopia-php/psr7/tree/0.2.0" - }, - "time": "2026-07-06T12:40:23+00:00" - }, - { - "name": "utopia-php/span", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/span.git", - "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/span/zipball/d11f2714324cb22b286b2afbf3ea9de32c68de83", - "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "swoole/ide-helper": "^5.0" - }, - "suggest": { - "ext-swoole": "Required for coroutine-based storage" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Span\\": "src/Span/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Simple span tracing library for PHP with coroutine support", - "support": { - "issues": "https://github.com/utopia-php/span/issues", - "source": "https://github.com/utopia-php/span/tree/4.0.1" - }, - "time": "2026-06-20T09:45:06+00:00" - }, - { - "name": "utopia-php/telemetry", - "version": "0.4.5", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/telemetry.git", - "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/139943bffcd4f6dd8fb9ed247f946a1d151b006a", - "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a", - "shasum": "" - }, - "require": { - "ext-protobuf": "*", - "nyholm/psr7": "1.*", - "open-telemetry/exporter-otlp": "1.*", - "open-telemetry/sdk": "1.*", - "php": ">=8.0", - "symfony/http-client": "7.*" - }, - "require-dev": { - "swoole/ide-helper": "6.*" - }, - "suggest": { - "ext-sockets": "Required for the Swoole transport implementation", - "ext-swoole": "Required for the Swoole transport implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Telemetry\\": "src/Telemetry" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A lite & fast telemetry library, with adapters for OpenTelemetry", - "keywords": [ - "framework", - "php", - "upf" - ], - "support": { - "issues": "https://github.com/utopia-php/telemetry/issues", - "source": "https://github.com/utopia-php/telemetry/tree/0.4.5" - }, - "time": "2026-07-08T11:07:25+00:00" - } - ], - "packages-dev": [ - { - "name": "swoole/ide-helper", - "version": "6.0.2", - "source": { - "type": "git", - "url": "https://github.com/swoole/ide-helper.git", - "reference": "6f12243dce071714c5febe059578d909698f9a52" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/swoole/ide-helper/zipball/6f12243dce071714c5febe059578d909698f9a52", - "reference": "6f12243dce071714c5febe059578d909698f9a52", - "shasum": "" - }, - "type": "library", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Team Swoole", - "email": "team@swoole.com" - } - ], - "description": "IDE help files for Swoole.", - "support": { - "issues": "https://github.com/swoole/ide-helper/issues", - "source": "https://github.com/swoole/ide-helper/tree/6.0.2" - }, - "time": "2025-03-23T07:31:41+00:00" - } - ], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": true, - "prefer-lowest": false, - "platform": { - "php": ">=8.5.0", - "ext-curl": "*", - "ext-openssl": "*", - "ext-swoole": "*" - }, - "platform-dev": {}, - "platform-overrides": { - "php": "8.5" - }, - "plugin-api-version": "2.9.0" -} +{ "_readme": [ "This file locks the dependencies of your project to a known state", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], "content-hash": "c9f4636738c26211dee548812bb3a769", "packages": [ { "name": "brick/math", "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "2.1.22", "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { "psr-4": { "Brick\\Math\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "Arbitrary-precision arithmetic library", "keywords": [ "Arbitrary-precision", "BigInteger", "BigRational", "arithmetic", "bigdecimal", "bignum", "bignumber", "brick", "decimal", "integer", "math", "mathematics", "rational" ], "support": { "issues": "https://github.com/brick/math/issues", "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { "url": "https://github.com/BenMorel", "type": "github" } ], "time": "2026-02-10T14:33:43+00:00" }, { "name": "composer/semver", "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^1.11", "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { "branch-alias": { "dev-main": "3.x-dev" } }, "autoload": { "psr-4": { "Composer\\Semver\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Nils Adermann", "email": "naderman@naderman.de", "homepage": "http://www.naderman.de" }, { "name": "Jordi Boggiano", "email": "j.boggiano@seld.be", "homepage": "http://seld.be" }, { "name": "Rob Bast", "email": "rob.bast@gmail.com", "homepage": "http://robbast.nl" } ], "description": "Semver library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", "validation", "versioning" ], "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { "url": "https://packagist.com", "type": "custom" }, { "url": "https://github.com/composer", "type": "github" } ], "time": "2025-08-20T19:15:30+00:00" }, { "name": "giggsey/libphonenumber-for-php-lite", "version": "9.0.23", "source": { "type": "git", "url": "https://github.com/giggsey/libphonenumber-for-php-lite.git", "reference": "e8f6f896c1bef398136849934a934904bd9bf072" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/e8f6f896c1bef398136849934a934904bd9bf072", "reference": "e8f6f896c1bef398136849934a934904bd9bf072", "shasum": "" }, "require": { "php": "^8.1", "symfony/polyfill-mbstring": "^1.17" }, "conflict": { "giggsey/libphonenumber-for-php": "*" }, "require-dev": { "ext-dom": "*", "friendsofphp/php-cs-fixer": "^3.71", "infection/infection": "^0.29|^0.31.0", "nette/php-generator": "^4.1", "php-coveralls/php-coveralls": "^2.7", "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^2.1.7", "phpstan/phpstan-deprecation-rules": "^2.0.1", "phpstan/phpstan-phpunit": "^2.0.4", "phpstan/phpstan-strict-rules": "^2.0.3", "phpunit/phpunit": "^10.5.45", "symfony/console": "^6.4", "symfony/filesystem": "^6.4", "symfony/process": "^6.4" }, "suggest": { "giggsey/libphonenumber-for-php": "Use libphonenumber-for-php for geocoding, carriers, timezones and matching" }, "type": "library", "extra": { "branch-alias": { "dev-master": "9.x-dev" } }, "autoload": { "psr-4": { "libphonenumber\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "authors": [ { "name": "Joshua Gigg", "email": "giggsey@gmail.com", "homepage": "https://giggsey.com/" } ], "description": "A lite version of giggsey/libphonenumber-for-php, which is a PHP Port of Google's libphonenumber", "homepage": "https://github.com/giggsey/libphonenumber-for-php-lite", "keywords": [ "geocoding", "geolocation", "libphonenumber", "mobile", "phonenumber", "validation" ], "support": { "issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues", "source": "https://github.com/giggsey/libphonenumber-for-php-lite" }, "time": "2026-01-30T12:33:03+00:00" }, { "name": "google/protobuf", "version": "v4.33.6", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", "shasum": "" }, "require": { "php": ">=8.1.0" }, "require-dev": { "phpunit/phpunit": ">=10.5.62 <11.0.0" }, "suggest": { "ext-bcmath": "Need to support JSON deserialization" }, "type": "library", "autoload": { "psr-4": { "Google\\Protobuf\\": "src/Google/Protobuf", "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "description": "proto library for PHP", "homepage": "https://developers.google.com/protocol-buffers/", "keywords": [ "proto" ], "support": { "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" }, "time": "2026-03-18T17:32:05+00:00" }, { "name": "nyholm/psr7", "version": "1.8.2", "source": { "type": "git", "url": "https://github.com/Nyholm/psr7.git", "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", "shasum": "" }, "require": { "php": ">=7.2", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0" }, "provide": { "php-http/message-factory-implementation": "1.0", "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "require-dev": { "http-interop/http-factory-tests": "^0.9", "php-http/message-factory": "^1.0", "php-http/psr7-integration-tests": "^1.0", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", "symfony/error-handler": "^4.4" }, "type": "library", "extra": { "branch-alias": { "dev-master": "1.8-dev" } }, "autoload": { "psr-4": { "Nyholm\\Psr7\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Tobias Nyholm", "email": "tobias.nyholm@gmail.com" }, { "name": "Martijn van der Ven", "email": "martijn@vanderven.se" } ], "description": "A fast PHP7 implementation of PSR-7", "homepage": "https://tnyholm.se", "keywords": [ "psr-17", "psr-7" ], "support": { "issues": "https://github.com/Nyholm/psr7/issues", "source": "https://github.com/Nyholm/psr7/tree/1.8.2" }, "funding": [ { "url": "https://github.com/Zegnat", "type": "github" }, { "url": "https://github.com/nyholm", "type": "github" } ], "time": "2024-09-09T07:06:30+00:00" }, { "name": "nyholm/psr7-server", "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/Nyholm/psr7-server.git", "reference": "4335801d851f554ca43fa6e7d2602141538854dc" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", "reference": "4335801d851f554ca43fa6e7d2602141538854dc", "shasum": "" }, "require": { "php": "^7.1 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.0 || ^2.0" }, "require-dev": { "nyholm/nsa": "^1.1", "nyholm/psr7": "^1.3", "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" }, "type": "library", "autoload": { "psr-4": { "Nyholm\\Psr7Server\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Tobias Nyholm", "email": "tobias.nyholm@gmail.com" }, { "name": "Martijn van der Ven", "email": "martijn@vanderven.se" } ], "description": "Helper classes to handle PSR-7 server requests", "homepage": "http://tnyholm.se", "keywords": [ "psr-17", "psr-7" ], "support": { "issues": "https://github.com/Nyholm/psr7-server/issues", "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" }, "funding": [ { "url": "https://github.com/Zegnat", "type": "github" }, { "url": "https://github.com/nyholm", "type": "github" } ], "time": "2023-11-08T09:30:43+00:00" }, { "name": "open-telemetry/api", "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", "shasum": "" }, "require": { "open-telemetry/context": "^1.4", "php": "^8.1", "psr/log": "^1.1|^2.0|^3.0", "symfony/polyfill-php82": "^1.26" }, "conflict": { "open-telemetry/sdk": "<=1.11" }, "type": "library", "extra": { "spi": { "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" ] }, "branch-alias": { "dev-main": "1.8.x-dev" } }, "autoload": { "files": [ "Trace/functions.php" ], "psr-4": { "OpenTelemetry\\API\\": "." } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "authors": [ { "name": "opentelemetry-php contributors", "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" } ], "description": "API for OpenTelemetry PHP.", "keywords": [ "Metrics", "api", "apm", "logging", "opentelemetry", "otel", "tracing" ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, "time": "2026-02-25T13:24:05+00:00" }, { "name": "open-telemetry/context", "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", "shasum": "" }, "require": { "php": "^8.1", "symfony/polyfill-php82": "^1.26" }, "suggest": { "ext-ffi": "To allow context switching in Fibers" }, "type": "library", "extra": { "branch-alias": { "dev-main": "1.0.x-dev" } }, "autoload": { "files": [ "fiber/initialize_fiber_handler.php" ], "psr-4": { "OpenTelemetry\\Context\\": "." } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "authors": [ { "name": "opentelemetry-php contributors", "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" } ], "description": "Context implementation for OpenTelemetry PHP.", "keywords": [ "Context", "opentelemetry", "otel" ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, "time": "2025-10-19T06:44:33+00:00" }, { "name": "open-telemetry/exporter-otlp", "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be", "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be", "shasum": "" }, "require": { "open-telemetry/api": "^1.0", "open-telemetry/gen-otlp-protobuf": "^1.1", "open-telemetry/sdk": "^1.0", "php": "^8.1", "php-http/discovery": "^1.14" }, "type": "library", "extra": { "branch-alias": { "dev-main": "1.0.x-dev" } }, "autoload": { "files": [ "_register.php" ], "psr-4": { "OpenTelemetry\\Contrib\\Otlp\\": "." } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "authors": [ { "name": "opentelemetry-php contributors", "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" } ], "description": "OTLP exporter for OpenTelemetry.", "keywords": [ "Metrics", "exporter", "gRPC", "http", "opentelemetry", "otel", "otlp", "tracing" ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, "time": "2026-02-05T09:44:52+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", "shasum": "" }, "require": { "google/protobuf": "^3.22 || ^4.0", "php": "^8.0" }, "suggest": { "ext-protobuf": "For better performance, when dealing with the protobuf format" }, "type": "library", "extra": { "branch-alias": { "dev-main": "1.x-dev" } }, "autoload": { "psr-4": { "Opentelemetry\\Proto\\": "Opentelemetry/Proto/", "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "authors": [ { "name": "opentelemetry-php contributors", "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" } ], "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.", "keywords": [ "Metrics", "apm", "gRPC", "logging", "opentelemetry", "otel", "otlp", "protobuf", "tracing" ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, "time": "2025-10-19T06:44:33+00:00" }, { "name": "open-telemetry/sdk", "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", "open-telemetry/api": "^1.8", "open-telemetry/context": "^1.4", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", "php-http/discovery": "^1.14", "psr/http-client": "^1.0", "psr/http-client-implementation": "^1.0", "psr/http-factory-implementation": "^1.0", "psr/http-message": "^1.0.1|^2.0", "psr/log": "^1.1|^2.0|^3.0", "ramsey/uuid": "^3.0 || ^4.0", "symfony/polyfill-mbstring": "^1.23", "symfony/polyfill-php82": "^1.26", "tbachert/spi": "^1.0.5" }, "suggest": { "ext-gmp": "To support unlimited number of synchronous metric readers", "ext-mbstring": "To increase performance of string operations", "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration" }, "type": "library", "extra": { "spi": { "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig" ], "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" ], "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" ] }, "branch-alias": { "dev-main": "1.12.x-dev" } }, "autoload": { "files": [ "Common/Util/functions.php", "Logs/Exporter/_register.php", "Metrics/MetricExporter/_register.php", "Propagation/_register.php", "Trace/SpanExporter/_register.php", "Common/Dev/Compatibility/_load.php", "_autoload.php" ], "psr-4": { "OpenTelemetry\\SDK\\": "." } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "authors": [ { "name": "opentelemetry-php contributors", "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" } ], "description": "SDK for OpenTelemetry PHP.", "keywords": [ "Metrics", "apm", "logging", "opentelemetry", "otel", "sdk", "tracing" ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, "time": "2026-03-21T11:50:01+00:00" }, { "name": "open-telemetry/sem-conv", "version": "1.38.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sem-conv.git", "reference": "e613bc640a407def4991b8a936a9b27edd9a3240" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240", "reference": "e613bc640a407def4991b8a936a9b27edd9a3240", "shasum": "" }, "require": { "php": "^8.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "1.x-dev" } }, "autoload": { "psr-4": { "OpenTelemetry\\SemConv\\": "." } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "authors": [ { "name": "opentelemetry-php contributors", "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" } ], "description": "Semantic conventions for OpenTelemetry PHP.", "keywords": [ "Metrics", "apm", "logging", "opentelemetry", "otel", "semantic conventions", "semconv", "tracing" ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, "time": "2026-01-21T04:14:03+00:00" }, { "name": "php-http/discovery", "version": "1.20.0", "source": { "type": "git", "url": "https://github.com/php-http/discovery.git", "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", "shasum": "" }, "require": { "composer-plugin-api": "^1.0|^2.0", "php": "^7.1 || ^8.0" }, "conflict": { "nyholm/psr7": "<1.0", "zendframework/zend-diactoros": "*" }, "provide": { "php-http/async-client-implementation": "*", "php-http/client-implementation": "*", "psr/http-client-implementation": "*", "psr/http-factory-implementation": "*", "psr/http-message-implementation": "*" }, "require-dev": { "composer/composer": "^1.0.2|^2.0", "graham-campbell/phpspec-skip-example-extension": "^5.0", "php-http/httplug": "^1.0 || ^2.0", "php-http/message-factory": "^1.0", "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", "sebastian/comparator": "^3.0.5 || ^4.0.8", "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" }, "type": "composer-plugin", "extra": { "class": "Http\\Discovery\\Composer\\Plugin", "plugin-optional": true }, "autoload": { "psr-4": { "Http\\Discovery\\": "src/" }, "exclude-from-classmap": [ "src/Composer/Plugin.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "M+írk S+ígi-Kaz+ír", "email": "mark.sagikazar@gmail.com" } ], "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", "homepage": "http://php-http.org", "keywords": [ "adapter", "client", "discovery", "factory", "http", "message", "psr17", "psr7" ], "support": { "issues": "https://github.com/php-http/discovery/issues", "source": "https://github.com/php-http/discovery/tree/1.20.0" }, "time": "2024-10-02T11:20:13+00:00" }, { "name": "phpmailer/phpmailer", "version": "v6.9.1", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", "reference": "039de174cd9c17a8389754d3b877a2ed22743e18" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/039de174cd9c17a8389754d3b877a2ed22743e18", "reference": "039de174cd9c17a8389754d3b877a2ed22743e18", "shasum": "" }, "require": { "ext-ctype": "*", "ext-filter": "*", "ext-hash": "*", "php": ">=5.5.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "doctrine/annotations": "^1.2.6 || ^1.13.3", "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", "phpcompatibility/php-compatibility": "^9.3.5", "roave/security-advisories": "dev-latest", "squizlabs/php_codesniffer": "^3.7.2", "yoast/phpunit-polyfills": "^1.0.4" }, "suggest": { "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", "ext-openssl": "Needed for secure SMTP sending and DKIM signing", "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", "league/oauth2-google": "Needed for Google XOAUTH2 authentication", "psr/log": "For optional PSR-3 debug logging", "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" }, "type": "library", "autoload": { "psr-4": { "PHPMailer\\PHPMailer\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "LGPL-2.1-only" ], "authors": [ { "name": "Marcus Bointon", "email": "phpmailer@synchromedia.co.uk" }, { "name": "Jim Jagielski", "email": "jimjag@gmail.com" }, { "name": "Andy Prevost", "email": "codeworxtech@users.sourceforge.net" }, { "name": "Brent R. Matzelle" } ], "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.1" }, "funding": [ { "url": "https://github.com/Synchro", "type": "github" } ], "time": "2023-11-25T22:23:28+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" }, { "name": "psr/http-client", "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/php-fig/http-client.git", "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { "php": "^7.0 || ^8.0", "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { "branch-alias": { "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { "Psr\\Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "PHP-FIG", "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP clients", "homepage": "https://github.com/php-fig/http-client", "keywords": [ "http", "http-client", "psr", "psr-18" ], "support": { "source": "https://github.com/php-fig/http-client" }, "time": "2023-09-23T14:17:50+00:00" }, { "name": "psr/http-factory", "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-factory.git", "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "shasum": "" }, "require": { "php": ">=7.1", "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { "branch-alias": { "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "PHP-FIG", "homepage": "https://www.php-fig.org/" } ], "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ "factory", "http", "message", "psr", "psr-17", "psr-7", "request", "response" ], "support": { "source": "https://github.com/php-fig/http-factory" }, "time": "2024-04-15T12:06:14+00:00" }, { "name": "psr/http-message", "version": "2.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "PHP-FIG", "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", "homepage": "https://github.com/php-fig/http-message", "keywords": [ "http", "http-message", "psr", "psr-7", "request", "response" ], "support": { "source": "https://github.com/php-fig/http-message/tree/2.0" }, "time": "2023-04-04T09:54:51+00:00" }, { "name": "psr/log", "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "PHP-FIG", "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for logging libraries", "homepage": "https://github.com/php-fig/log", "keywords": [ "log", "psr", "psr-3" ], "support": { "source": "https://github.com/php-fig/log/tree/3.0.2" }, "time": "2024-09-11T13:17:53+00:00" }, { "name": "ramsey/collection", "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/ramsey/collection.git", "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { "captainhook/plugin-composer": "^5.3", "ergebnis/composer-normalize": "^2.45", "fakerphp/faker": "^1.24", "hamcrest/hamcrest-php": "^2.0", "jangregor/phpstan-prophecy": "^2.1", "mockery/mockery": "^1.6", "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.4", "phpspec/prophecy-phpunit": "^2.3", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpstan/phpstan-mockery": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^10.5", "ramsey/coding-standard": "^2.3", "ramsey/conventional-commits": "^1.6", "roave/security-advisories": "dev-latest" }, "type": "library", "extra": { "captainhook": { "force-install": true }, "ramsey/conventional-commits": { "configFile": "conventional-commits.json" } }, "autoload": { "psr-4": { "Ramsey\\Collection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Ben Ramsey", "email": "ben@benramsey.com", "homepage": "https://benramsey.com" } ], "description": "A PHP library for representing and manipulating collections.", "keywords": [ "array", "collection", "hash", "map", "queue", "set" ], "support": { "issues": "https://github.com/ramsey/collection/issues", "source": "https://github.com/ramsey/collection/tree/2.1.1" }, "time": "2025-03-22T05:38:12+00:00" }, { "name": "ramsey/uuid", "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, "replace": { "rhumsaa/uuid": "self.version" }, "require-dev": { "captainhook/captainhook": "^5.25", "captainhook/plugin-composer": "^5.3", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "ergebnis/composer-normalize": "^2.47", "mockery/mockery": "^1.6", "paragonie/random-lib": "^2", "php-mock/php-mock": "^2.6", "php-mock/php-mock-mockery": "^1.5", "php-parallel-lint/php-parallel-lint": "^1.4.0", "phpbench/phpbench": "^1.2.14", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpstan/phpstan-mockery": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.6", "slevomat/coding-standard": "^8.18", "squizlabs/php_codesniffer": "^3.13" }, "suggest": { "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." }, "type": "library", "extra": { "captainhook": { "force-install": true } }, "autoload": { "files": [ "src/functions.php" ], "psr-4": { "Ramsey\\Uuid\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", "keywords": [ "guid", "identifier", "uuid" ], "support": { "issues": "https://github.com/ramsey/uuid/issues", "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, "time": "2025-12-14T04:43:48+00:00" }, { "name": "symfony/deprecation-contracts", "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { "php": ">=8.1" }, "type": "library", "extra": { "thanks": { "url": "https://github.com/symfony/contracts", "name": "symfony/contracts" }, "branch-alias": { "dev-main": "3.7-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.7.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-13T15:52:40+00:00" }, { "name": "symfony/http-client", "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", "reference": "e8a112b8415707265a7e614278136a9d92989a6a" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", "reference": "e8a112b8415707265a7e614278136a9d92989a6a", "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.13" }, "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-05-24T09:57:54+00:00" }, { "name": "symfony/http-client-contracts", "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { "php": ">=8.1" }, "type": "library", "extra": { "thanks": { "url": "https://github.com/symfony/contracts", "name": "symfony/contracts" }, "branch-alias": { "dev-main": "3.7-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.7.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-03-06T13:17:50+00:00" }, { "name": "symfony/polyfill-mbstring", "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { "ext-iconv": "*", "php": ">=7.2" }, "provide": { "ext-mbstring": "*" }, "suggest": { "ext-mbstring": "For best performance" }, "type": "library", "extra": { "thanks": { "url": "https://github.com/symfony/polyfill", "name": "symfony/polyfill" } }, "autoload": { "files": [ "bootstrap.php" ], "psr-4": { "Symfony\\Polyfill\\Mbstring\\": "" } }, "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 the Mbstring extension", "homepage": "https://symfony.com", "keywords": [ "compatibility", "mbstring", "polyfill", "portable", "shim" ], "support": { "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "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-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php82", "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "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.38.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-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php83", "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "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\\Php83\\": "" }, "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.3+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", "polyfill", "portable", "shim" ], "support": { "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "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-05-27T06:51:48+00:00" }, { "name": "symfony/service-contracts", "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "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.7-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.7.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-03-28T09:44:51+00:00" }, { "name": "tbachert/spi", "version": "v1.0.5", "source": { "type": "git", "url": "https://github.com/Nevay/spi.git", "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002", "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002", "shasum": "" }, "require": { "composer-plugin-api": "^2.0", "composer/semver": "^1.0 || ^2.0 || ^3.0", "php": "^8.1" }, "require-dev": { "composer/composer": "^2.0", "infection/infection": "^0.27.9", "phpunit/phpunit": "^10.5", "psalm/phar": "^5.18" }, "type": "composer-plugin", "extra": { "class": "Nevay\\SPI\\Composer\\Plugin", "branch-alias": { "dev-main": "1.0.x-dev" }, "plugin-optional": true }, "autoload": { "psr-4": { "Nevay\\SPI\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], "description": "Service provider loading facility", "keywords": [ "service provider" ], "support": { "issues": "https://github.com/Nevay/spi/issues", "source": "https://github.com/Nevay/spi/tree/v1.0.5" }, "time": "2025-06-29T15:42:06+00:00" }, { "name": "utopia-php/client", "version": "0.1.3", "source": { "type": "git", "url": "https://github.com/utopia-php/client.git", "reference": "95f32cea3cec81aaa52ab4e9d946a558b70ed7a8" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/utopia-php/client/zipball/95f32cea3cec81aaa52ab4e9d946a558b70ed7a8", "reference": "95f32cea3cec81aaa52ab4e9d946a558b70ed7a8", "shasum": "" }, "require": { "php": ">=8.4", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", "utopia-php/pools": "^1.0", "utopia-php/span": "^1.1 || ^3.0 || ^4.0" }, "require-dev": { "swoole/ide-helper": "^6.0" }, "suggest": { "ext-curl": "Required to use the cURL HTTP client adapter.", "ext-simplexml": "Required to decode XML responses with Response::xml().", "ext-swoole": "Required to use the Swoole coroutine HTTP client adapter." }, "type": "library", "autoload": { "psr-4": { "Utopia\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "A lightweight PSR-18 HTTP client with cURL and Swoole coroutine backends", "keywords": [ "client", "curl", "http", "php", "psr-18", "swoole", "utopia" ], "support": { "issues": "https://github.com/utopia-php/client/issues", "source": "https://github.com/utopia-php/client/tree/0.1.3" }, "time": "2026-06-20T10:46:00+00:00" }, { "name": "utopia-php/pools", "version": "1.0.8", "source": { "type": "git", "url": "https://github.com/utopia-php/pools.git", "reference": "b685ca01883ed820b9898b85163a8f3d970a2da7" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/utopia-php/pools/zipball/b685ca01883ed820b9898b85163a8f3d970a2da7", "reference": "b685ca01883ed820b9898b85163a8f3d970a2da7", "shasum": "" }, "require": { "php": ">=8.4", "utopia-php/telemetry": "^0.4" }, "require-dev": { "swoole/ide-helper": "6.*" }, "suggest": { "ext-swoole": "Required to use the Swoole pool adapter" }, "type": "library", "autoload": { "psr-4": { "Utopia\\Pools\\": "src/Pools" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Team Appwrite", "email": "team@appwrite.io" } ], "description": "A simple library to manage connection pools", "keywords": [ "framework", "php", "pools", "utopia" ], "support": { "issues": "https://github.com/utopia-php/pools/issues", "source": "https://github.com/utopia-php/pools/tree/1.0.8" }, "time": "2026-06-20T09:45:06+00:00" }, { "name": "utopia-php/span", "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/span.git", "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/utopia-php/span/zipball/d11f2714324cb22b286b2afbf3ea9de32c68de83", "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "swoole/ide-helper": "^5.0" }, "suggest": { "ext-swoole": "Required for coroutine-based storage" }, "type": "library", "autoload": { "psr-4": { "Utopia\\Span\\": "src/Span/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "Simple span tracing library for PHP with coroutine support", "support": { "issues": "https://github.com/utopia-php/span/issues", "source": "https://github.com/utopia-php/span/tree/4.0.1" }, "time": "2026-06-20T09:45:06+00:00" }, { "name": "utopia-php/telemetry", "version": "0.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/telemetry.git", "reference": "028f3a85bb987b1e9357fb1632aa40f020d1b86e" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/028f3a85bb987b1e9357fb1632aa40f020d1b86e", "reference": "028f3a85bb987b1e9357fb1632aa40f020d1b86e", "shasum": "" }, "require": { "ext-opentelemetry": "*", "ext-protobuf": "*", "nyholm/psr7": "1.*", "open-telemetry/exporter-otlp": "1.*", "open-telemetry/sdk": "1.*", "php": ">=8.0", "symfony/http-client": "7.*" }, "require-dev": { "phpbench/phpbench": "1.*", "swoole/ide-helper": "6.*" }, "suggest": { "ext-sockets": "Required for the Swoole transport implementation", "ext-swoole": "Required for the Swoole transport implementation" }, "type": "library", "autoload": { "psr-4": { "Utopia\\Telemetry\\": "src/Telemetry" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "A lite & fast telemetry library, with adapters for OpenTelemetry", "keywords": [ "framework", "php", "upf" ], "support": { "issues": "https://github.com/utopia-php/telemetry/issues", "source": "https://github.com/utopia-php/telemetry/tree/0.4.1" }, "time": "2026-06-10T15:48:26+00:00" } ], "packages-dev": [ { "name": "laravel/pint", "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", "php": "^8.2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.94.2", "illuminate/view": "^12.54.1", "larastan/larastan": "^3.9.3", "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" ], "type": "project", "autoload": { "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", "Database\\Factories\\": "database/factories/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Nuno Maduro", "email": "enunomaduro@gmail.com" } ], "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ "dev", "format", "formatter", "lint", "linter", "php" ], "support": { "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, "time": "2026-03-12T15:51:39+00:00" }, { "name": "myclabs/deep-copy", "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", "autoload": { "files": [ "src/DeepCopy/deep_copy.php" ], "psr-4": { "DeepCopy\\": "src/DeepCopy/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "Create deep copies (clones) of your objects", "keywords": [ "clone", "copy", "duplicate", "object", "object graph" ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", "type": "tidelift" } ], "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" ], "type": "library", "extra": { "branch-alias": { "dev-master": "5.x-dev" } }, "autoload": { "psr-4": { "PhpParser\\": "lib/PhpParser" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Nikita Popov" } ], "description": "A PHP parser written in PHP", "keywords": [ "parser", "php" ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-phar": "*", "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { "dev-master": "2.0.x-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Arne Blankerts", "email": "arne@blankerts.de", "role": "Developer" }, { "name": "Sebastian Heuer", "email": "sebastian@phpeople.de", "role": "Developer" }, { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "Developer" } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { "issues": "https://github.com/phar-io/manifest/issues", "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, "funding": [ { "url": "https://github.com/theseer", "type": "github" } ], "time": "2024-03-03T12:33:53+00:00" }, { "name": "phar-io/version", "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Arne Blankerts", "email": "arne@blankerts.de", "role": "Developer" }, { "name": "Sebastian Heuer", "email": "sebastian@phpeople.de", "role": "Developer" }, { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "Developer" } ], "description": "Library for handling version information and constraints", "support": { "issues": "https://github.com/phar-io/version/issues", "source": "https://github.com/phar-io/version/tree/3.2.1" }, "time": "2022-02-21T01:04:05+00:00" }, { "name": "phpstan/phpstan", "version": "2.2.2", "dist": { "type": "zip", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" }, "bin": [ "phpstan", "phpstan.phar" ], "type": "library", "autoload": { "files": [ "bootstrap.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Ond+Öej Mirtes" }, { "name": "Markus Staab" }, { "name": "Vincent Langlet" } ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", "static analysis" ], "support": { "docs": "https://phpstan.org/user-guide/getting-started", "forum": "https://github.com/phpstan/phpstan/discussions", "issues": "https://github.com/phpstan/phpstan/issues", "security": "https://github.com/phpstan/phpstan/security/policy", "source": "https://github.com/phpstan/phpstan-src" }, "funding": [ { "url": "https://github.com/ondrejmirtes", "type": "github" }, { "url": "https://github.com/phpstan", "type": "github" } ], "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpunit/php-code-coverage", "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", "theseer/tokenizer": "^1.3.1" }, "require-dev": { "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { "branch-alias": { "dev-main": "11.0.x-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ "coverage", "testing", "xunit" ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", "type": "tidelift" } ], "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { "dev-main": "5.1-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "FilterIterator implementation that filters files based on a list of suffixes.", "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ "filesystem", "iterator" ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", "type": "tidelift" } ], "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" }, "type": "library", "extra": { "branch-alias": { "dev-main": "5.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Invoke callables with a timeout", "homepage": "https://github.com/sebastianbergmann/php-invoker/", "keywords": [ "process" ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "4.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Simple template engine.", "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ "template" ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "7.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Utility class for timing", "homepage": "https://github.com/sebastianbergmann/php-timer/", "keywords": [ "timer" ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.1", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/recursion-context": "^6.0.3", "sebastian/type": "^5.1.3", "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" }, "suggest": { "ext-soap": "To be able to generate mocks based on WSDL files" }, "bin": [ "phpunit" ], "type": "library", "extra": { "branch-alias": { "dev-main": "11.5-dev" } }, "autoload": { "files": [ "src/Framework/Assert/Functions.php" ], "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "The PHP Unit Testing framework.", "homepage": "https://phpunit.de/", "keywords": [ "phpunit", "testing", "xunit" ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { "url": "https://phpunit.de/sponsors.html", "type": "custom" }, { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], "time": "2026-02-18T12:37:06+00:00" }, { "name": "sebastian/cli-parser", "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "3.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Library for parsing CLI options", "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T04:41:36+00:00" }, { "name": "sebastian/code-unit", "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { "dev-main": "3.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Collection of value objects that represent the PHP code units", "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", "security": "https://github.com/sebastianbergmann/code-unit/security/policy", "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2025-03-19T07:56:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "4.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" } ], "description": "Looks up which function or method a line of code belongs to", "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", "php": ">=8.2", "sebastian/diff": "^6.0", "sebastian/exporter": "^6.0" }, "require-dev": { "phpunit/phpunit": "^11.4" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { "dev-main": "6.3-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" }, { "name": "Jeff Welch", "email": "whatthejeff@gmail.com" }, { "name": "Volker Dusch", "email": "github@wallbash.com" }, { "name": "Bernhard Schussek", "email": "bschussek@2bepublished.at" } ], "description": "Provides the functionality to compare PHP values for equality", "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ "comparator", "compare", "equality" ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", "type": "tidelift" } ], "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "4.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Library for calculating the complexity of PHP code units", "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0", "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { "dev-main": "6.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" }, { "name": "Kore Nordmann", "email": "mail@kore-nordmann.de" } ], "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ "diff", "udiff", "unidiff", "unified diff" ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { "dev-main": "7.2-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" } ], "description": "Provides functionality to handle HHVM/PHP environments", "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", "hhvm" ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", "type": "tidelift" } ], "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.2", "sebastian/recursion-context": "^6.0" }, "require-dev": { "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { "dev-main": "6.3-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" }, { "name": "Jeff Welch", "email": "whatthejeff@gmail.com" }, { "name": "Volker Dusch", "email": "github@wallbash.com" }, { "name": "Adam Harvey", "email": "aharvey@php.net" }, { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" } ], "description": "Provides the functionality to export PHP variables for visualization", "homepage": "https://www.github.com/sebastianbergmann/exporter", "keywords": [ "export", "exporter" ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", "type": "tidelift" } ], "time": "2025-09-24T06:12:51+00:00" }, { "name": "sebastian/global-state", "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { "php": ">=8.2", "sebastian/object-reflector": "^4.0", "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "7.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" } ], "description": "Snapshotting of global state", "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "3.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Library for counting the lines of code in PHP source code", "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { "php": ">=8.2", "sebastian/object-reflector": "^4.0", "sebastian/recursion-context": "^6.0" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "6.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" } ], "description": "Traverses array structures and object graphs to enumerate all referenced objects", "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { "dev-main": "4.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" } ], "description": "Allows reflection of object attributes, including inherited and non-public ones", "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { "dev-main": "6.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" }, { "name": "Jeff Welch", "email": "whatthejeff@gmail.com" }, { "name": "Adam Harvey", "email": "aharvey@php.net" } ], "description": "Provides functionality to recursively process PHP variables", "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", "type": "tidelift" } ], "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { "dev-main": "5.1-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Collection of value objects that represent the types of the PHP type system", "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" }, { "url": "https://liberapay.com/sebastianbergmann", "type": "liberapay" }, { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" }, { "url": "https://tidelift.com/funding/github/packagist/sebastian/type", "type": "tidelift" } ], "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { "dev-main": "5.0-dev" } }, "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", "role": "lead" } ], "description": "Library that helps with managing the version number of Git-hosted PHP projects", "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], "time": "2024-10-09T05:16:32+00:00" }, { "name": "staabm/side-effects-detector", "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/staabm/side-effects-detector.git", "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { "ext-tokenizer": "*", "php": "^7.4 || ^8.0" }, "require-dev": { "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^1.12.6", "phpunit/phpunit": "^9.6.21", "symfony/var-dumper": "^5.4.43", "tomasvotruba/type-coverage": "1.0.0", "tomasvotruba/unused-public": "1.0.0" }, "type": "library", "autoload": { "classmap": [ "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "A static analysis tool to detect side effects in PHP code", "keywords": [ "static analysis" ], "support": { "issues": "https://github.com/staabm/side-effects-detector/issues", "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { "url": "https://github.com/staabm", "type": "github" } ], "time": "2024-10-20T05:08:20+00:00" }, { "name": "theseer/tokenizer", "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { "classmap": [ "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { "name": "Arne Blankerts", "email": "arne@blankerts.de", "role": "Developer" } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { "url": "https://github.com/theseer", "type": "github" } ], "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.4", "ext-curl": "*", "ext-openssl": "*" }, "platform-dev": {}, "platform-overrides": { "php": "8.4" }, "plugin-api-version": "2.9.0"} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index 4c12715e..3aa6f168 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -1,428 +1 @@ - CURL_HTTP_VERSION_2_0])`. - */ - public function __construct(?Telemetry $telemetry = null, private readonly ?Closure $clientFactory = null) - { - $this->sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); - } - - /** - * Get the name of the adapter. - */ - abstract public function getName(): string; - - /** - * Get the type of the adapter. - */ - abstract public function getType(): string; - - /** - * Get the type of the message the adapter can send. - */ - abstract public function getMessageType(): string; - - /** - * Get the maximum number of messages that can be sent in a single request. - */ - abstract public function getMaxMessagesPerRequest(): int; - - /** - * Send a message. - * - * @return array{ - * deliveredTo: int, - * type: string, - * results: array> - * } | array> - * }> GEOSMS adapter returns an array of results keyed by adapter name. - * - * @throws \Exception - */ - public function send(Message $message): array - { - if (!is_a($message, $this->getMessageType())) { - throw new \Exception('Invalid message type.'); - } - if (method_exists($message, 'getTo') && \count($message->getTo()) > $this->getMaxMessagesPerRequest()) { - throw new \Exception("{$this->getName()} can only send {$this->getMaxMessagesPerRequest()} messages per request."); - } - if (!method_exists($this, 'process')) { - throw new \Exception('Adapter does not implement process method.'); - } - - try { - $response = $this->process($message); - } catch (\Throwable $error) { - $this->recordSend($message, method_exists($message, 'getTo') ? \count($message->getTo()) : 1, 0); - throw $error; - } - - $this->recordResponse($message, $response); - - return $response; - } - - public function setTelemetry(Telemetry $telemetry): void - { - $this->sendCounter = $telemetry->createCounter('messaging.send'); - } - - private function recordSend(Message $message, int $recipients, int $delivered): void - { - if ($delivered > 0) { - $this->sendCounter->add($delivered, $this->telemetryAttributes($message, [ - 'result' => 'success', - ])); - } - - $failed = $recipients - $delivered; - if ($failed > 0) { - $this->sendCounter->add($failed, $this->telemetryAttributes($message, [ - 'result' => 'failure', - ])); - } - } - - /** - * @param array $attributes - * @return array - */ - private function telemetryAttributes(Message $message, array $attributes = []): array - { - if ($message->getOrigin() !== null) { - $attributes['origin'] = $message->getOrigin(); - } - - return $attributes + [ - 'type' => $this->getType(), - 'provider' => strtolower($this->getName()), - ]; - } - - /** - * @param array $response - */ - private function recordResponse(Message $message, array $response): void - { - $results = $response['results'] ?? []; - if (empty($results)) { - return; - } - - $delivered = 0; - $failed = 0; - - foreach ($results as $result) { - ($result['status'] ?? '') === 'success' ? $delivered++ : $failed++; - } - - $this->recordSend($message, $delivered + $failed, $delivered); - } - - /** - * Send a single HTTP request. - * - * @param string $method The HTTP method to use. - * @param string $url The URL to send the request to. - * @param array $headers An array of headers to send with the request. - * @param array|null $body The body of the request. - * @param int $timeout The timeout in seconds. - * @return array{ - * url: string, - * statusCode: int, - * response: array|string|null, - * headers: array, - * error: string, - * errorCode: int - * } - * - * @throws Exception If the request fails. - */ - protected function request( - string $method, - string $url, - array $headers = [], - ?array $body = null, - int $timeout = 30, - int $connectTimeout = 10, - ): array { - $client = $this->clientFactory instanceof \Closure - ? ($this->clientFactory)() - : $this->defaultClient($timeout, $connectTimeout); - - $request = $this->buildRequest($method, $url, $headers, $body); - - try { - $response = $client->sendRequest($request); - } catch (ClientExceptionInterface $error) { - return [ - 'url' => $url, - 'statusCode' => 0, - 'response' => null, - 'headers' => [], - 'error' => $error->getMessage(), - 'errorCode' => $error->getCode(), - ]; - } - - return $this->buildResult($response, $url); - } - - /** - * Send multiple concurrent HTTP requests using Swoole coroutines over a - * bounded connection pool. - * - * @param array $urls - * @param array $headers - * @param array> $bodies - * @return array|string|null, - * headers: array, - * error: string, - * errorCode: int - * }> - * - * @throws Exception - */ - protected function requestMulti( - string $method, - array $urls, - array $headers = [], - array $bodies = [], - int $timeout = 30, - int $connectTimeout = 10, - ): array { - if ($urls === []) { - throw new \Exception('No URLs provided. Must provide at least one URL.'); - } - - $urlCount = \count($urls); - $bodyCount = \count($bodies); - - if (!($urlCount === $bodyCount || $urlCount === 1 || $bodyCount === 1)) { - throw new \Exception('URL and body counts must be equal or one must equal 1.'); - } - - if ($bodyCount > 0 && $urlCount > $bodyCount) { - $bodies = array_pad($bodies, $urlCount, $bodies[0]); - } elseif ($urlCount < $bodyCount) { - $urls = array_pad($urls, $bodyCount, $urls[0]); - } - - $requests = []; - foreach ($urls as $i => $url) { - $requests[$i] = $this->buildRequest($method, $url, $headers, $bodies[$i] ?? null); - } - - $results = []; - - $run = function () use ($requests, $timeout, $connectTimeout, &$results): void { - $pool = new ConnectionPool( - pool: new SwoolePoolAdapter(), - name: self::CONNECTION_POOL_NAME, - size: min(\count($requests), self::MAX_CONCURRENT_REQUESTS), - init: $this->clientFactory ?? $this->defaultClient($timeout, $connectTimeout)->withConnectionReuse(...), - ); - - $group = new WaitGroup(); - - foreach ($requests as $index => $request) { - $group->add(); - - Coroutine::create(function () use ($pool, $request, $index, &$results, $group): void { - try { - $results[$index] = $pool->use(fn(ClientInterface $client): array => $this->buildResult($client->sendRequest($request), (string) $request->getUri())); - } catch (\Throwable $error) { - // Throwable rather than the PSR client exception: pool - // acquisition and factory failures must also land in - // this slot's result — an uncaught throwable in a - // coroutine is fatal and would drop the slot entirely. - $results[$index] = [ - 'url' => (string) $request->getUri(), - 'statusCode' => 0, - 'response' => null, - 'headers' => [], - 'error' => $error->getMessage(), - 'errorCode' => (int) $error->getCode(), - ]; - } finally { - $group->done(); - } - }); - } - - $group->wait(); - }; - - // Fan out directly when already inside a coroutine runtime (e.g. - // Swoole servers/workers); otherwise bootstrap a scheduler for the - // duration of the batch. - if (Coroutine::getCid() > 0) { - $run(); - } else { - \Swoole\Coroutine\run($run); - } - - $responses = []; - foreach ($results as $index => $result) { - $responses[] = ['index' => $index] + $result; - } - - return $responses; - } - - /** - * Build the default HTTP client used when none was injected. - * - * cURL rather than Swoole's HTTP client: APNs only accepts HTTP/2, which - * Coroutine\Http\Client cannot negotiate (curl falls back to HTTP/1.1 for - * servers without it). Swoole's native-curl hook keeps requestMulti() - * sends concurrent per coroutine. - */ - private function defaultClient(int $timeout, int $connectTimeout): Client - { - return new Client(new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])) - ->withTimeout((float) $timeout) - ->withConnectTimeout((float) $connectTimeout); - } - - /** - * Build a PSR-7 request, encoding the body based on the request headers: - * JSON, form-urlencoded, or multipart/form-data (mirroring curl's - * handling of array CURLOPT_POSTFIELDS) in that order of precedence. - * - * @param array $headers Headers as "Name: value" strings. - * @param array|null $body - */ - private function buildRequest(string $method, string $url, array $headers, ?array $body): RequestInterface - { - $factory = new RequestFactory(); - - $headerMap = []; - foreach ($headers as $header) { - $parts = explode(':', $header, 2); - if (\count($parts) === 2) { - $headerMap[trim($parts[0])] = trim($parts[1]); - } - } - - // On the request rather than the client so injected PSR-18 clients - // send the same identity. - if (!array_any(array_keys($headerMap), fn(string $name): bool => strtolower($name) === 'user-agent')) { - $headerMap['User-Agent'] = "Appwrite {$this->getName()} Message Sender"; - } - - if ($body === null) { - return $factory->query($method, $url, [], $headerMap); - } - - foreach ($headers as $header) { - if (str_contains($header, 'application/json')) { - return $factory->json($method, $url, $body, $headerMap); - } - if (str_contains($header, 'application/x-www-form-urlencoded')) { - return $factory->form($method, $url, $body, $headerMap); - } - } - - // Drop any bare multipart Content-Type so the factory can set one - // carrying the boundary. - foreach (array_keys($headerMap) as $name) { - if (strtolower($name) === 'content-type') { - unset($headerMap[$name]); - } - } - - return $factory->multipart($method, $url, $body, $headerMap); - } - - /** - * Map a PSR-7 response to the array shape adapters consume. - * - * @return array{ - * url: string, - * statusCode: int, - * response: array|string|null, - * headers: array, - * error: string, - * errorCode: int - * } - */ - private function buildResult(ResponseInterface $response, string $url): array - { - $body = (string) $response->getBody(); - - try { - $body = json_decode($body, true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException) { - // Ignore - } - - $headers = []; - foreach (array_keys($response->getHeaders()) as $name) { - $headers[strtolower((string) $name)] = $response->getHeaderLine((string) $name); - } - - return [ - 'url' => $url, - 'statusCode' => $response->getStatusCode(), - 'response' => $body, - 'headers' => $headers, - 'error' => '', - 'errorCode' => 0, - ]; - } -} +sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); } /** * Get the name of the adapter. */ abstract public function getName(): string; /** * Get the type of the adapter. */ abstract public function getType(): string; /** * Get the type of the message the adapter can send. */ abstract public function getMessageType(): string; /** * Get the maximum number of messages that can be sent in a single request. */ abstract public function getMaxMessagesPerRequest(): int; /** * Send a message. * * @return array{ * deliveredTo: int, * type: string, * results: array> * } | array> * }> GEOSMS adapter returns an array of results keyed by adapter name. * * @throws \Exception */ public function send(Message $message): array { if (!\is_a($message, $this->getMessageType())) { throw new \Exception('Invalid message type.'); } if (\method_exists($message, 'getTo') && \count($message->getTo()) > $this->getMaxMessagesPerRequest()) { throw new \Exception("{$this->getName()} can only send {$this->getMaxMessagesPerRequest()} messages per request."); } if (!\method_exists($this, 'process')) { throw new \Exception('Adapter does not implement process method.'); } try { $response = $this->process($message); } catch (\Throwable $error) { $this->recordSend($message, \method_exists($message, 'getTo') ? \count($message->getTo()) : 1, 0); throw $error; } $this->recordResponse($message, $response); return $response; } public function setTelemetry(Telemetry $telemetry): void { $this->sendCounter = $telemetry->createCounter('messaging.send'); } private function recordSend(Message $message, int $recipients, int $delivered): void { if ($delivered > 0) { $this->sendCounter->add($delivered, $this->telemetryAttributes($message, [ 'result' => 'success', ])); } $failed = $recipients - $delivered; if ($failed > 0) { $this->sendCounter->add($failed, $this->telemetryAttributes($message, [ 'result' => 'failure', ])); } } /** * @param array $attributes * @return array */ private function telemetryAttributes(Message $message, array $attributes = []): array { if ($message->getOrigin() !== null) { $attributes['origin'] = $message->getOrigin(); } return $attributes + [ 'type' => $this->getType(), 'provider' => \strtolower($this->getName()), ]; } /** * @param array $response */ private function recordResponse(Message $message, array $response): void { $results = $response['results'] ?? []; if (empty($results)) { return; } $delivered = 0; $failed = 0; foreach ($results as $result) { ($result['status'] ?? '') === 'success' ? $delivered++ : $failed++; } $this->recordSend($message, $delivered + $failed, $delivered); } /** * Send a single HTTP request and return the client's PSR-7 response. * * @param string $method The HTTP method to use. * @param string $url The URL to send the request to. * @param array $headers Headers as "Key: value" strings. * @param array|null $body The body of the request. * @param int $timeout The timeout in seconds. * * @throws \Psr\Http\Client\ClientExceptionInterface If the request fails at the transport level. */ protected function request( string $method, string $url, array $headers = [], ?array $body = null, int $timeout = 30, int $connectTimeout = 10 ): ResponseInterface { return $this->client($timeout, $connectTimeout) ->sendRequest($this->buildRequest($method, $url, $headers, $body)); } /** * Send multiple HTTP requests over a single kept-alive HTTP/2 connection. * Responses are returned in request order, so the Nth response corresponds * to the Nth recipient. * * @param array $urls * @param array $headers Headers as "Key: value" strings. * @param array> $bodies * @return array * * @throws Exception */ protected function requestMulti( string $method, array $urls, array $headers = [], array $bodies = [], int $timeout = 30, int $connectTimeout = 10 ): array { if (empty($urls)) { throw new \Exception('No URLs provided. Must provide at least one URL.'); } $urlCount = \count($urls); $bodyCount = \count($bodies); if (!($urlCount == $bodyCount || $urlCount == 1 || $bodyCount == 1)) { throw new \Exception('URL and body counts must be equal or one must equal 1.'); } if ($urlCount > $bodyCount) { $bodies = \array_pad($bodies, $urlCount, $bodies[0]); } elseif ($urlCount < $bodyCount) { $urls = \array_pad($urls, $bodyCount, $urls[0]); } $client = $this->client($timeout, $connectTimeout, multi: true); $responses = []; foreach ($urls as $i => $url) { $responses[] = $client->sendRequest($this->buildRequest($method, $url, $headers, $bodies[$i])); } return $responses; } /** * Build a client carrying the adapter's user agent and timeouts. When * $multi is set the cURL transport negotiates HTTP/2 and keeps the * connection alive so a batch of requests to the same host reuses it. */ private function client(int $timeout, int $connectTimeout, bool $multi = false): Client { $adapter = new CurlAdapter( options: $multi ? [\CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2_0] : [], ); return (new Client($adapter)) ->withTimeout((float) $timeout) ->withConnectTimeout((float) $connectTimeout) ->withConnectionReuse($multi) ->withHeaders([Header::USER_AGENT => "Appwrite {$this->getName()} Message Sender"]); } /** * Translate the legacy "Key: value" header list and body array into a * PSR-7 request, picking the body encoding from the Content-Type header. * * @param array $headers * @param array|null $body */ private function buildRequest(string $method, string $url, array $headers, ?array $body): RequestInterface { $factory = new RequestFactory(); $contentType = ''; $map = []; foreach ($headers as $header) { [$key, $value] = \array_pad(\explode(':', $header, 2), 2, ''); $key = \trim($key); $value = \trim($value); if (\strtolower($key) === 'content-type') { $contentType = \strtolower($value); continue; } $map[$key] = $value; } $body ??= []; return match (true) { \str_contains($contentType, 'application/x-www-form-urlencoded') => $factory->form($method, $url, $body, $map), \str_contains($contentType, 'multipart/form-data') => $factory->multipart($method, $url, $body, $map), default => $factory->json($method, $url, $body, $map), }; } /** * @param string $phone * @return int|null * @throws Exception */ public function getCountryCode(string $phone): ?int { if (empty($phone)) { throw new Exception('$phone cannot be empty.'); } $helper = PhoneNumberUtil::getInstance(); try { return $helper ->parse($phone) ->getCountryCode(); } catch (\Throwable $th) { throw new Exception("Error parsing phone: " . $th->getMessage()); } }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter/Chat/Discord.php b/src/Utopia/Messaging/Adapter/Chat/Discord.php index 7bb1919d..250eddb5 100644 --- a/src/Utopia/Messaging/Adapter/Chat/Discord.php +++ b/src/Utopia/Messaging/Adapter/Chat/Discord.php @@ -108,7 +108,7 @@ protected function process(DiscordMessage $message): array ], ); - $statusCode = $result['statusCode']; + $statusCode = $result->getStatusCode(); if ($statusCode >= 200 && $statusCode < 300) { $response->setDeliveredTo(1); diff --git a/src/Utopia/Messaging/Adapter/Email/Mailgun.php b/src/Utopia/Messaging/Adapter/Email/Mailgun.php index 0a5496b8..8a2e9917 100644 --- a/src/Utopia/Messaging/Adapter/Email/Mailgun.php +++ b/src/Utopia/Messaging/Adapter/Email/Mailgun.php @@ -1,166 +1 @@ -isEU ? $euDomain : $usDomain; - - $recipients = $message->getTo(); - $toEmails = array_map(fn(array $to): string => $to['email'], $recipients); - - $body = [ - 'to' => implode(',', array_map( - fn(array $to) => empty($to['name']) - ? $to['email'] - : "{$to['name']} <{$to['email']}>", - $recipients, - )), - 'from' => "{$message->getFromName()} <{$message->getFromEmail()}>", - 'subject' => $message->getSubject(), - 'text' => $message->isHtml() ? null : $message->getContent(), - 'html' => $message->isHtml() ? $message->getContent() : null, - 'h:Reply-To: ' . "{$message->getReplyToName()} <{$message->getReplyToEmail()}>", - ]; - - if (\count($recipients) > 1) { - $body['recipient-variables'] = json_encode(array_fill_keys($toEmails, [])); - } - - if (!\is_null($message->getCC())) { - foreach ($message->getCC() as $cc) { - if (!empty($cc['email'])) { - $ccString = empty($cc['name']) - ? $cc['email'] - : "{$cc['name']} <{$cc['email']}>"; - - $body['cc'] = empty($body['cc']) - ? $ccString - : "{$body['cc']},{$ccString}"; - } - } - } - - if (!\is_null($message->getBCC())) { - foreach ($message->getBCC() as $bcc) { - if (!empty($bcc['email'])) { - $bccString = empty($bcc['name']) - ? $bcc['email'] - : "{$bcc['name']} <{$bcc['email']}>"; - - $body['bcc'] = empty($body['bcc']) - ? $bccString - : "{$body['bcc']},{$bccString}"; - } - } - } - - $isMultipart = false; - - if (!\is_null($message->getAttachments())) { - $size = 0; - - foreach ($message->getAttachments() as $attachment) { - $size += filesize($attachment->getPath()); - } - - if ($size > self::MAX_ATTACHMENT_BYTES) { - throw new \Exception('Attachments size exceeds the maximum allowed size of '); - } - - foreach ($message->getAttachments() as $index => $attachment) { - $isMultipart = true; - - $body["attachment[$index]"] = Part::file( - "attachment[$index]", - $attachment->getPath(), - $attachment->getName(), - $attachment->getType(), - ); - } - } - - $response = new Response($this->getType()); - - $headers = [ - 'Authorization: Basic ' . base64_encode("api:$this->apiKey"), - ]; - - $headers[] = $isMultipart ? 'Content-Type: multipart/form-data' : 'Content-Type: application/x-www-form-urlencoded'; - - $result = $this->request( - method: 'POST', - url: "https://$domain/v3/$this->domain/messages", - headers: $headers, - body: $body, - ); - - $statusCode = $result['statusCode']; - - if ($statusCode >= 200 && $statusCode < 300) { - $response->setDeliveredTo(\count($message->getTo())); - foreach ($message->getTo() as $to) { - $response->addResult($to['email']); - } - } elseif ($statusCode >= 400 && $statusCode < 500) { - foreach ($message->getTo() as $to) { - if (\is_string($result['response'])) { - $response->addResult($to['email'], $result['response']); - } elseif (isset($result['response']['message'])) { - $response->addResult($to['email'], $result['response']['message']); - } else { - $response->addResult($to['email'], 'Unknown error'); - } - } - } - - return $response->toArray(); - } -} +isEU ? $euDomain : $usDomain; $recipients = $message->getTo(); $toEmails = \array_map(fn ($to) => $to['email'], $recipients); $body = [ 'to' => \implode(',', \array_map( fn ($to) => !empty($to['name']) ? "{$to['name']} <{$to['email']}>" : $to['email'], $recipients )), 'from' => "{$message->getFromName()} <{$message->getFromEmail()}>", 'subject' => $message->getSubject(), 'text' => $message->isHtml() ? null : $message->getContent(), 'html' => $message->isHtml() ? $message->getContent() : null, 'h:Reply-To: '."{$message->getReplyToName()} <{$message->getReplyToEmail()}>", ]; if (\count($recipients) > 1) { $body['recipient-variables'] = json_encode(array_fill_keys($toEmails, [])); } if (!\is_null($message->getCC())) { foreach ($message->getCC() as $cc) { if (!empty($cc['email'])) { $ccString = !empty($cc['name']) ? "{$cc['name']} <{$cc['email']}>" : $cc['email']; $body['cc'] = !empty($body['cc']) ? "{$body['cc']},{$ccString}" : $ccString; } } } if (!\is_null($message->getBCC())) { foreach ($message->getBCC() as $bcc) { if (!empty($bcc['email'])) { $bccString = !empty($bcc['name']) ? "{$bcc['name']} <{$bcc['email']}>" : $bcc['email']; $body['bcc'] = !empty($body['bcc']) ? "{$body['bcc']},{$bccString}" : $bccString; } } } $isMultipart = false; if (!\is_null($message->getAttachments())) { $size = 0; foreach ($message->getAttachments() as $attachment) { $size += \filesize($attachment->getPath()); } if ($size > self::MAX_ATTACHMENT_BYTES) { throw new \Exception('Attachments size exceeds the maximum allowed size of '); } foreach ($message->getAttachments() as $index => $attachment) { $isMultipart = true; $body["attachment[$index]"] = Part::file( 'attachment', $attachment->getPath(), $attachment->getName(), $attachment->getType(), ); } } $response = new Response($this->getType()); $headers = [ 'Authorization: Basic ' . \base64_encode("api:$this->apiKey"), ]; if ($isMultipart) { $headers[] = 'Content-Type: multipart/form-data'; } else { $headers[] = 'Content-Type: application/x-www-form-urlencoded'; } $result = $this->request( method: 'POST', url: "https://$domain/v3/$this->domain/messages", headers: $headers, body: $body, ); $statusCode = $result->getStatusCode(); if ($statusCode >= 200 && $statusCode < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to['email']); } } elseif ($statusCode >= 400 && $statusCode < 500) { $content = \json_decode((string) $result->getBody(), true); foreach ($message->getTo() as $to) { if (isset($content['message'])) { $response->addResult($to['email'], $content['message']); } elseif ((string) $result->getBody() !== '') { $response->addResult($to['email'], (string) $result->getBody()); } else { $response->addResult($to['email'], 'Unknown error'); } } } return $response->toArray(); }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter/Email/Resend.php b/src/Utopia/Messaging/Adapter/Email/Resend.php index 6fdbfe19..850d93d6 100644 --- a/src/Utopia/Messaging/Adapter/Email/Resend.php +++ b/src/Utopia/Messaging/Adapter/Email/Resend.php @@ -2,6 +2,7 @@ namespace Utopia\Messaging\Adapter\Email; +use Psr\Http\Message\ResponseInterface; use Utopia\Messaging\Adapter\Email as EmailAdapter; use Utopia\Messaging\Messages\Email as EmailMessage; use Utopia\Messaging\Response; @@ -156,10 +157,10 @@ private function sendBatch(EmailMessage $message, array $emails, array $headers, body: $emails, ); - $statusCode = $result['statusCode']; + $statusCode = $result->getStatusCode(); if ($statusCode === 200) { - $responseData = $result['response']; + $responseData = \json_decode((string) $result->getBody(), true); if (\is_array($responseData) && isset($responseData['errors']) && ! empty($responseData['errors'])) { $failedIndices = []; @@ -184,13 +185,13 @@ private function sendBatch(EmailMessage $message, array $emails, array $headers, } } } elseif ($statusCode >= 400 && $statusCode < 500) { - $errorMessage = $this->extractErrorMessage($result['response'], 'Unknown error'); + $errorMessage = $this->extractErrorMessage($result, 'Unknown error'); foreach ($message->getTo() as $to) { $response->addResult($to['email'], $errorMessage); } } elseif ($statusCode >= 500) { - $errorMessage = $this->extractErrorMessage($result['response'], 'Server error'); + $errorMessage = $this->extractErrorMessage($result, 'Server error'); foreach ($message->getTo() as $to) { $response->addResult($to['email'], $errorMessage); @@ -220,16 +221,16 @@ private function sendIndividually(EmailMessage $message, array $emails, array $h body: $email, ); - $statusCode = $result['statusCode']; + $statusCode = $result->getStatusCode(); if ($statusCode >= 200 && $statusCode < 300) { $response->addResult($to['email']); $deliveredTo++; } elseif ($statusCode >= 400 && $statusCode < 500) { - $errorMessage = $this->extractErrorMessage($result['response'], 'Unknown error'); + $errorMessage = $this->extractErrorMessage($result, 'Unknown error'); $response->addResult($to['email'], $errorMessage); } else { - $errorMessage = $this->extractErrorMessage($result['response'], 'Server error'); + $errorMessage = $this->extractErrorMessage($result, 'Server error'); $response->addResult($to['email'], $errorMessage); } } @@ -239,14 +240,9 @@ private function sendIndividually(EmailMessage $message, array $emails, array $h return $response->toArray(); } - /** - * @param array|string|null $body - */ - private function extractErrorMessage(array|string|null $body, string $default): string + private function extractErrorMessage(ResponseInterface $result, string $default): string { - if (\is_string($body)) { - return $body; - } + $body = \json_decode((string) $result->getBody(), true); if (\is_array($body)) { if (isset($body['message']) && \is_string($body['message'])) { @@ -256,6 +252,8 @@ private function extractErrorMessage(array|string|null $body, string $default): if (isset($body['error']) && \is_string($body['error'])) { return $body['error']; } + } elseif ((string) $result->getBody() !== '') { + return (string) $result->getBody(); } return $default; diff --git a/src/Utopia/Messaging/Adapter/Email/SES.php b/src/Utopia/Messaging/Adapter/Email/SES.php index 6c2617f0..aa8854b0 100644 --- a/src/Utopia/Messaging/Adapter/Email/SES.php +++ b/src/Utopia/Messaging/Adapter/Email/SES.php @@ -1,720 +1 @@ - - */ - private array $ensuredTemplates = []; - - /** - * @param string $accessKey AWS access key ID. - * @param string $secretKey AWS secret access key. - * @param string $region AWS region, e.g. 'us-east-1'. - * @param string|null $sessionToken Optional session token for temporary credentials. - */ - public function __construct( - private readonly string $accessKey, - private readonly string $secretKey, - private readonly string $region, - private readonly ?string $sessionToken = null, - ) { - parent::__construct(); - } - - public function getName(): string - { - return static::NAME; - } - - public function getMaxMessagesPerRequest(): int - { - return self::MAX_DESTINATIONS; - } - - /** - * {@inheritdoc} - */ - protected function process(EmailMessage $message): array - { - $response = new Response($this->getType()); - - $hasAttachments = ! \is_null($message->getAttachments()) && $message->getAttachments() !== []; - - if ($hasAttachments) { - return $this->sendRaw($message, $response); - } - - return $this->sendBulk($message, $response); - } - - /** - * Primary path: template-based bulk send via SES SendBulkEmail. - * - * @return array{deliveredTo: int, type: string, results: array>} - * - * @throws \Exception - */ - private function sendBulk(EmailMessage $message, Response $response): array - { - $templateName = $this->templateName($message); - - $cc = array_map( - fn(array $recipient): string => $this->formatAddress($recipient['email'], $recipient['name'] ?? null), - $message->getCC() ?? [], - ); - $bcc = array_map( - fn(array $recipient): string => $this->formatAddress($recipient['email'], $recipient['name'] ?? null), - $message->getBCC() ?? [], - ); - - $entries = array_map( - function (array $to) use ($cc, $bcc): array { - $destination = ['ToAddresses' => [$to['email']]]; - - if ($cc !== []) { - $destination['CcAddresses'] = $cc; - } - if ($bcc !== []) { - $destination['BccAddresses'] = $bcc; - } - - return [ - 'Destination' => $destination, - 'ReplacementEmailContent' => [ - 'ReplacementTemplate' => [ - 'ReplacementTemplateData' => '{}', - ], - ], - ]; - }, - $message->getTo(), - ); - - $body = [ - 'FromEmailAddress' => $this->formatAddress($message->getFromEmail(), $message->getFromName()), - 'DefaultContent' => [ - 'Template' => [ - 'TemplateName' => $templateName, - 'TemplateData' => '{}', - ], - ], - 'BulkEmailEntries' => $entries, - ]; - - if (!\in_array($message->getReplyToEmail(), ['', '0'], true)) { - $body['ReplyToAddresses'] = [ - $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), - ]; - } - - $result = $this->dispatch('POST', '/v2/email/outbound-bulk-emails', $body); - - // If the template does not exist yet, create it once and retry. - if ($this->isTemplateMissing($result)) { - $this->ensureTemplate($message, $templateName); - $result = $this->dispatch('POST', '/v2/email/outbound-bulk-emails', $body); - } - - return $this->parseBulkResult($message, $result, $response); - } - - /** - * Fallback path: one SES SendEmail (Content.Raw) request per recipient. - * Used when the message carries attachments, which SES templates cannot - * represent. - * - * @return array{deliveredTo: int, type: string, results: array>} - * - * @throws \Exception - */ - private function sendRaw(EmailMessage $message, Response $response): array - { - $this->assertAttachmentSize($message); - - $deliveredTo = 0; - - foreach ($message->getTo() as $to) { - $mime = $this->buildMime($message, $to); - - if (\strlen($mime) > self::MAX_ATTACHMENT_BYTES) { - throw new \Exception('MIME message size exceeds SES limit of ' . self::MAX_ATTACHMENT_BYTES . ' bytes'); - } - - $body = [ - 'FromEmailAddress' => $this->formatAddress($message->getFromEmail(), $message->getFromName()), - 'Destination' => [ - 'ToAddresses' => [$to['email']], - ], - 'Content' => [ - 'Raw' => [ - 'Data' => base64_encode($mime), - ], - ], - ]; - - if (!\in_array($message->getReplyToEmail(), ['', '0'], true)) { - $body['ReplyToAddresses'] = [ - $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), - ]; - } - - $result = $this->dispatch('POST', '/v2/email/outbound-emails', $body); - - $statusCode = $result['statusCode']; - - if ($statusCode >= 200 && $statusCode < 300) { - $response->addResult($to['email']); - $deliveredTo++; - } else { - $response->addResult($to['email'], $this->errorMessage($result)); - } - } - - $response->setDeliveredTo($deliveredTo); - - return $response->toArray(); - } - - /** - * Map a SendBulkEmail response to per-recipient results. - * - * On a whole-request failure (non-2xx) every recipient in the batch is - * marked failed with the SES error. On success each recipient is mapped - * from its corresponding BulkEmailEntryResults entry. - * - * @param array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null} $result - * @return array{deliveredTo: int, type: string, results: array>} - */ - private function parseBulkResult(EmailMessage $message, array $result, Response $response): array - { - $recipients = $message->getTo(); - $statusCode = $result['statusCode']; - - if ($statusCode < 200 || $statusCode >= 300) { - $error = $this->errorMessage($result); - foreach ($recipients as $to) { - $response->addResult($to['email'], $error); - } - - return $response->toArray(); - } - - $entryResults = \is_array($result['response']) - ? ($result['response']['BulkEmailEntryResults'] ?? null) - : null; - - if (! \is_array($entryResults)) { - // 2xx without parseable BulkEmailEntryResults: per-recipient - // delivery cannot be confirmed, so report failure rather than - // false-positive successes. - $error = 'SES returned a success status without per-recipient results'; - foreach ($recipients as $to) { - $response->addResult($to['email'], $error); - } - - return $response->toArray(); - } - - $deliveredTo = 0; - - foreach ($recipients as $index => $to) { - $entry = $entryResults[$index] ?? null; - $status = \is_array($entry) ? ($entry['Status'] ?? null) : null; - - if ($status === self::STATUS_SUCCESS) { - $response->addResult($to['email']); - $deliveredTo++; - } else { - $error = (\is_array($entry) ? ($entry['Error'] ?? null) : null) - ?: ($status ?? 'Unknown error'); - $response->addResult($to['email'], $error); - } - } - - $response->setDeliveredTo($deliveredTo); - - return $response->toArray(); - } - - /** - * Ensure the content-hash template exists in the SES account, creating it - * from the message's subject/HTML/text if necessary. Idempotent per - * instance and tolerant of concurrent creation (AlreadyExistsException). - * - * @throws \Exception - */ - private function ensureTemplate(EmailMessage $message, string $templateName): void - { - if (isset($this->ensuredTemplates[$templateName])) { - return; - } - - $content = $message->isHtml() - ? ['Subject' => $message->getSubject(), 'Html' => $message->getContent()] - : ['Subject' => $message->getSubject(), 'Text' => $message->getContent()]; - - $result = $this->dispatch('POST', '/v2/email/templates', [ - 'TemplateName' => $templateName, - 'TemplateContent' => $content, - ]); - - $statusCode = $result['statusCode']; - $created = $statusCode >= 200 && $statusCode < 300; - $alreadyExists = $this->errorType($result) === 'AlreadyExistsException'; - - if (! $created && ! $alreadyExists) { - throw new \Exception('SES failed to create email template: ' . $this->errorMessage($result)); - } - - $this->ensuredTemplates[$templateName] = true; - } - - /** - * Derive a deterministic, SES-valid template name from the message content - * so identical content reuses a single template across batches and sends. - * - * The SHA-256 hash is truncated so the prefixed name stays within the SES - * 64-character template-name limit; the retained length still leaves ample - * entropy to keep distinct content on distinct templates. - * - * Note: templates created via {@see ensureTemplate()} are never deleted, so - * one persists per unique (subject, content, isHtml) triple. High-variety - * or multi-tenant senders should periodically purge stale `utopia-` - * templates to stay under the per-account template quota (default 20,000). - */ - private function templateName(EmailMessage $message): string - { - $hash = hash('sha256', implode("\0", [ - $message->getSubject(), - $message->getContent(), - $message->isHtml() ? '1' : '0', - ])); - - $hashLength = self::TEMPLATE_NAME_MAX_LENGTH - \strlen(self::TEMPLATE_NAME_PREFIX); - - return self::TEMPLATE_NAME_PREFIX . substr($hash, 0, $hashLength); - } - - /** - * Whether a SendBulkEmail result indicates the referenced template is - * missing, via either the top-level error or per-entry statuses. - * - * @param array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null} $result - */ - private function isTemplateMissing(array $result): bool - { - $errorType = $this->errorType($result); - if ($errorType === 'NotFoundException' || $errorType === 'BadRequestException') { - // BadRequestException is generic, so confirm the message is about a - // missing template rather than another template error (e.g. invalid - // template content). - $message = strtolower($this->errorMessage($result)); - if ( - str_contains($message, 'template') - && (str_contains($message, 'does not exist') || str_contains($message, 'not found')) - ) { - return true; - } - } - - $entryResults = \is_array($result['response'] ?? null) - ? ($result['response']['BulkEmailEntryResults'] ?? null) - : null; - - if (\is_array($entryResults)) { - foreach ($entryResults as $entry) { - $status = \is_array($entry) ? ($entry['Status'] ?? null) : null; - if ($status === 'TEMPLATE_NOT_FOUND' || $status === 'TEMPLATE_DOES_NOT_EXIST') { - return true; - } - } - } - - return false; - } - - /** - * Build a raw RFC 5322 MIME message (with attachments) for a single - * recipient using PHPMailer's pre-send assembly. - * - * @param array $to - * - * @throws \Exception - */ - private function buildMime(EmailMessage $message, array $to): string - { - $mail = new PHPMailer(true); - $mail->CharSet = 'UTF-8'; - $mail->Subject = $message->getSubject(); - $mail->Body = $message->getContent(); - $mail->setFrom($message->getFromEmail(), $message->getFromName()); - $mail->addReplyTo($message->getReplyToEmail(), $message->getReplyToName()); - $mail->isHTML($message->isHtml()); - - if ($message->isHtml()) { - $alt = preg_replace('/]*>(.*?)<\/style>/is', '', $message->getContent()); - $mail->AltBody = trim(strip_tags($alt ?? '')); - } - - $mail->addAddress($to['email'], $to['name'] ?? ''); - - foreach ($message->getCC() ?? [] as $cc) { - $mail->addCC($cc['email'], $cc['name'] ?? ''); - } - - foreach ($message->getBCC() ?? [] as $bcc) { - $mail->addBCC($bcc['email'], $bcc['name'] ?? ''); - } - - foreach ($message->getAttachments() ?? [] as $attachment) { - $content = $attachment->getContent(); - if ($content === null) { - $data = file_get_contents($attachment->getPath()); - if ($data === false) { - throw new \Exception('Failed to read attachment file: ' . $attachment->getPath()); - } - $content = $data; - } - - $mail->addStringAttachment( - string: $content, - filename: $attachment->getName(), - encoding: PHPMailer::ENCODING_BASE64, - type: $attachment->getType(), - ); - } - - if (! $mail->preSend()) { - throw new \Exception('Failed to build MIME message: ' . $mail->ErrorInfo); - } - - return $mail->getSentMIMEMessage(); - } - - /** - * Validate total attachment size against the adapter limit. - * - * @throws \Exception - */ - private function assertAttachmentSize(EmailMessage $message): void - { - $size = 0; - - foreach ($message->getAttachments() ?? [] as $attachment) { - if ($attachment->getContent() !== null) { - $size += \strlen($attachment->getContent()); - } else { - $fileSize = filesize($attachment->getPath()); - if ($fileSize === false) { - throw new \Exception('Failed to read attachment file: ' . $attachment->getPath()); - } - $size += $fileSize; - } - } - - if ($size > self::MAX_ATTACHMENT_BYTES) { - throw new \Exception('Total attachment size exceeds ' . self::MAX_ATTACHMENT_BYTES . ' bytes'); - } - } - - /** - * Format an email address with an optional display name (RFC 5322). - * - * When the display name contains any RFC 5322 special character it is - * wrapped in a quoted-string (with embedded quotes and backslashes - * escaped). Without this, a name such as "Acme, Inc." produces a malformed - * address that SES rejects with a 400. - */ - private function formatAddress(string $email, ?string $name): string - { - if (\in_array($name, [null, '', '0'], true)) { - return $email; - } - - if (preg_match('/[,;:@<>()\[\]\\\\".]/', $name)) { - $name = '"' . addcslashes($name, '"\\') . '"'; - } - - return "{$name} <{$email}>"; - } - - /** - * Sign and dispatch a request to the SES API v2 endpoint for the - * configured region. - * - * @param array $body - * @return array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null} - * - * @throws \Exception - */ - private function dispatch(string $method, string $path, array $body): array - { - $host = 'email.' . $this->region . '.amazonaws.com'; - $payload = json_encode($body, JSON_THROW_ON_ERROR); - - $headers = $this->signature($method, $host, $path, $payload); - $headers[] = 'Content-Type: application/json'; - - return $this->request( - method: $method, - url: 'https://' . $host . $path, - headers: $headers, - body: $body, - ); - } - - /** - * Build the AWS Signature Version 4 request headers using the current - * timestamp. - * - * The signed headers are content-type, host and x-amz-date (plus - * x-amz-security-token when temporary credentials are used). The returned - * list contains the Host, X-Amz-Date, optional X-Amz-Security-Token and - * Authorization headers; the caller adds Content-Type. - * - * @return array - * - * @link https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html - */ - private function signature(string $method, string $host, string $path, string $payload): array - { - $amzDate = gmdate('Ymd\THis\Z'); - - $signed = [ - 'content-type' => 'application/json', - 'host' => $host, - 'x-amz-date' => $amzDate, - ]; - - if (!\in_array($this->sessionToken, [null, '', '0'], true)) { - $signed['x-amz-security-token'] = $this->sessionToken; - } - - $authorization = $this->sign($method, $path, $payload, $signed, $amzDate); - - $headers = [ - 'Host: ' . $host, - 'X-Amz-Date: ' . $amzDate, - 'Authorization: ' . $authorization, - ]; - - if (!\in_array($this->sessionToken, [null, '', '0'], true)) { - $headers[] = 'X-Amz-Security-Token: ' . $this->sessionToken; - } - - return $headers; - } - - /** - * Compute the AWS Signature Version 4 Authorization header value. - * - * Pure function of its inputs (no clock, no network): canonical request → - * string to sign → signing key → signature. Exposed as protected so the - * signing can be verified against AWS's published test vectors. - * - * Header names in $signedHeaders must be lowercase; they are sorted and - * joined to form both the canonical headers block and the SignedHeaders - * list, per the SigV4 specification. - * - * @param array $signedHeaders Lowercase header name => value. - * - * @link https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html - */ - protected function sign(string $method, string $path, string $payload, array $signedHeaders, string $amzDate): string - { - ksort($signedHeaders); - - $canonicalHeaders = ''; - foreach ($signedHeaders as $name => $value) { - $canonicalHeaders .= $name . ':' . trim($value) . "\n"; - } - $signedHeaderList = implode(';', array_keys($signedHeaders)); - - $canonicalRequest = implode("\n", [ - $method, - $path, - '', - $canonicalHeaders, - $signedHeaderList, - hash('sha256', $payload), - ]); - - $dateStamp = substr($amzDate, 0, 8); - $credentialScope = $dateStamp . '/' . $this->region . '/' . $this->service . '/aws4_request'; - - $stringToSign = implode("\n", [ - self::ALGORITHM, - $amzDate, - $credentialScope, - hash('sha256', $canonicalRequest), - ]); - - $signingKey = $this->signingKey($dateStamp); - $signature = hash_hmac('sha256', $stringToSign, $signingKey); - - return self::ALGORITHM - . ' Credential=' . $this->accessKey . '/' . $credentialScope - . ', SignedHeaders=' . $signedHeaderList - . ', Signature=' . $signature; - } - - /** - * Derive the SigV4 signing key for the given date via the HMAC-SHA256 - * chain over date, region, service and the aws4_request terminator. - */ - private function signingKey(string $dateStamp): string - { - $kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $this->secretKey, true); - $kRegion = hash_hmac('sha256', $this->region, $kDate, true); - $kService = hash_hmac('sha256', $this->service, $kRegion, true); - - return hash_hmac('sha256', 'aws4_request', $kService, true); - } - - /** - * Extract a human-readable error message from a SES error response. - * - * @param array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null} $result - */ - private function errorMessage(array $result): string - { - $body = $result['response']; - - if (\is_array($body)) { - if (isset($body['message']) && \is_string($body['message'])) { - return $body['message']; - } - if (isset($body['Message']) && \is_string($body['Message'])) { - return $body['Message']; - } - } - - if (\is_string($body) && $body !== '') { - return $body; - } - - if (! empty($result['error'])) { - return $result['error']; - } - - return 'Unknown error'; - } - - /** - * Extract the SES error type, e.g. "AlreadyExistsException" or - * "NotFoundException". - * - * SES API v2 uses the AWS REST-JSON protocol, which reports the exception - * type in the x-amzn-ErrorType response header, not the body. The header - * value can carry a trailing ":" which is stripped. Older AWS - * JSON-protocol responses instead carry it in a `__type` (optionally - * "prefix#Type") or `code` body field, which is used as a fallback. - * - * @param array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null} $result - */ - private function errorType(array $result): ?string - { - $header = $result['headers']['x-amzn-errortype'] ?? null; - if (\is_string($header) && $header !== '') { - return trim(explode(':', $header)[0]); - } - - $body = $result['response']; - if (\is_array($body)) { - $type = $body['__type'] ?? $body['code'] ?? null; - if (\is_string($type)) { - // __type can be "prefix#AlreadyExistsException"; keep the suffix. - $parts = explode('#', $type); - - return end($parts); - } - } - - return null; - } -} + */ private array $ensuredTemplates = []; /** * @param string $accessKey AWS access key ID. * @param string $secretKey AWS secret access key. * @param string $region AWS region, e.g. 'us-east-1'. * @param string|null $sessionToken Optional session token for temporary credentials. */ public function __construct( private string $accessKey, private string $secretKey, private string $region, private ?string $sessionToken = null, ) { parent::__construct(); } public function getName(): string { return static::NAME; } public function getMaxMessagesPerRequest(): int { return self::MAX_DESTINATIONS; } /** * {@inheritdoc} */ protected function process(EmailMessage $message): array { $response = new Response($this->getType()); $hasAttachments = ! \is_null($message->getAttachments()) && ! empty($message->getAttachments()); if ($hasAttachments) { return $this->sendRaw($message, $response); } return $this->sendBulk($message, $response); } /** * Primary path: template-based bulk send via SES SendBulkEmail. * * @return array{deliveredTo: int, type: string, results: array>} * * @throws \Exception */ private function sendBulk(EmailMessage $message, Response $response): array { $templateName = $this->templateName($message); $cc = \array_map( fn ($recipient) => $this->formatAddress($recipient['email'], $recipient['name'] ?? null), $message->getCC() ?? [] ); $bcc = \array_map( fn ($recipient) => $this->formatAddress($recipient['email'], $recipient['name'] ?? null), $message->getBCC() ?? [] ); $entries = \array_map( function ($to) use ($cc, $bcc) { $destination = ['ToAddresses' => [$to['email']]]; if (! empty($cc)) { $destination['CcAddresses'] = $cc; } if (! empty($bcc)) { $destination['BccAddresses'] = $bcc; } return [ 'Destination' => $destination, 'ReplacementEmailContent' => [ 'ReplacementTemplate' => [ 'ReplacementTemplateData' => '{}', ], ], ]; }, $message->getTo() ); $body = [ 'FromEmailAddress' => $this->formatAddress($message->getFromEmail(), $message->getFromName()), 'DefaultContent' => [ 'Template' => [ 'TemplateName' => $templateName, 'TemplateData' => '{}', ], ], 'BulkEmailEntries' => $entries, ]; if (! empty($message->getReplyToEmail())) { $body['ReplyToAddresses'] = [ $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), ]; } $result = $this->dispatch('POST', '/v2/email/outbound-bulk-emails', $body); // If the template does not exist yet, create it once and retry. if ($this->isTemplateMissing($result)) { $this->ensureTemplate($message, $templateName); $result = $this->dispatch('POST', '/v2/email/outbound-bulk-emails', $body); } return $this->parseBulkResult($message, $result, $response); } /** * Fallback path: one SES SendEmail (Content.Raw) request per recipient. * Used when the message carries attachments, which SES templates cannot * represent. * * @return array{deliveredTo: int, type: string, results: array>} * * @throws \Exception */ private function sendRaw(EmailMessage $message, Response $response): array { $this->assertAttachmentSize($message); $deliveredTo = 0; foreach ($message->getTo() as $to) { $mime = $this->buildMime($message, $to); if (\strlen($mime) > self::MAX_ATTACHMENT_BYTES) { throw new \Exception('MIME message size exceeds SES limit of '.self::MAX_ATTACHMENT_BYTES.' bytes'); } $body = [ 'FromEmailAddress' => $this->formatAddress($message->getFromEmail(), $message->getFromName()), 'Destination' => [ 'ToAddresses' => [$to['email']], ], 'Content' => [ 'Raw' => [ 'Data' => \base64_encode($mime), ], ], ]; if (! empty($message->getReplyToEmail())) { $body['ReplyToAddresses'] = [ $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), ]; } $result = $this->dispatch('POST', '/v2/email/outbound-emails', $body); $statusCode = $result->getStatusCode(); if ($statusCode >= 200 && $statusCode < 300) { $response->addResult($to['email']); $deliveredTo++; } else { $response->addResult($to['email'], $this->errorMessage($result)); } } $response->setDeliveredTo($deliveredTo); return $response->toArray(); } /** * Map a SendBulkEmail response to per-recipient results. * * On a whole-request failure (non-2xx) every recipient in the batch is * marked failed with the SES error. On success each recipient is mapped * from its corresponding BulkEmailEntryResults entry. * * @return array{deliveredTo: int, type: string, results: array>} */ private function parseBulkResult(EmailMessage $message, ResponseInterface $result, Response $response): array { $recipients = $message->getTo(); $statusCode = $result->getStatusCode(); if ($statusCode < 200 || $statusCode >= 300) { $error = $this->errorMessage($result); foreach ($recipients as $to) { $response->addResult($to['email'], $error); } return $response->toArray(); } $body = \json_decode((string) $result->getBody(), true); $entryResults = \is_array($body) ? ($body['BulkEmailEntryResults'] ?? null) : null; if (! \is_array($entryResults)) { // 2xx without parseable BulkEmailEntryResults: per-recipient // delivery cannot be confirmed, so report failure rather than // false-positive successes. $error = 'SES returned a success status without per-recipient results'; foreach ($recipients as $to) { $response->addResult($to['email'], $error); } return $response->toArray(); } $deliveredTo = 0; foreach ($recipients as $index => $to) { $entry = $entryResults[$index] ?? null; $status = \is_array($entry) ? ($entry['Status'] ?? null) : null; if ($status === self::STATUS_SUCCESS) { $response->addResult($to['email']); $deliveredTo++; } else { $error = (\is_array($entry) ? ($entry['Error'] ?? null) : null) ?: ($status ?? 'Unknown error'); $response->addResult($to['email'], $error); } } $response->setDeliveredTo($deliveredTo); return $response->toArray(); } /** * Ensure the content-hash template exists in the SES account, creating it * from the message's subject/HTML/text if necessary. Idempotent per * instance and tolerant of concurrent creation (AlreadyExistsException). * * @throws \Exception */ private function ensureTemplate(EmailMessage $message, string $templateName): void { if (isset($this->ensuredTemplates[$templateName])) { return; } $content = $message->isHtml() ? ['Subject' => $message->getSubject(), 'Html' => $message->getContent()] : ['Subject' => $message->getSubject(), 'Text' => $message->getContent()]; $result = $this->dispatch('POST', '/v2/email/templates', [ 'TemplateName' => $templateName, 'TemplateContent' => $content, ]); $statusCode = $result->getStatusCode(); $created = $statusCode >= 200 && $statusCode < 300; $alreadyExists = $this->errorType($result) === 'AlreadyExistsException'; if (! $created && ! $alreadyExists) { throw new \Exception('SES failed to create email template: '.$this->errorMessage($result)); } $this->ensuredTemplates[$templateName] = true; } /** * Derive a deterministic, SES-valid template name from the message content * so identical content reuses a single template across batches and sends. * * The SHA-256 hash is truncated so the prefixed name stays within the SES * 64-character template-name limit; the retained length still leaves ample * entropy to keep distinct content on distinct templates. * * Note: templates created via {@see ensureTemplate()} are never deleted, so * one persists per unique (subject, content, isHtml) triple. High-variety * or multi-tenant senders should periodically purge stale `utopia-` * templates to stay under the per-account template quota (default 20,000). */ private function templateName(EmailMessage $message): string { $hash = \hash('sha256', \implode("\0", [ $message->getSubject(), $message->getContent(), $message->isHtml() ? '1' : '0', ])); $hashLength = self::TEMPLATE_NAME_MAX_LENGTH - \strlen(self::TEMPLATE_NAME_PREFIX); return self::TEMPLATE_NAME_PREFIX.\substr($hash, 0, $hashLength); } /** * Whether a SendBulkEmail result indicates the referenced template is * missing, via either the top-level error or per-entry statuses. * */ private function isTemplateMissing(ResponseInterface $result): bool { $errorType = $this->errorType($result); if ($errorType === 'NotFoundException' || $errorType === 'BadRequestException') { // BadRequestException is generic, so confirm the message is about a // missing template rather than another template error (e.g. invalid // template content). $message = \strtolower($this->errorMessage($result)); if ( \str_contains($message, 'template') && (\str_contains($message, 'does not exist') || \str_contains($message, 'not found')) ) { return true; } } $body = \json_decode((string) $result->getBody(), true); $entryResults = \is_array($body) ? ($body['BulkEmailEntryResults'] ?? null) : null; if (\is_array($entryResults)) { foreach ($entryResults as $entry) { $status = \is_array($entry) ? ($entry['Status'] ?? null) : null; if ($status === 'TEMPLATE_NOT_FOUND' || $status === 'TEMPLATE_DOES_NOT_EXIST') { return true; } } } return false; } /** * Build a raw RFC 5322 MIME message (with attachments) for a single * recipient using PHPMailer's pre-send assembly. * * @param array $to * * @throws \Exception */ private function buildMime(EmailMessage $message, array $to): string { $mail = new PHPMailer(true); $mail->CharSet = 'UTF-8'; $mail->Subject = $message->getSubject(); $mail->Body = $message->getContent(); $mail->setFrom($message->getFromEmail(), $message->getFromName()); $mail->addReplyTo($message->getReplyToEmail(), $message->getReplyToName()); $mail->isHTML($message->isHtml()); if ($message->isHtml()) { $alt = \preg_replace('/]*>(.*?)<\/style>/is', '', $message->getContent()); $mail->AltBody = \trim(\strip_tags($alt ?? '')); } $mail->addAddress($to['email'], $to['name'] ?? ''); foreach ($message->getCC() ?? [] as $cc) { $mail->addCC($cc['email'], $cc['name'] ?? ''); } foreach ($message->getBCC() ?? [] as $bcc) { $mail->addBCC($bcc['email'], $bcc['name'] ?? ''); } foreach ($message->getAttachments() ?? [] as $attachment) { $content = $attachment->getContent(); if ($content === null) { $data = \file_get_contents($attachment->getPath()); if ($data === false) { throw new \Exception('Failed to read attachment file: '.$attachment->getPath()); } $content = $data; } $mail->addStringAttachment( string: $content, filename: $attachment->getName(), encoding: PHPMailer::ENCODING_BASE64, type: $attachment->getType(), ); } if (! $mail->preSend()) { throw new \Exception('Failed to build MIME message: '.$mail->ErrorInfo); } return $mail->getSentMIMEMessage(); } /** * Validate total attachment size against the adapter limit. * * @throws \Exception */ private function assertAttachmentSize(EmailMessage $message): void { $size = 0; foreach ($message->getAttachments() ?? [] as $attachment) { if ($attachment->getContent() !== null) { $size += \strlen($attachment->getContent()); } else { $fileSize = \filesize($attachment->getPath()); if ($fileSize === false) { throw new \Exception('Failed to read attachment file: '.$attachment->getPath()); } $size += $fileSize; } } if ($size > self::MAX_ATTACHMENT_BYTES) { throw new \Exception('Total attachment size exceeds '.self::MAX_ATTACHMENT_BYTES.' bytes'); } } /** * Format an email address with an optional display name (RFC 5322). * * When the display name contains any RFC 5322 special character it is * wrapped in a quoted-string (with embedded quotes and backslashes * escaped). Without this, a name such as "Acme, Inc." produces a malformed * address that SES rejects with a 400. */ private function formatAddress(string $email, ?string $name): string { if (empty($name)) { return $email; } if (\preg_match('/[,;:@<>()\[\]\\\\".]/', $name)) { $name = '"'.\addcslashes($name, '"\\').'"'; } return "{$name} <{$email}>"; } /** * Sign and dispatch a request to the SES API v2 endpoint for the * configured region. * * @param array $body * * @throws \Exception */ private function dispatch(string $method, string $path, array $body): ResponseInterface { $host = 'email.'.$this->region.'.amazonaws.com'; $payload = \json_encode($body, JSON_THROW_ON_ERROR); $headers = $this->signature($method, $host, $path, $payload); $headers[] = 'Content-Type: application/json'; return $this->request( method: $method, url: 'https://'.$host.$path, headers: $headers, body: $body, ); } /** * Build the AWS Signature Version 4 request headers using the current * timestamp. * * The signed headers are content-type, host and x-amz-date (plus * x-amz-security-token when temporary credentials are used). The returned * list contains the Host, X-Amz-Date, optional X-Amz-Security-Token and * Authorization headers; the caller adds Content-Type. * * @return array * * @link https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html */ private function signature(string $method, string $host, string $path, string $payload): array { $amzDate = \gmdate('Ymd\THis\Z'); $signed = [ 'content-type' => 'application/json', 'host' => $host, 'x-amz-date' => $amzDate, ]; if (! empty($this->sessionToken)) { $signed['x-amz-security-token'] = $this->sessionToken; } $authorization = $this->sign($method, $path, $payload, $signed, $amzDate); $headers = [ 'Host: '.$host, 'X-Amz-Date: '.$amzDate, 'Authorization: '.$authorization, ]; if (! empty($this->sessionToken)) { $headers[] = 'X-Amz-Security-Token: '.$this->sessionToken; } return $headers; } /** * Compute the AWS Signature Version 4 Authorization header value. * * Pure function of its inputs (no clock, no network): canonical request GåÆ * string to sign GåÆ signing key GåÆ signature. Exposed as protected so the * signing can be verified against AWS's published test vectors. * * Header names in $signedHeaders must be lowercase; they are sorted and * joined to form both the canonical headers block and the SignedHeaders * list, per the SigV4 specification. * * @param array $signedHeaders Lowercase header name => value. * * @link https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html */ protected function sign(string $method, string $path, string $payload, array $signedHeaders, string $amzDate): string { \ksort($signedHeaders); $canonicalHeaders = ''; foreach ($signedHeaders as $name => $value) { $canonicalHeaders .= $name.':'.\trim($value)."\n"; } $signedHeaderList = \implode(';', \array_keys($signedHeaders)); $canonicalRequest = \implode("\n", [ $method, $path, '', $canonicalHeaders, $signedHeaderList, \hash('sha256', $payload), ]); $dateStamp = \substr($amzDate, 0, 8); $credentialScope = $dateStamp.'/'.$this->region.'/'.$this->service.'/aws4_request'; $stringToSign = \implode("\n", [ self::ALGORITHM, $amzDate, $credentialScope, \hash('sha256', $canonicalRequest), ]); $signingKey = $this->signingKey($dateStamp); $signature = \hash_hmac('sha256', $stringToSign, $signingKey); return self::ALGORITHM .' Credential='.$this->accessKey.'/'.$credentialScope .', SignedHeaders='.$signedHeaderList .', Signature='.$signature; } /** * Derive the SigV4 signing key for the given date via the HMAC-SHA256 * chain over date, region, service and the aws4_request terminator. */ private function signingKey(string $dateStamp): string { $kDate = \hash_hmac('sha256', $dateStamp, 'AWS4'.$this->secretKey, true); $kRegion = \hash_hmac('sha256', $this->region, $kDate, true); $kService = \hash_hmac('sha256', $this->service, $kRegion, true); return \hash_hmac('sha256', 'aws4_request', $kService, true); } /** * Extract a human-readable error message from a SES error response. * */ private function errorMessage(ResponseInterface $result): string { $body = \json_decode((string) $result->getBody(), true); if (\is_array($body)) { if (isset($body['message']) && \is_string($body['message'])) { return $body['message']; } if (isset($body['Message']) && \is_string($body['Message'])) { return $body['Message']; } } $raw = (string) $result->getBody(); if ($raw !== '') { return $raw; } return 'Unknown error'; } /** * Extract the SES error type, e.g. "AlreadyExistsException" or * "NotFoundException". * * SES API v2 uses the AWS REST-JSON protocol, which reports the exception * type in the x-amzn-ErrorType response header, not the body. The header * value can carry a trailing ":" which is stripped. Older AWS * JSON-protocol responses instead carry it in a `__type` (optionally * "prefix#Type") or `code` body field, which is used as a fallback. * */ private function errorType(ResponseInterface $result): ?string { $header = $result->getHeaderLine('x-amzn-errortype'); if ($header !== '') { return \trim(\explode(':', $header)[0]); } $body = \json_decode((string) $result->getBody(), true); if (\is_array($body)) { $type = $body['__type'] ?? $body['code'] ?? null; if (\is_string($type)) { // __type can be "prefix#AlreadyExistsException"; keep the suffix. $parts = \explode('#', $type); return \end($parts); } } return null; }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter/Email/Sendgrid.php b/src/Utopia/Messaging/Adapter/Email/Sendgrid.php index 2766b841..f6133f0f 100644 --- a/src/Utopia/Messaging/Adapter/Email/Sendgrid.php +++ b/src/Utopia/Messaging/Adapter/Email/Sendgrid.php @@ -135,7 +135,7 @@ protected function process(EmailMessage $message): array body: $body, ); - $statusCode = $result['statusCode']; + $statusCode = $result->getStatusCode(); if ($statusCode === 202) { $response->setDeliveredTo(\count($message->getTo())); @@ -143,11 +143,12 @@ protected function process(EmailMessage $message): array $response->addResult($to['email']); } } else { + $content = \json_decode((string) $result->getBody(), true); foreach ($message->getTo() as $to) { - if (\is_string($result['response'])) { - $response->addResult($to['email'], $result['response']); - } elseif (!\is_null($result['response']['errors'][0]['message'] ?? null)) { - $response->addResult($to['email'], $result['response']['errors'][0]['message']); + if (!\is_null($content['errors'][0]['message'] ?? null)) { + $response->addResult($to['email'], $content['errors'][0]['message']); + } elseif ((string) $result->getBody() !== '') { + $response->addResult($to['email'], (string) $result->getBody()); } else { $response->addResult($to['email'], 'Unknown error'); } diff --git a/src/Utopia/Messaging/Adapter/Push/APNS.php b/src/Utopia/Messaging/Adapter/Push/APNS.php index d3266297..35ac30a8 100644 --- a/src/Utopia/Messaging/Adapter/Push/APNS.php +++ b/src/Utopia/Messaging/Adapter/Push/APNS.php @@ -1,144 +1 @@ -getTitle())) { - $payload['aps']['alert']['title'] = $message->getTitle(); - } - if (!\is_null($message->getBody())) { - $payload['aps']['alert']['body'] = $message->getBody(); - } - if (!\is_null($message->getData())) { - $payload['aps']['data'] = $message->getData(); - } - if (!\is_null($message->getAction())) { - $payload['aps']['category'] = $message->getAction(); - } - if (!\is_null($message->getCritical())) { - $payload['aps']['sound']['critical'] = 1; - $payload['aps']['sound']['name'] = 'default'; - $payload['aps']['sound']['volume'] = 1.0; - } - if (!\is_null($message->getSound())) { - if (!\is_null($message->getCritical())) { - $payload['aps']['sound']['name'] = $message->getSound(); - } else { - $payload['aps']['sound'] = $message->getSound(); - } - } - if (!\is_null($message->getBadge())) { - $payload['aps']['badge'] = $message->getBadge(); - } - if (!\is_null($message->getContentAvailable())) { - $payload['aps']['content-available'] = (int) $message->getContentAvailable(); - } - if (!\is_null($message->getPriority())) { - $payload['headers']['apns-priority'] = match ($message->getPriority()) { - Priority::HIGH => '10', - Priority::NORMAL => '5', - }; - } - - $claims = [ - 'iss' => $this->teamId, // Issuer - 'iat' => time(), // Issued at time - 'exp' => time() + 3600, // Expiration time - ]; - - $jwt = JWT::encode( - $claims, - $this->authKey, - 'ES256', - $this->authKeyId, - ); - - $endpoint = 'https://api.push.apple.com'; - - if ($this->sandbox) { - $endpoint = 'https://api.development.push.apple.com'; - } - - $urls = []; - foreach ($message->getTo() as $token) { - $urls[] = $endpoint . '/3/device/' . $token; - } - - $results = $this->requestMulti( - method: 'POST', - urls: $urls, - headers: [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . $jwt, - 'apns-topic: ' . $this->bundleId, - 'apns-push-type: alert', - ], - bodies: [$payload], - ); - - $response = new Response($this->getType()); - - foreach ($results as $result) { - $device = basename($result['url']); - $statusCode = $result['statusCode']; - - switch ($statusCode) { - case 200: - $response->incrementDeliveredTo(); - $response->addResult($device); - break; - default: - $error = ($result['response']['reason'] ?? null) === 'ExpiredToken' || ($result['response']['reason'] ?? null) === 'BadDeviceToken' - ? $this->getExpiredErrorMessage() - : ($result['response']['reason'] ?? ($result['error'] ?: 'Unknown error')); - - $response->addResult($device, $error); - break; - } - } - - return $response->toArray(); - } -} +getTitle())) { $payload['aps']['alert']['title'] = $message->getTitle(); } if (!\is_null($message->getBody())) { $payload['aps']['alert']['body'] = $message->getBody(); } if (!\is_null($message->getData())) { $payload['aps']['data'] = $message->getData(); } if (!\is_null($message->getAction())) { $payload['aps']['category'] = $message->getAction(); } if (!\is_null($message->getCritical())) { $payload['aps']['sound']['critical'] = 1; $payload['aps']['sound']['name'] = 'default'; $payload['aps']['sound']['volume'] = 1.0; } if (!\is_null($message->getSound())) { if (!\is_null($message->getCritical())) { $payload['aps']['sound']['name'] = $message->getSound(); } else { $payload['aps']['sound'] = $message->getSound(); } } if (!\is_null($message->getBadge())) { $payload['aps']['badge'] = $message->getBadge(); } if (!\is_null($message->getContentAvailable())) { $payload['aps']['content-available'] = (int)$message->getContentAvailable(); } if (!\is_null($message->getPriority())) { $payload['headers']['apns-priority'] = match ($message->getPriority()) { Priority::HIGH => '10', Priority::NORMAL => '5', }; } $claims = [ 'iss' => $this->teamId, // Issuer 'iat' => \time(), // Issued at time 'exp' => \time() + 3600, // Expiration time ]; $jwt = JWT::encode( $claims, $this->authKey, 'ES256', $this->authKeyId ); $endpoint = 'https://api.push.apple.com'; if ($this->sandbox) { $endpoint = 'https://api.development.push.apple.com'; } $urls = []; foreach ($message->getTo() as $token) { $urls[] = $endpoint.'/3/device/'.$token; } $results = $this->requestMulti( method: 'POST', urls: $urls, headers: [ 'Content-Type: application/json', 'Authorization: Bearer '.$jwt, 'apns-topic: '.$this->bundleId, 'apns-push-type: alert', ], bodies: [$payload] ); $response = new Response($this->getType()); foreach ($results as $index => $result) { $device = $message->getTo()[$index]; $statusCode = $result->getStatusCode(); switch ($statusCode) { case 200: $response->incrementDeliveredTo(); $response->addResult($device); break; default: $body = \json_decode((string) $result->getBody(), true); $error = ($body['reason'] ?? null) === 'ExpiredToken' || ($body['reason'] ?? null) === 'BadDeviceToken' ? $this->getExpiredErrorMessage() : $body['reason'] ?? null; $response->addResult($device, $error); break; } } return $response->toArray(); }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter/Push/FCM.php b/src/Utopia/Messaging/Adapter/Push/FCM.php index d9a8f8e4..d3a405a4 100644 --- a/src/Utopia/Messaging/Adapter/Push/FCM.php +++ b/src/Utopia/Messaging/Adapter/Push/FCM.php @@ -1,206 +1 @@ -serviceAccountJSON, true); - - $now = time(); - - $signingKey = $credentials['private_key']; - $signingAlgorithm = 'RS256'; - - $payload = [ - 'iss' => $credentials['client_email'], - 'exp' => $now + self::DEFAULT_EXPIRY_SECONDS, - 'iat' => $now - self::DEFAULT_SKEW_SECONDS, - 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', - 'aud' => self::GOOGLE_TOKEN_URL, - ]; - - $jwt = JWT::encode( - $payload, - $signingKey, - $signingAlgorithm, - ); - - $token = $this->request( - method: 'POST', - url: self::GOOGLE_TOKEN_URL, - headers: [ - 'Content-Type: application/x-www-form-urlencoded', - ], - body: [ - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'assertion' => $jwt, - ], - ); - - if ($token['statusCode'] !== 200 || !\is_array($token['response']) || !isset($token['response']['access_token'])) { - throw new \Exception('Failed to obtain FCM access token: ' . ($token['error'] !== '' ? $token['error'] : 'HTTP ' . $token['statusCode'])); - } - - $accessToken = $token['response']['access_token']; - - $shared = []; - - if (!\is_null($message->getTitle())) { - $shared['message']['notification']['title'] = $message->getTitle(); - } - if (!\is_null($message->getBody())) { - $shared['message']['notification']['body'] = $message->getBody(); - } - if (!\is_null($message->getData())) { - $shared['message']['data'] = $message->getData(); - } - if (!\is_null($message->getAction())) { - $shared['message']['android']['notification']['click_action'] = $message->getAction(); - $shared['message']['apns']['payload']['aps']['category'] = $message->getAction(); - } - if (!\is_null($message->getImage())) { - $shared['message']['android']['notification']['image'] = $message->getImage(); - $shared['message']['apns']['payload']['aps']['mutable-content'] = 1; - $shared['message']['apns']['fcm_options']['image'] = $message->getImage(); - } - if (!\is_null($message->getCritical())) { - $shared['message']['apns']['payload']['aps']['sound']['critical'] = 1; - } - if (!\is_null($message->getSound())) { - $shared['message']['android']['notification']['sound'] = $message->getSound(); - - if (!\is_null($message->getCritical())) { - $shared['message']['apns']['payload']['aps']['sound']['name'] = $message->getSound(); - } else { - $shared['message']['apns']['payload']['aps']['sound'] = $message->getSound(); - } - } - if (!\is_null($message->getIcon())) { - $shared['message']['android']['notification']['icon'] = $message->getIcon(); - } - if (!\is_null($message->getColor())) { - $shared['message']['android']['notification']['color'] = $message->getColor(); - } - if (!\is_null($message->getTag())) { - $shared['message']['android']['notification']['tag'] = $message->getTag(); - } - if (!\is_null($message->getBadge())) { - $shared['message']['apns']['payload']['aps']['badge'] = $message->getBadge(); - } - if (!\is_null($message->getContentAvailable())) { - $shared['message']['apns']['payload']['aps']['content-available'] = (int) $message->getContentAvailable(); - } - if (!\is_null($message->getPriority())) { - $shared['message']['android']['priority'] = match ($message->getPriority()) { - Priority::HIGH => 'high', - Priority::NORMAL => 'normal' - }; - $shared['message']['apns']['headers']['apns-priority'] = match ($message->getPriority()) { - Priority::HIGH => '10', - Priority::NORMAL => '5', - }; - } - - $bodies = []; - - foreach ($message->getTo() as $to) { - $body = $shared; - $body['message']['token'] = $to; - $bodies[] = $body; - } - - $results = $this->requestMulti( - method: 'POST', - urls: ["https://fcm.googleapis.com/v1/projects/{$credentials['project_id']}/messages:send"], - headers: [ - 'Content-Type: application/json', - "Authorization: Bearer {$accessToken}", - ], - bodies: $bodies, - ); - - $response = new Response($this->getType()); - - foreach ($results as $result) { - if ($result['statusCode'] === 200) { - $response->incrementDeliveredTo(); - $response->addResult($message->getTo()[$result['index']]); - } else { - $response->addResult($message->getTo()[$result['index']], $this->getError($result)); - } - } - - return $response->toArray(); - } - - /** - * @param array{ - * statusCode: int, - * response: array|string|null, - * error: string|null, - * errorCode: int - * } $result - */ - protected function getError(array $result): string - { - $response = \is_array($result['response']) ? $result['response'] : []; - $error = \is_array($response['error'] ?? null) ? $response['error'] : []; - - if (\in_array($error['status'] ?? null, ['UNREGISTERED', 'NOT_FOUND'], true)) { - return $this->getExpiredErrorMessage(); - } - - $message = $error['message'] ?? null; - if (\is_string($message) && $message !== '') { - return $message; - } - - $transportError = $result['error'] ?? null; - $details = "HTTP status {$result['statusCode']}; cURL error code {$result['errorCode']}"; - - return \is_string($transportError) && $transportError !== '' - ? "{$transportError} ({$details})" - : "Request failed ({$details})"; - } -} +serviceAccountJSON, true); $now = \time(); $signingKey = $credentials['private_key']; $signingAlgorithm = 'RS256'; $payload = [ 'iss' => $credentials['client_email'], 'exp' => $now + self::DEFAULT_EXPIRY_SECONDS, 'iat' => $now - self::DEFAULT_SKEW_SECONDS, 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'aud' => self::GOOGLE_TOKEN_URL, ]; $jwt = JWT::encode( $payload, $signingKey, $signingAlgorithm, ); $signingKey = null; $payload = null; $token = $this->request( method: 'POST', url: self::GOOGLE_TOKEN_URL, headers: [ 'Content-Type: application/x-www-form-urlencoded', ], body: [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ] ); $accessToken = \json_decode((string) $token->getBody(), true)['access_token'] ?? null; $shared = []; if (!\is_null($message->getTitle())) { $shared['message']['notification']['title'] = $message->getTitle(); } if (!\is_null($message->getBody())) { $shared['message']['notification']['body'] = $message->getBody(); } if (!\is_null($message->getData())) { $shared['message']['data'] = $message->getData(); } if (!\is_null($message->getAction())) { $shared['message']['android']['notification']['click_action'] = $message->getAction(); $shared['message']['apns']['payload']['aps']['category'] = $message->getAction(); } if (!\is_null($message->getImage())) { $shared['message']['android']['notification']['image'] = $message->getImage(); $shared['message']['apns']['payload']['aps']['mutable-content'] = 1; $shared['message']['apns']['fcm_options']['image'] = $message->getImage(); } if (!\is_null($message->getCritical())) { $shared['message']['apns']['payload']['aps']['sound']['critical'] = 1; } if (!\is_null($message->getSound())) { $shared['message']['android']['notification']['sound'] = $message->getSound(); if (!\is_null($message->getCritical())) { $shared['message']['apns']['payload']['aps']['sound']['name'] = $message->getSound(); } else { $shared['message']['apns']['payload']['aps']['sound'] = $message->getSound(); } } if (!\is_null($message->getIcon())) { $shared['message']['android']['notification']['icon'] = $message->getIcon(); } if (!\is_null($message->getColor())) { $shared['message']['android']['notification']['color'] = $message->getColor(); } if (!\is_null($message->getTag())) { $shared['message']['android']['notification']['tag'] = $message->getTag(); } if (!\is_null($message->getBadge())) { $shared['message']['apns']['payload']['aps']['badge'] = $message->getBadge(); } if (!\is_null($message->getContentAvailable())) { $shared['message']['apns']['payload']['aps']['content-available'] = (int)$message->getContentAvailable(); } if (!\is_null($message->getPriority())) { $shared['message']['android']['priority'] = match ($message->getPriority()) { Priority::HIGH => 'high', Priority::NORMAL => 'normal' }; $shared['message']['apns']['headers']['apns-priority'] = match ($message->getPriority()) { Priority::HIGH => '10', Priority::NORMAL => '5', }; } $bodies = []; foreach ($message->getTo() as $to) { $body = $shared; $body['message']['token'] = $to; $bodies[] = $body; } $results = $this->requestMulti( method: 'POST', urls: ["https://fcm.googleapis.com/v1/projects/{$credentials['project_id']}/messages:send"], headers: [ 'Content-Type: application/json', "Authorization: Bearer {$accessToken}", ], bodies: $bodies ); $response = new Response($this->getType()); foreach ($results as $index => $result) { if ($result->getStatusCode() === 200) { $response->incrementDeliveredTo(); $response->addResult($message->getTo()[$index]); } else { $body = \json_decode((string) $result->getBody(), true); $error = ($body['error']['status'] ?? null) === 'UNREGISTERED' || ($body['error']['status'] ?? null) === 'NOT_FOUND' ? $this->getExpiredErrorMessage() : $body['error']['message'] ?? 'Unknown error'; $response->addResult($message->getTo()[$index], $error); } } return $response->toArray(); }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter/SMS/Clickatell.php b/src/Utopia/Messaging/Adapter/SMS/Clickatell.php index 08407638..4785abf0 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Clickatell.php +++ b/src/Utopia/Messaging/Adapter/SMS/Clickatell.php @@ -55,7 +55,7 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Fast2SMS.php b/src/Utopia/Messaging/Adapter/SMS/Fast2SMS.php index 210d8e42..5c738a5b 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Fast2SMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/Fast2SMS.php @@ -95,8 +95,8 @@ protected function process(SMSMessage $message): array body: $payload, ); - $res = $result['response']; - if ($result['statusCode'] === 200 && isset($res['return']) && $res['return'] === true) { + $res = \json_decode((string) $result->getBody(), true); + if ($result->getStatusCode() === 200 && isset($res['return']) && $res['return'] === true) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Infobip.php b/src/Utopia/Messaging/Adapter/SMS/Infobip.php index f1a0295c..74209159 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Infobip.php +++ b/src/Utopia/Messaging/Adapter/SMS/Infobip.php @@ -61,7 +61,7 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Inforu.php b/src/Utopia/Messaging/Adapter/SMS/Inforu.php index a7ed1a19..53301400 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Inforu.php +++ b/src/Utopia/Messaging/Adapter/SMS/Inforu.php @@ -1,82 +1 @@ -getType()); - - $recipients = array_map( - fn(string $number): array => ['Phone' => ltrim($number, '+')], - $message->getTo(), - ); - - $result = $this->request( - method: 'POST', - url: 'https://capi.inforu.co.il/api/v2/SMS/SendSms', - headers: [ - 'Content-Type: application/json', - 'Authorization: Basic ' . $this->apiToken, - ], - body: [ - 'Data' => [ - 'Message' => $message->getContent(), - 'Recipients' => $recipients, - 'Settings' => [ - 'Sender' => $this->senderId, - ], - ], - ], - ); - - if ($result['statusCode'] === 200 && ($result['response']['StatusId'] ?? 0) === 1) { - $response->setDeliveredTo(\count($message->getTo())); - foreach ($message->getTo() as $to) { - $response->addResult($to); - } - } else { - $errorMessage = $result['response']['StatusDescription'] ?? 'Unknown error'; - foreach ($message->getTo() as $to) { - $response->addResult($to, $errorMessage); - } - } - - return $response->toArray(); - } -} +getType()); $recipients = array_map( fn ($number) => ['Phone' => ltrim($number, '+')], $message->getTo() ); $result = $this->request( method: 'POST', url: 'https://capi.inforu.co.il/api/v2/SMS/SendSms', headers: [ 'Content-Type: application/json', 'Authorization: Basic ' . $this->apiToken, ], body: [ 'Data' => [ 'Message' => $message->getContent(), 'Recipients' => $recipients, 'Settings' => [ 'Sender' => $this->senderId, ], ], ], ); $body = \json_decode((string) $result->getBody(), true); if ($result->getStatusCode() === 200 && ($body['StatusId'] ?? 0) === 1) { $response->setDeliveredTo(count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); } } else { $errorMessage = $body['StatusDescription'] ?? 'Unknown error'; foreach ($message->getTo() as $to) { $response->addResult($to, $errorMessage); } } return $response->toArray(); }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter/SMS/Mock.php b/src/Utopia/Messaging/Adapter/SMS/Mock.php index a2e3ff12..7daea361 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Mock.php +++ b/src/Utopia/Messaging/Adapter/SMS/Mock.php @@ -70,7 +70,7 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] === 200) { + if ($result->getStatusCode() === 200) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91.php b/src/Utopia/Messaging/Adapter/SMS/Msg91.php index da32c7ed..3be58bbc 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Msg91.php +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91.php @@ -94,7 +94,7 @@ protected function process(SMSMessage $message): array body: $body, ); - if ($result['statusCode'] === 200) { + if ($result->getStatusCode() === 200) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Plivo.php b/src/Utopia/Messaging/Adapter/SMS/Plivo.php index 696dc4fd..d6d641e3 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Plivo.php +++ b/src/Utopia/Messaging/Adapter/SMS/Plivo.php @@ -57,7 +57,7 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Seven.php b/src/Utopia/Messaging/Adapter/SMS/Seven.php index 071560e3..a6447e06 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Seven.php +++ b/src/Utopia/Messaging/Adapter/SMS/Seven.php @@ -55,7 +55,7 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Sinch.php b/src/Utopia/Messaging/Adapter/SMS/Sinch.php index b367ff48..9d8aff17 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Sinch.php +++ b/src/Utopia/Messaging/Adapter/SMS/Sinch.php @@ -59,7 +59,7 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/Telesign.php b/src/Utopia/Messaging/Adapter/SMS/Telesign.php index c6f2a068..7d4a8279 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Telesign.php +++ b/src/Utopia/Messaging/Adapter/SMS/Telesign.php @@ -61,15 +61,16 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] === 200) { + if ($result->getStatusCode() === 200) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); } } else { + $body = \json_decode((string) $result->getBody(), true); foreach ($message->getTo() as $to) { - if (!\is_null($result['response']['errors'][0]['description'] ?? null)) { - $response->addResult($to, $result['response']['errors'][0]['description']); + if (!\is_null($body['errors'][0]['description'] ?? null)) { + $response->addResult($to, $body['errors'][0]['description']); } else { $response->addResult($to, 'Unknown error'); } diff --git a/src/Utopia/Messaging/Adapter/SMS/Telnyx.php b/src/Utopia/Messaging/Adapter/SMS/Telnyx.php index 3fa6fb67..c1a570d0 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Telnyx.php +++ b/src/Utopia/Messaging/Adapter/SMS/Telnyx.php @@ -53,7 +53,7 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); diff --git a/src/Utopia/Messaging/Adapter/SMS/TextMagic.php b/src/Utopia/Messaging/Adapter/SMS/TextMagic.php index a18a3c32..6974848c 100644 --- a/src/Utopia/Messaging/Adapter/SMS/TextMagic.php +++ b/src/Utopia/Messaging/Adapter/SMS/TextMagic.php @@ -63,15 +63,16 @@ protected function process(SMSMessage $message): array ], ); - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { $response->addResult($to); } } else { + $body = \json_decode((string) $result->getBody(), true); foreach ($message->getTo() as $to) { - if (!\is_null($result['response']['message'] ?? null)) { - $response->addResult($to, $result['response']['message']); + if (!\is_null($body['message'] ?? null)) { + $response->addResult($to, $body['message']); } else { $response->addResult($to, 'Unknown error'); } diff --git a/src/Utopia/Messaging/Adapter/SMS/Twilio.php b/src/Utopia/Messaging/Adapter/SMS/Twilio.php index f209fda3..01021049 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Twilio.php +++ b/src/Utopia/Messaging/Adapter/SMS/Twilio.php @@ -1,69 +1 @@ -getType()); - - $result = $this->request( - method: 'POST', - url: "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json", - headers: [ - 'Content-Type: application/x-www-form-urlencoded', - 'Authorization: Basic ' . base64_encode("{$this->accountSid}:{$this->authToken}"), - ], - body: [ - 'Body' => $message->getContent(), - 'From' => $this->from ?? $message->getFrom(), - 'MessagingServiceSid' => $this->messagingServiceSid ?? null, - 'To' => $message->getTo()[0], - ], - ); - - if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { - $response->setDeliveredTo(1); - $response->addResult($message->getTo()[0]); - } elseif (!\is_null($result['response']['message'] ?? null)) { - $response->addResult($message->getTo()[0], $result['response']['message']); - } else { - $response->addResult($message->getTo()[0], 'Unknown error'); - } - - return $response->toArray(); - } -} +getType()); $result = $this->request( method: 'POST', url: "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json", headers: [ 'Content-Type: application/x-www-form-urlencoded', 'Authorization: Basic '.base64_encode("{$this->accountSid}:{$this->authToken}"), ], body: [ 'Body' => $message->getContent(), 'From' => $this->from ?? $message->getFrom(), 'MessagingServiceSid' => $this->messagingServiceSid ?? null, 'To' => $message->getTo()[0], ], ); if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { $response->setDeliveredTo(1); $response->addResult($message->getTo()[0]); } else { $body = \json_decode((string) $result->getBody(), true); if (!\is_null($body['message'] ?? null)) { $response->addResult($message->getTo()[0], $body['message']); } else { $response->addResult($message->getTo()[0], 'Unknown error'); } } return $response->toArray(); }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Adapter/SMS/Vonage.php b/src/Utopia/Messaging/Adapter/SMS/Vonage.php index c5441ed6..7287826b 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Vonage.php +++ b/src/Utopia/Messaging/Adapter/SMS/Vonage.php @@ -1,75 +1 @@ - ltrim($to, '+'), - $message->getTo(), - ); - - $response = new Response($this->getType()); - $result = $this->request( - method: 'POST', - url: 'https://rest.nexmo.com/sms/json', - headers: [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - body: [ - 'text' => $message->getContent(), - 'from' => $this->from ?? $message->getFrom(), - 'to' => $to[0], //\implode(',', $to), - 'api_key' => $this->apiKey, - 'api_secret' => $this->apiSecret, - ], - ); - - if (($result['response']['messages'][0]['status'] ?? null) === 0) { - $response->setDeliveredTo(1); - $response->addResult($result['response']['messages'][0]['to']); - } elseif (!\is_null($result['response']['messages'][0]['error-text'] ?? null)) { - $response->addResult($message->getTo()[0], $result['response']['messages'][0]['error-text']); - } else { - $response->addResult($message->getTo()[0], 'Unknown error'); - } - - return $response->toArray(); - } -} + \ltrim($to, '+'), $message->getTo() ); $response = new Response($this->getType()); $result = $this->request( method: 'POST', url: 'https://rest.nexmo.com/sms/json', headers: [ 'Content-Type: application/x-www-form-urlencoded', ], body: [ 'text' => $message->getContent(), 'from' => $this->from ?? $message->getFrom(), 'to' => $to[0], //\implode(',', $to), 'api_key' => $this->apiKey, 'api_secret' => $this->apiSecret, ], ); $body = \json_decode((string) $result->getBody(), true); if (($body['messages'][0]['status'] ?? null) === 0) { $response->setDeliveredTo(1); $response->addResult($body['messages'][0]['to']); } else { if (!\is_null($body['messages'][0]['error-text'] ?? null)) { $response->addResult($message->getTo()[0], $body['messages'][0]['error-text']); } else { $response->addResult($message->getTo()[0], 'Unknown error'); } } return $response->toArray(); }} \ No newline at end of file diff --git a/src/Utopia/Messaging/Helpers/JWT.php b/src/Utopia/Messaging/Helpers/JWT.php index 246a3a8d..e54463ed 100644 --- a/src/Utopia/Messaging/Helpers/JWT.php +++ b/src/Utopia/Messaging/Helpers/JWT.php @@ -1,164 +1 @@ - ['openssl', OPENSSL_ALGO_SHA384], - 'ES256' => ['openssl', OPENSSL_ALGO_SHA256], - 'ES256K' => ['openssl', OPENSSL_ALGO_SHA256], - 'RS256' => ['openssl', OPENSSL_ALGO_SHA256], - 'RS384' => ['openssl', OPENSSL_ALGO_SHA384], - 'RS512' => ['openssl', OPENSSL_ALGO_SHA512], - 'HS256' => ['hash_hmac', 'SHA256'], - 'HS384' => ['hash_hmac', 'SHA384'], - 'HS512' => ['hash_hmac', 'SHA512'], - ]; - - /** - * Convert an array to a JWT, signed with the given key and algorithm. - * - * @param array $payload - * - * @throws \Exception - */ - public static function encode(array $payload, string $key, string $algorithm, ?string $keyId = null): string - { - $header = [ - 'typ' => 'JWT', - 'alg' => $algorithm, - ]; - - if (!\is_null($keyId)) { - $header['kid'] = $keyId; - } - - $header = json_encode($header, \JSON_UNESCAPED_SLASHES); - $payload = json_encode($payload, \JSON_UNESCAPED_SLASHES); - - $segments = []; - $segments[] = self::safeBase64Encode($header); - $segments[] = self::safeBase64Encode($payload); - - $signingMaterial = implode('.', $segments); - - $signature = self::sign($signingMaterial, $key, $algorithm); - - $segments[] = self::safeBase64Encode($signature); - - return implode('.', $segments); - } - - /** - * @throws \Exception - */ - private static function sign(string $data, string $key, string $alg): string - { - if (empty(static::ALGORITHMS[$alg])) { - throw new \Exception('Algorithm not supported'); - } - - [$function, $algorithm] = static::ALGORITHMS[$alg]; - - switch ($function) { - case 'openssl': - $signature = ''; - - $success = openssl_sign($data, $signature, $key, $algorithm); - - if (!$success) { - throw new \Exception('OpenSSL sign failed for JWT'); - } - - switch ($alg) { - case 'ES256': - case 'ES256K': - $signature = self::signatureFromDER($signature, 256); - break; - case 'ES384': - $signature = self::signatureFromDER($signature, 384); - break; - default: - break; - } - - return $signature; - case 'hash_hmac': - return hash_hmac((string) $algorithm, $data, $key, true); - default: - throw new \Exception('Algorithm not supported'); - } - } - - /** - * Encodes signature from a DER object. - * - * @param string $der binary signature in DER format - * @param int $keySize the number of bits in the key - */ - private static function signatureFromDER(string $der, int $keySize): string - { - // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE - [$offset, $_] = self::readDER($der); - [$offset, $r] = self::readDER($der, $offset); - [$_, $s] = self::readDER($der, $offset); - - // Convert r-value and s-value from signed two's compliment to unsigned big-endian integers - $r = ltrim((string) $r, "\x00"); - $s = ltrim((string) $s, "\x00"); - - // Pad out r and s so that they are $keySize bits long - $r = str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT); - $s = str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT); - - return $r . $s; - } - - /** - * Reads binary DER-encoded data and decodes into a single object - * - * @param int $offset - * to decode - * @return array{int, string|null} - */ - private static function readDER(string $der, int $offset = 0): array - { - $pos = $offset; - $size = \strlen($der); - $constructed = (\ord($der[$pos]) >> 5) & 0x01; - $type = \ord($der[$pos++]) & 0x1F; - - // Length - $len = \ord($der[$pos++]); - if (($len & 0x80) !== 0) { - $n = $len & 0x1F; - $len = 0; - while ($n-- && $pos < $size) { - $len = ($len << 8) | \ord($der[$pos++]); - } - } - - // Value - if ($type === 0x03) { - $pos++; // Skip the first contents octet (padding indicator) - $data = substr($der, $pos, $len - 1); - $pos += $len - 1; - } elseif ($constructed === 0) { - $data = substr($der, $pos, $len); - $pos += $len; - } else { - $data = null; - } - - return [$pos, $data]; - } - - /** - * Encode a string with URL-safe Base64. - */ - private static function safeBase64Encode(string $input): string - { - return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($input)); - } -} + ['openssl', OPENSSL_ALGO_SHA384], 'ES256' => ['openssl', OPENSSL_ALGO_SHA256], 'ES256K' => ['openssl', OPENSSL_ALGO_SHA256], 'RS256' => ['openssl', OPENSSL_ALGO_SHA256], 'RS384' => ['openssl', OPENSSL_ALGO_SHA384], 'RS512' => ['openssl', OPENSSL_ALGO_SHA512], 'HS256' => ['hash_hmac', 'SHA256'], 'HS384' => ['hash_hmac', 'SHA384'], 'HS512' => ['hash_hmac', 'SHA512'], ]; /** * Convert an array to a JWT, signed with the given key and algorithm. * * @param array $payload * * @throws \Exception */ public static function encode(array $payload, string $key, string $algorithm, ?string $keyId = null): string { $header = [ 'typ' => 'JWT', 'alg' => $algorithm, ]; if (!\is_null($keyId)) { $header['kid'] = $keyId; } $header = \json_encode($header, \JSON_UNESCAPED_SLASHES); $payload = \json_encode($payload, \JSON_UNESCAPED_SLASHES); $segments = []; $segments[] = self::safeBase64Encode($header); $segments[] = self::safeBase64Encode($payload); $signingMaterial = \implode('.', $segments); $signature = self::sign($signingMaterial, $key, $algorithm); $segments[] = self::safeBase64Encode($signature); return \implode('.', $segments); } /** * @throws \Exception */ private static function sign(string $data, string $key, string $alg): string { if (empty(static::ALGORITHMS[$alg])) { throw new \Exception('Algorithm not supported'); } [$function, $algorithm] = static::ALGORITHMS[$alg]; switch ($function) { case 'openssl': $signature = ''; $success = \openssl_sign($data, $signature, $key, $algorithm); if (!$success) { throw new \Exception('OpenSSL sign failed for JWT'); } switch ($alg) { case 'ES256': case 'ES256K': $signature = self::signatureFromDER($signature, 256); break; case 'ES384': $signature = self::signatureFromDER($signature, 384); break; default: break; } return $signature; case 'hash_hmac': return \hash_hmac($algorithm, $data, $key, true); default: throw new \Exception('Algorithm not supported'); } } /** * Encodes signature from a DER object. * * @param string $der binary signature in DER format * @param int $keySize the number of bits in the key */ private static function signatureFromDER(string $der, int $keySize): string { // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE [$offset, $_] = self::readDER($der); [$offset, $r] = self::readDER($der, $offset); [$_, $s] = self::readDER($der, $offset); // Convert r-value and s-value from signed two's compliment to unsigned big-endian integers $r = \ltrim($r, "\x00"); $s = \ltrim($s, "\x00"); // Pad out r and s so that they are $keySize bits long $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT); $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT); return $r.$s; } /** * Reads binary DER-encoded data and decodes into a single object * * @param int $offset * to decode * @return array{int, string|null} */ private static function readDER(string $der, int $offset = 0): array { $pos = $offset; $size = \strlen($der); $constructed = (\ord($der[$pos]) >> 5) & 0x01; $type = \ord($der[$pos++]) & 0x1F; // Length $len = \ord($der[$pos++]); if ($len & 0x80) { $n = $len & 0x1F; $len = 0; while ($n-- && $pos < $size) { $len = ($len << 8) | \ord($der[$pos++]); } } // Value if ($type === 0x03) { $pos++; // Skip the first contents octet (padding indicator) $data = \substr($der, $pos, $len - 1); $pos += $len - 1; } elseif (!$constructed) { $data = \substr($der, $pos, $len); $pos += $len; } else { $data = null; } return [$pos, $data]; } /** * Encode a string with URL-safe Base64. */ private static function safeBase64Encode(string $input): string { return \str_replace(['+', '/', '='], ['-', '_', ''], \base64_encode($input)); }} \ No newline at end of file diff --git a/tests/Messaging/Adapter/Email/ResendRoutingTest.php b/tests/Messaging/Adapter/Email/ResendRoutingTest.php index b06e5640..6dfd6b03 100644 --- a/tests/Messaging/Adapter/Email/ResendRoutingTest.php +++ b/tests/Messaging/Adapter/Email/ResendRoutingTest.php @@ -1,169 +1 @@ -stubResponses[] = ['statusCode' => 200, 'response' => []]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertCount(1, $stub->capturedRequests); - $this->assertEquals('https://api.resend.com/emails/batch', $stub->capturedRequests[0]['url']); - $this->assertCount(2, $stub->capturedRequests[0]['body']); - $this->assertArrayNotHasKey('attachments', $stub->capturedRequests[0]['body'][0]); - $this->assertEquals(2, $response['deliveredTo']); - } - - public function testWithAttachmentsUsesSingleEndpointPerRecipient(): void - { - $stub = new ResendStub('test-key'); - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']]; - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'two']]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - attachments: [new Attachment( - name: 'note.txt', - path: '', - type: 'text/plain', - content: 'hello', - )], - ); - - $response = $stub->send($message); - - $this->assertCount(2, $stub->capturedRequests); - - foreach ($stub->capturedRequests as $request) { - $this->assertEquals('https://api.resend.com/emails', $request['url']); - $this->assertArrayHasKey('attachments', $request['body']); - $this->assertCount(1, $request['body']['attachments']); - $this->assertEquals('note.txt', $request['body']['attachments'][0]['filename']); - $this->assertEquals('text/plain', $request['body']['attachments'][0]['content_type']); - $this->assertEquals(base64_encode('hello'), $request['body']['attachments'][0]['content']); - } - - $this->assertEquals(2, $response['deliveredTo']); - } - - public function testPartialFailureWithAttachmentsAggregatesResults(): void - { - $stub = new ResendStub('test-key'); - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']]; - $stub->stubResponses[] = ['statusCode' => 422, 'response' => ['message' => 'Invalid recipient']]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - attachments: [new Attachment( - name: 'note.txt', - path: '', - type: 'text/plain', - content: 'hello', - )], - ); - - $response = $stub->send($message); - - $this->assertEquals(1, $response['deliveredTo']); - $this->assertEquals('success', $response['results'][0]['status']); - $this->assertEquals('failure', $response['results'][1]['status']); - $this->assertEquals('Invalid recipient', $response['results'][1]['error']); - } - - public function testAttachmentExceedingMaxSizeThrows(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Total attachment size exceeds'); - - $stub = new ResendStub('test-key'); - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - attachments: [new Attachment( - name: 'large.bin', - path: '', - type: 'application/octet-stream', - content: str_repeat('x', 40 * 1024 * 1024 + 1), - )], - ); - - $stub->send($message); - } -} - -class ResendStub extends Resend -{ - /** - * @var array, body: mixed}> - */ - public array $capturedRequests = []; - - /** - * @var array|string|null}> - */ - public array $stubResponses = []; - - /** - * @param array $headers - * @param array|null $body - * @return array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null, errorCode: int} - */ - #[\Override] - protected function request( - string $method, - string $url, - array $headers = [], - ?array $body = null, - int $timeout = 30, - int $connectTimeout = 10, - ): array { - $this->capturedRequests[] = [ - 'method' => $method, - 'url' => $url, - 'headers' => $headers, - 'body' => $body, - ]; - - $stub = array_shift($this->stubResponses) ?? ['statusCode' => 200, 'response' => []]; - - return [ - 'url' => $url, - 'statusCode' => $stub['statusCode'], - 'response' => $stub['response'], - 'headers' => [], - 'error' => null, - 'errorCode' => 0, - ]; - } -} +stubResponses[] = ['statusCode' => 200, 'response' => []]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertCount(1, $stub->capturedRequests); $this->assertEquals('https://api.resend.com/emails/batch', $stub->capturedRequests[0]['url']); $this->assertCount(2, $stub->capturedRequests[0]['body']); $this->assertArrayNotHasKey('attachments', $stub->capturedRequests[0]['body'][0]); $this->assertEquals(2, $response['deliveredTo']); } public function testWithAttachmentsUsesSingleEndpointPerRecipient(): void { $stub = new ResendStub('test-key'); $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']]; $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'two']]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', attachments: [new Attachment( name: 'note.txt', path: '', type: 'text/plain', content: 'hello', )], ); $response = $stub->send($message); $this->assertCount(2, $stub->capturedRequests); foreach ($stub->capturedRequests as $request) { $this->assertEquals('https://api.resend.com/emails', $request['url']); $this->assertArrayHasKey('attachments', $request['body']); $this->assertCount(1, $request['body']['attachments']); $this->assertEquals('note.txt', $request['body']['attachments'][0]['filename']); $this->assertEquals('text/plain', $request['body']['attachments'][0]['content_type']); $this->assertEquals(\base64_encode('hello'), $request['body']['attachments'][0]['content']); } $this->assertEquals(2, $response['deliveredTo']); } public function testPartialFailureWithAttachmentsAggregatesResults(): void { $stub = new ResendStub('test-key'); $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']]; $stub->stubResponses[] = ['statusCode' => 422, 'response' => ['message' => 'Invalid recipient']]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', attachments: [new Attachment( name: 'note.txt', path: '', type: 'text/plain', content: 'hello', )], ); $response = $stub->send($message); $this->assertEquals(1, $response['deliveredTo']); $this->assertEquals('success', $response['results'][0]['status']); $this->assertEquals('failure', $response['results'][1]['status']); $this->assertEquals('Invalid recipient', $response['results'][1]['error']); } public function testAttachmentExceedingMaxSizeThrows(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Total attachment size exceeds'); $stub = new ResendStub('test-key'); $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', attachments: [new Attachment( name: 'large.bin', path: '', type: 'application/octet-stream', content: \str_repeat('x', 40 * 1024 * 1024 + 1), )], ); $stub->send($message); }}class ResendStub extends Resend{ /** * @var array, body: mixed}> */ public array $capturedRequests = []; /** * @var array|string|null}> */ public array $stubResponses = []; /** * @param array $headers * @param array|null $body */ protected function request( string $method, string $url, array $headers = [], ?array $body = null, int $timeout = 30, int $connectTimeout = 10 ): ResponseInterface { $this->capturedRequests[] = [ 'method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body, ]; $stub = \array_shift($this->stubResponses) ?? ['statusCode' => 200, 'response' => []]; $payload = \is_string($stub['response']) ? $stub['response'] : (string) \json_encode($stub['response']); return new Response($stub['statusCode'], '', new Stream($payload)); }} \ No newline at end of file diff --git a/tests/Messaging/Adapter/Email/SESRoutingTest.php b/tests/Messaging/Adapter/Email/SESRoutingTest.php index d0273b9c..0ee7b146 100644 --- a/tests/Messaging/Adapter/Email/SESRoutingTest.php +++ b/tests/Messaging/Adapter/Email/SESRoutingTest.php @@ -1,691 +1 @@ -assertSame(50, $stub->getMaxMessagesPerRequest()); - } - - public function testWithoutAttachmentsUsesBulkEndpoint(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [ - ['Status' => 'SUCCESS', 'MessageId' => 'a'], - ['Status' => 'SUCCESS', 'MessageId' => 'b'], - ]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertCount(1, $stub->capturedRequests); - $request = $stub->capturedRequests[0]; - - $this->assertSame('POST', $request['method']); - $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $request['url']); - $this->assertStringContainsString('email.us-east-1.amazonaws.com', $request['url']); - - // One BulkEmailEntry per recipient, each with a single ToAddresses entry. - $this->assertCount(2, $request['body']['BulkEmailEntries']); - $this->assertSame(['a@example.com'], $request['body']['BulkEmailEntries'][0]['Destination']['ToAddresses']); - $this->assertSame(['b@example.com'], $request['body']['BulkEmailEntries'][1]['Destination']['ToAddresses']); - - // The default content references a template by name. - $this->assertArrayHasKey('TemplateName', $request['body']['DefaultContent']['Template']); - $this->assertSame('Sender ', $request['body']['FromEmailAddress']); - - $this->assertSame(2, $response['deliveredTo']); - $this->assertSame('success', $response['results'][0]['status']); - $this->assertSame('success', $response['results'][1]['status']); - } - - public function testTemplateNameIsDeterministicForSameContent(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; - - $build = fn(): \Utopia\Messaging\Messages\Email => new Email( - to: [['email' => 'a@example.com']], - subject: 'Same Subject', - content: 'Same Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $stub->send($build()); - $stub->send($build()); - - $first = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; - $second = $stub->capturedRequests[1]['body']['DefaultContent']['Template']['TemplateName']; - - $this->assertSame($first, $second); - $this->assertStringStartsWith('utopia-', $first); - } - - public function testTemplateNameDiffersForDifferentContent(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; - - $stub->send(new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject A', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - )); - - $stub->send(new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject B', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - )); - - $first = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; - $second = $stub->capturedRequests[1]['body']['DefaultContent']['Template']['TemplateName']; - - $this->assertNotSame($first, $second); - } - - public function testTemplateNotFoundTriggersCreateAndRetry(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - - // 1) Bulk send: template missing (per-entry status). - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'TEMPLATE_NOT_FOUND']]], - ]; - // 2) CreateEmailTemplate: created. - $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; - // 3) Bulk send retry: success. - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS', 'MessageId' => 'x']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: '

Body

', - fromName: 'Sender', - fromEmail: 'from@example.com', - html: true, - ); - - $response = $stub->send($message); - - $this->assertCount(3, $stub->capturedRequests); - $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[0]['url']); - $this->assertStringEndsWith('/v2/email/templates', $stub->capturedRequests[1]['url']); - $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[2]['url']); - - // The created template carries the message subject and HTML content. - $templateBody = $stub->capturedRequests[1]['body']; - $this->assertSame('Subject', $templateBody['TemplateContent']['Subject']); - $this->assertSame('

Body

', $templateBody['TemplateContent']['Html']); - $this->assertArrayNotHasKey('Text', $templateBody['TemplateContent']); - - $this->assertSame(1, $response['deliveredTo']); - $this->assertSame('success', $response['results'][0]['status']); - } - - public function testTopLevelMissingTemplateTriggersCreateAndRetry(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - - // The real SES v2 response for a missing DefaultContent template is a - // top-level 400 whose exception type lives only in the x-amzn-ErrorType - // response header, with a plain {"message": ...} body. The auto-create - // must trigger off that, not off a per-entry BulkEmailEntryResults - // status (which SES does not return for a missing default template). - $stub->stubResponses[] = [ - 'statusCode' => 400, - 'headers' => ['x-amzn-errortype' => 'BadRequestException'], - 'response' => ['message' => 'Template utopia-abc123 does not exist.'], - ]; - $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS', 'MessageId' => 'x']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertCount(3, $stub->capturedRequests); - $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[0]['url']); - $this->assertStringEndsWith('/v2/email/templates', $stub->capturedRequests[1]['url']); - $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[2]['url']); - - $this->assertSame(1, $response['deliveredTo']); - $this->assertSame('success', $response['results'][0]['status']); - } - - public function testCreateTemplateToleratesAlreadyExists(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - - // Missing on the first send (top-level 400) ... - $stub->stubResponses[] = [ - 'statusCode' => 400, - 'headers' => ['x-amzn-errortype' => 'BadRequestException'], - 'response' => ['message' => 'Template utopia-abc123 does not exist.'], - ]; - // ... but a concurrent sender created it first, so CreateEmailTemplate - // returns AlreadyExistsException (again, type in the header). That must - // be tolerated rather than surfaced as a failure. - $stub->stubResponses[] = [ - 'statusCode' => 400, - 'headers' => ['x-amzn-errortype' => 'AlreadyExistsException'], - 'response' => ['message' => 'Template utopia-abc123 already exists.'], - ]; - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertCount(3, $stub->capturedRequests); - $this->assertSame(1, $response['deliveredTo']); - $this->assertSame('success', $response['results'][0]['status']); - } - - public function testTextTemplateUsesTextContent(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'TEMPLATE_NOT_FOUND']]], - ]; - $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Plain Subject', - content: 'Plain body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $stub->send($message); - - $templateBody = $stub->capturedRequests[1]['body']; - $this->assertSame('Plain body', $templateBody['TemplateContent']['Text']); - $this->assertArrayNotHasKey('Html', $templateBody['TemplateContent']); - } - - public function testPartialFailureMapsPerRecipientResults(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [ - ['Status' => 'SUCCESS', 'MessageId' => 'ok'], - ['Status' => 'MESSAGE_REJECTED', 'Error' => 'Email address is not verified'], - ]], - ]; - - $message = new Email( - to: [['email' => 'good@example.com'], ['email' => 'bad@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertSame(1, $response['deliveredTo']); - $this->assertSame('success', $response['results'][0]['status']); - $this->assertSame('good@example.com', $response['results'][0]['recipient']); - $this->assertSame('failure', $response['results'][1]['status']); - $this->assertSame('bad@example.com', $response['results'][1]['recipient']); - $this->assertSame('Email address is not verified', $response['results'][1]['error']); - } - - public function testWholeRequestFailureMarksAllRecipientsFailed(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 400, - 'response' => ['message' => 'The sending domain is not verified'], - ]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertSame(0, $response['deliveredTo']); - foreach ($response['results'] as $result) { - $this->assertSame('failure', $result['status']); - $this->assertSame('The sending domain is not verified', $result['error']); - } - } - - public function testFiftyRecipientsProduceSingleBulkRequest(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - - $entryResults = []; - $recipients = []; - for ($i = 0; $i < 50; $i++) { - $recipients[] = ['email' => "user{$i}@example.com"]; - $entryResults[] = ['Status' => 'SUCCESS']; - } - - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => $entryResults], - ]; - - $message = new Email( - to: $recipients, - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertCount(1, $stub->capturedRequests); - $this->assertCount(50, $stub->capturedRequests[0]['body']['BulkEmailEntries']); - $this->assertSame(50, $response['deliveredTo']); - } - - public function testExceedingFiftyRecipientsThrows(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('can only send 50 messages per request'); - - $stub = new SESStub('key', 'secret', 'us-east-1'); - - $recipients = []; - for ($i = 0; $i < 51; $i++) { - $recipients[] = ['email' => "user{$i}@example.com"]; - } - - $message = new Email( - to: $recipients, - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $stub->send($message); - } - - public function testWithAttachmentsUsesSendEmailRawPerRecipient(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'one']]; - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'two']]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - attachments: [new Attachment( - name: 'note.txt', - path: '', - type: 'text/plain', - content: 'hello attachment', - )], - ); - - $response = $stub->send($message); - - $this->assertCount(2, $stub->capturedRequests); - - foreach ($stub->capturedRequests as $request) { - $this->assertStringEndsWith('/v2/email/outbound-emails', $request['url']); - $this->assertArrayHasKey('Raw', $request['body']['Content']); - - $mime = base64_decode((string) $request['body']['Content']['Raw']['Data']); - $this->assertStringContainsString('Subject: Subject', $mime); - $this->assertStringContainsString('note.txt', $mime); - // The attachment content is base64-encoded inside the MIME body. - $this->assertStringContainsString(base64_encode('hello attachment'), $mime); - } - - $this->assertSame(2, $response['deliveredTo']); - } - - public function testAttachmentPartialFailureAggregatesResults(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'one']]; - $stub->stubResponses[] = ['statusCode' => 400, 'response' => ['message' => 'Invalid recipient']]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - attachments: [new Attachment( - name: 'note.txt', - path: '', - type: 'text/plain', - content: 'hello', - )], - ); - - $response = $stub->send($message); - - $this->assertSame(1, $response['deliveredTo']); - $this->assertSame('success', $response['results'][0]['status']); - $this->assertSame('failure', $response['results'][1]['status']); - $this->assertSame('Invalid recipient', $response['results'][1]['error']); - } - - public function testAttachmentExceedingMaxSizeThrows(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Total attachment size exceeds'); - - $stub = new SESStub('key', 'secret', 'us-east-1'); - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - attachments: [new Attachment( - name: 'large.bin', - path: '', - type: 'application/octet-stream', - content: str_repeat('x', 25 * 1024 * 1024 + 1), - )], - ); - - $stub->send($message); - } - - public function testSessionTokenAddsSecurityTokenHeader(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1', 'session-token-value'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $stub->send($message); - - $headers = $stub->capturedRequests[0]['headers']; - $joined = implode("\n", $headers); - - $this->assertStringContainsString('X-Amz-Security-Token: session-token-value', $joined); - // The signed headers list in the Authorization header must include it. - $this->assertStringContainsString('x-amz-security-token', $joined); - } - - public function testBulkEntriesIncludeCcAndBcc(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - cc: [['email' => 'cc@example.com', 'name' => 'CC Person']], - bcc: [['email' => 'bcc@example.com']], - ); - - $stub->send($message); - - $destination = $stub->capturedRequests[0]['body']['BulkEmailEntries'][0]['Destination']; - - $this->assertSame(['a@example.com'], $destination['ToAddresses']); - $this->assertSame(['CC Person '], $destination['CcAddresses']); - $this->assertSame(['bcc@example.com'], $destination['BccAddresses']); - } - - public function testBulkEntriesOmitCcAndBccWhenAbsent(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $stub->send($message); - - $destination = $stub->capturedRequests[0]['body']['BulkEmailEntries'][0]['Destination']; - - $this->assertArrayNotHasKey('CcAddresses', $destination); - $this->assertArrayNotHasKey('BccAddresses', $destination); - } - - public function testDisplayNameWithSpecialCharactersIsQuoted(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Acme, Inc.', - fromEmail: 'from@example.com', - ); - - $stub->send($message); - - // A name containing RFC 5322 specials must be quoted or SES rejects it. - $this->assertSame( - '"Acme, Inc." ', - $stub->capturedRequests[0]['body']['FromEmailAddress'], - ); - } - - public function testTemplateNameRespectsSesLengthLimit(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - $stub->stubResponses[] = [ - 'statusCode' => 200, - 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], - ]; - - $message = new Email( - to: [['email' => 'a@example.com']], - subject: str_repeat('long subject ', 64), - content: str_repeat('long body ', 64), - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $stub->send($message); - - $templateName = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; - - $this->assertLessThanOrEqual(64, \strlen((string) $templateName)); - $this->assertStringStartsWith('utopia-', $templateName); - } - - public function testSuccessWithoutEntryResultsMarksAllRecipientsFailed(): void - { - $stub = new SESStub('key', 'secret', 'us-east-1'); - // A 2xx whose body carries no BulkEmailEntryResults must not be reported - // as a delivery, since per-recipient status cannot be confirmed. - $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; - - $message = new Email( - to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - ); - - $response = $stub->send($message); - - $this->assertSame(0, $response['deliveredTo']); - foreach ($response['results'] as $result) { - $this->assertSame('failure', $result['status']); - $this->assertNotSame('', $result['error']); - } - } - - public function testMimeExceedingSesLimitThrows(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('MIME message size exceeds SES limit'); - - $stub = new SESStub('key', 'secret', 'us-east-1'); - - // ~8MB of raw content clears the raw-attachment check (< 10MB) but its - // base64-encoded MIME exceeds the SES 10MB message limit. - $message = new Email( - to: [['email' => 'a@example.com']], - subject: 'Subject', - content: 'Body', - fromName: 'Sender', - fromEmail: 'from@example.com', - attachments: [new Attachment( - name: 'big.bin', - path: '', - type: 'application/octet-stream', - content: str_repeat('x', 8 * 1024 * 1024), - )], - ); - - $stub->send($message); - } -} - -/** - * Captures the requests the SES adapter would send and returns canned - * responses, so request building and routing can be asserted without network. - */ -class SESStub extends SES -{ - /** - * @var array, body: mixed}> - */ - public array $capturedRequests = []; - - /** - * @var array|string|null, headers?: array}> - */ - public array $stubResponses = []; - - /** - * @param array $headers - * @param array|null $body - * @return array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null, errorCode: int} - */ - #[\Override] - protected function request( - string $method, - string $url, - array $headers = [], - ?array $body = null, - int $timeout = 30, - int $connectTimeout = 10, - ): array { - $this->capturedRequests[] = [ - 'method' => $method, - 'url' => $url, - 'headers' => $headers, - 'body' => $body, - ]; - - $stub = array_shift($this->stubResponses) ?? ['statusCode' => 200, 'response' => []]; - - return [ - 'url' => $url, - 'statusCode' => $stub['statusCode'], - 'response' => $stub['response'], - 'headers' => $stub['headers'] ?? [], - 'error' => null, - 'errorCode' => 0, - ]; - } -} +assertSame(50, $stub->getMaxMessagesPerRequest()); } public function testWithoutAttachmentsUsesBulkEndpoint(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [ ['Status' => 'SUCCESS', 'MessageId' => 'a'], ['Status' => 'SUCCESS', 'MessageId' => 'b'], ]], ]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertCount(1, $stub->capturedRequests); $request = $stub->capturedRequests[0]; $this->assertSame('POST', $request['method']); $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $request['url']); $this->assertStringContainsString('email.us-east-1.amazonaws.com', $request['url']); // One BulkEmailEntry per recipient, each with a single ToAddresses entry. $this->assertCount(2, $request['body']['BulkEmailEntries']); $this->assertSame(['a@example.com'], $request['body']['BulkEmailEntries'][0]['Destination']['ToAddresses']); $this->assertSame(['b@example.com'], $request['body']['BulkEmailEntries'][1]['Destination']['ToAddresses']); // The default content references a template by name. $this->assertArrayHasKey('TemplateName', $request['body']['DefaultContent']['Template']); $this->assertSame('Sender ', $request['body']['FromEmailAddress']); $this->assertSame(2, $response['deliveredTo']); $this->assertSame('success', $response['results'][0]['status']); $this->assertSame('success', $response['results'][1]['status']); } public function testTemplateNameIsDeterministicForSameContent(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; $build = fn () => new Email( to: [['email' => 'a@example.com']], subject: 'Same Subject', content: 'Same Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $stub->send($build()); $stub->send($build()); $first = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; $second = $stub->capturedRequests[1]['body']['DefaultContent']['Template']['TemplateName']; $this->assertSame($first, $second); $this->assertStringStartsWith('utopia-', $first); } public function testTemplateNameDiffersForDifferentContent(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; $stub->send(new Email( to: [['email' => 'a@example.com']], subject: 'Subject A', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', )); $stub->send(new Email( to: [['email' => 'a@example.com']], subject: 'Subject B', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', )); $first = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; $second = $stub->capturedRequests[1]['body']['DefaultContent']['Template']['TemplateName']; $this->assertNotSame($first, $second); } public function testTemplateNotFoundTriggersCreateAndRetry(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); // 1) Bulk send: template missing (per-entry status). $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'TEMPLATE_NOT_FOUND']]], ]; // 2) CreateEmailTemplate: created. $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; // 3) Bulk send retry: success. $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS', 'MessageId' => 'x']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: '

Body

', fromName: 'Sender', fromEmail: 'from@example.com', html: true, ); $response = $stub->send($message); $this->assertCount(3, $stub->capturedRequests); $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[0]['url']); $this->assertStringEndsWith('/v2/email/templates', $stub->capturedRequests[1]['url']); $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[2]['url']); // The created template carries the message subject and HTML content. $templateBody = $stub->capturedRequests[1]['body']; $this->assertSame('Subject', $templateBody['TemplateContent']['Subject']); $this->assertSame('

Body

', $templateBody['TemplateContent']['Html']); $this->assertArrayNotHasKey('Text', $templateBody['TemplateContent']); $this->assertSame(1, $response['deliveredTo']); $this->assertSame('success', $response['results'][0]['status']); } public function testTopLevelMissingTemplateTriggersCreateAndRetry(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); // The real SES v2 response for a missing DefaultContent template is a // top-level 400 whose exception type lives only in the x-amzn-ErrorType // response header, with a plain {"message": ...} body. The auto-create // must trigger off that, not off a per-entry BulkEmailEntryResults // status (which SES does not return for a missing default template). $stub->stubResponses[] = [ 'statusCode' => 400, 'headers' => ['x-amzn-errortype' => 'BadRequestException'], 'response' => ['message' => 'Template utopia-abc123 does not exist.'], ]; $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS', 'MessageId' => 'x']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertCount(3, $stub->capturedRequests); $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[0]['url']); $this->assertStringEndsWith('/v2/email/templates', $stub->capturedRequests[1]['url']); $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[2]['url']); $this->assertSame(1, $response['deliveredTo']); $this->assertSame('success', $response['results'][0]['status']); } public function testCreateTemplateToleratesAlreadyExists(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); // Missing on the first send (top-level 400) ... $stub->stubResponses[] = [ 'statusCode' => 400, 'headers' => ['x-amzn-errortype' => 'BadRequestException'], 'response' => ['message' => 'Template utopia-abc123 does not exist.'], ]; // ... but a concurrent sender created it first, so CreateEmailTemplate // returns AlreadyExistsException (again, type in the header). That must // be tolerated rather than surfaced as a failure. $stub->stubResponses[] = [ 'statusCode' => 400, 'headers' => ['x-amzn-errortype' => 'AlreadyExistsException'], 'response' => ['message' => 'Template utopia-abc123 already exists.'], ]; $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertCount(3, $stub->capturedRequests); $this->assertSame(1, $response['deliveredTo']); $this->assertSame('success', $response['results'][0]['status']); } public function testTextTemplateUsesTextContent(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'TEMPLATE_NOT_FOUND']]], ]; $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Plain Subject', content: 'Plain body', fromName: 'Sender', fromEmail: 'from@example.com', ); $stub->send($message); $templateBody = $stub->capturedRequests[1]['body']; $this->assertSame('Plain body', $templateBody['TemplateContent']['Text']); $this->assertArrayNotHasKey('Html', $templateBody['TemplateContent']); } public function testPartialFailureMapsPerRecipientResults(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [ ['Status' => 'SUCCESS', 'MessageId' => 'ok'], ['Status' => 'MESSAGE_REJECTED', 'Error' => 'Email address is not verified'], ]], ]; $message = new Email( to: [['email' => 'good@example.com'], ['email' => 'bad@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertSame(1, $response['deliveredTo']); $this->assertSame('success', $response['results'][0]['status']); $this->assertSame('good@example.com', $response['results'][0]['recipient']); $this->assertSame('failure', $response['results'][1]['status']); $this->assertSame('bad@example.com', $response['results'][1]['recipient']); $this->assertSame('Email address is not verified', $response['results'][1]['error']); } public function testWholeRequestFailureMarksAllRecipientsFailed(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 400, 'response' => ['message' => 'The sending domain is not verified'], ]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertSame(0, $response['deliveredTo']); foreach ($response['results'] as $result) { $this->assertSame('failure', $result['status']); $this->assertSame('The sending domain is not verified', $result['error']); } } public function testFiftyRecipientsProduceSingleBulkRequest(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $entryResults = []; $recipients = []; for ($i = 0; $i < 50; $i++) { $recipients[] = ['email' => "user{$i}@example.com"]; $entryResults[] = ['Status' => 'SUCCESS']; } $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => $entryResults], ]; $message = new Email( to: $recipients, subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertCount(1, $stub->capturedRequests); $this->assertCount(50, $stub->capturedRequests[0]['body']['BulkEmailEntries']); $this->assertSame(50, $response['deliveredTo']); } public function testExceedingFiftyRecipientsThrows(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('can only send 50 messages per request'); $stub = new SESStub('key', 'secret', 'us-east-1'); $recipients = []; for ($i = 0; $i < 51; $i++) { $recipients[] = ['email' => "user{$i}@example.com"]; } $message = new Email( to: $recipients, subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $stub->send($message); } public function testWithAttachmentsUsesSendEmailRawPerRecipient(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'one']]; $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'two']]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', attachments: [new Attachment( name: 'note.txt', path: '', type: 'text/plain', content: 'hello attachment', )], ); $response = $stub->send($message); $this->assertCount(2, $stub->capturedRequests); foreach ($stub->capturedRequests as $request) { $this->assertStringEndsWith('/v2/email/outbound-emails', $request['url']); $this->assertArrayHasKey('Raw', $request['body']['Content']); $mime = \base64_decode($request['body']['Content']['Raw']['Data']); $this->assertStringContainsString('Subject: Subject', $mime); $this->assertStringContainsString('note.txt', $mime); // The attachment content is base64-encoded inside the MIME body. $this->assertStringContainsString(\base64_encode('hello attachment'), $mime); } $this->assertSame(2, $response['deliveredTo']); } public function testAttachmentPartialFailureAggregatesResults(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'one']]; $stub->stubResponses[] = ['statusCode' => 400, 'response' => ['message' => 'Invalid recipient']]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', attachments: [new Attachment( name: 'note.txt', path: '', type: 'text/plain', content: 'hello', )], ); $response = $stub->send($message); $this->assertSame(1, $response['deliveredTo']); $this->assertSame('success', $response['results'][0]['status']); $this->assertSame('failure', $response['results'][1]['status']); $this->assertSame('Invalid recipient', $response['results'][1]['error']); } public function testAttachmentExceedingMaxSizeThrows(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Total attachment size exceeds'); $stub = new SESStub('key', 'secret', 'us-east-1'); $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', attachments: [new Attachment( name: 'large.bin', path: '', type: 'application/octet-stream', content: \str_repeat('x', 25 * 1024 * 1024 + 1), )], ); $stub->send($message); } public function testSessionTokenAddsSecurityTokenHeader(): void { $stub = new SESStub('key', 'secret', 'us-east-1', 'session-token-value'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $stub->send($message); $headers = $stub->capturedRequests[0]['headers']; $joined = \implode("\n", $headers); $this->assertStringContainsString('X-Amz-Security-Token: session-token-value', $joined); // The signed headers list in the Authorization header must include it. $this->assertStringContainsString('x-amz-security-token', $joined); } public function testBulkEntriesIncludeCcAndBcc(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', cc: [['email' => 'cc@example.com', 'name' => 'CC Person']], bcc: [['email' => 'bcc@example.com']], ); $stub->send($message); $destination = $stub->capturedRequests[0]['body']['BulkEmailEntries'][0]['Destination']; $this->assertSame(['a@example.com'], $destination['ToAddresses']); $this->assertSame(['CC Person '], $destination['CcAddresses']); $this->assertSame(['bcc@example.com'], $destination['BccAddresses']); } public function testBulkEntriesOmitCcAndBccWhenAbsent(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $stub->send($message); $destination = $stub->capturedRequests[0]['body']['BulkEmailEntries'][0]['Destination']; $this->assertArrayNotHasKey('CcAddresses', $destination); $this->assertArrayNotHasKey('BccAddresses', $destination); } public function testDisplayNameWithSpecialCharactersIsQuoted(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Acme, Inc.', fromEmail: 'from@example.com', ); $stub->send($message); // A name containing RFC 5322 specials must be quoted or SES rejects it. $this->assertSame( '"Acme, Inc." ', $stub->capturedRequests[0]['body']['FromEmailAddress'] ); } public function testTemplateNameRespectsSesLengthLimit(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); $stub->stubResponses[] = [ 'statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], ]; $message = new Email( to: [['email' => 'a@example.com']], subject: \str_repeat('long subject ', 64), content: \str_repeat('long body ', 64), fromName: 'Sender', fromEmail: 'from@example.com', ); $stub->send($message); $templateName = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; $this->assertLessThanOrEqual(64, \strlen($templateName)); $this->assertStringStartsWith('utopia-', $templateName); } public function testSuccessWithoutEntryResultsMarksAllRecipientsFailed(): void { $stub = new SESStub('key', 'secret', 'us-east-1'); // A 2xx whose body carries no BulkEmailEntryResults must not be reported // as a delivery, since per-recipient status cannot be confirmed. $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; $message = new Email( to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', ); $response = $stub->send($message); $this->assertSame(0, $response['deliveredTo']); foreach ($response['results'] as $result) { $this->assertSame('failure', $result['status']); $this->assertNotSame('', $result['error']); } } public function testMimeExceedingSesLimitThrows(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('MIME message size exceeds SES limit'); $stub = new SESStub('key', 'secret', 'us-east-1'); // ~8MB of raw content clears the raw-attachment check (< 10MB) but its // base64-encoded MIME exceeds the SES 10MB message limit. $message = new Email( to: [['email' => 'a@example.com']], subject: 'Subject', content: 'Body', fromName: 'Sender', fromEmail: 'from@example.com', attachments: [new Attachment( name: 'big.bin', path: '', type: 'application/octet-stream', content: \str_repeat('x', 8 * 1024 * 1024), )], ); $stub->send($message); }}/** * Captures the requests the SES adapter would send and returns canned * responses, so request building and routing can be asserted without network. */class SESStub extends SES{ /** * @var array, body: mixed}> */ public array $capturedRequests = []; /** * @var array|string|null, headers?: array}> */ public array $stubResponses = []; /** * @param array $headers * @param array|null $body */ protected function request( string $method, string $url, array $headers = [], ?array $body = null, int $timeout = 30, int $connectTimeout = 10 ): ResponseInterface { $this->capturedRequests[] = [ 'method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body, ]; $stub = \array_shift($this->stubResponses) ?? ['statusCode' => 200, 'response' => []]; $payload = \is_string($stub['response']) ? $stub['response'] : (string) \json_encode($stub['response']); $response = new Response($stub['statusCode'], '', new Stream($payload)); foreach ($stub['headers'] ?? [] as $name => $value) { $response = $response->withHeader($name, $value); } return $response; }} \ No newline at end of file