From 53df9cd52477b5506f972b8af91493e0758e881f Mon Sep 17 00:00:00 2001 From: eldadfux Date: Thu, 21 May 2026 21:50:14 -0400 Subject: [PATCH] Update README.md to provide detailed project information, installation instructions, quick start guide, and usage examples for Utopia SMTP, a modern PHP 8.3 toolkit for building SMTP servers and clients. --- .github/workflows/ci.yml | 68 + .gitignore | 4 + Dockerfile | 34 + LICENSE | 21 + README.md | 119 +- composer.json | 46 + composer.lock | 3875 +++++++++++++++++ docker-compose.yml | 16 + phpstan.neon | 8 + phpunit.xml | 8 + pint.json | 4 + src/SMTP/Adapter.php | 32 + src/SMTP/Adapter/Native.php | 165 + src/SMTP/Adapter/Swoole.php | 98 + src/SMTP/Client.php | 184 + src/SMTP/Connection.php | 19 + src/SMTP/Connection/Socket.php | 100 + src/SMTP/Connection/Swoole.php | 78 + .../Exception/Message/DecodingException.php | 10 + .../Exception/Protocol/DecodingException.php | 10 + src/SMTP/Handler.php | 18 + src/SMTP/Handler/Memory.php | 74 + src/SMTP/Handler/Proxy.php | 36 + src/SMTP/Handler/Result.php | 9 + src/SMTP/Message.php | 216 + src/SMTP/Message/Address.php | 53 + src/SMTP/Protocol.php | 235 + src/SMTP/Protocol/Command.php | 51 + src/SMTP/Protocol/Response.php | 52 + src/SMTP/Server.php | 168 + src/SMTP/Transport.php | 16 + src/SMTP/Transport/Socket.php | 30 + tests/e2e/SMTP/ClientTest.php | 45 + tests/resources/server.php | 26 + tests/unit/SMTP/Adapter/AdapterTestCase.php | 52 + tests/unit/SMTP/Adapter/NativeTest.php | 19 + tests/unit/SMTP/Adapter/SwooleTest.php | 21 + tests/unit/SMTP/ClientTest.php | 17 + .../SMTP/Connection/BufferedConnection.php | 60 + .../Connection/BufferedConnectionTest.php | 14 + .../SMTP/Connection/ConnectionTestCase.php | 48 + tests/unit/SMTP/Connection/SocketTest.php | 110 + tests/unit/SMTP/Connection/SwooleTest.php | 63 + tests/unit/SMTP/Handler/MemoryTest.php | 30 + tests/unit/SMTP/Message/AddressTest.php | 40 + tests/unit/SMTP/MessageTest.php | 89 + tests/unit/SMTP/Protocol/CommandTest.php | 25 + tests/unit/SMTP/Protocol/ResponseTest.php | 41 + tests/unit/SMTP/ProtocolTest.php | 92 + tests/unit/SMTP/Server/SwooleServerTest.php | 73 + tests/unit/SMTP/ServerTest.php | 108 + tests/unit/SMTP/Transport/SocketTest.php | 16 + 52 files changed, 6844 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docker-compose.yml create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 pint.json create mode 100644 src/SMTP/Adapter.php create mode 100644 src/SMTP/Adapter/Native.php create mode 100644 src/SMTP/Adapter/Swoole.php create mode 100644 src/SMTP/Client.php create mode 100644 src/SMTP/Connection.php create mode 100644 src/SMTP/Connection/Socket.php create mode 100644 src/SMTP/Connection/Swoole.php create mode 100644 src/SMTP/Exception/Message/DecodingException.php create mode 100644 src/SMTP/Exception/Protocol/DecodingException.php create mode 100644 src/SMTP/Handler.php create mode 100644 src/SMTP/Handler/Memory.php create mode 100644 src/SMTP/Handler/Proxy.php create mode 100644 src/SMTP/Handler/Result.php create mode 100644 src/SMTP/Message.php create mode 100644 src/SMTP/Message/Address.php create mode 100644 src/SMTP/Protocol.php create mode 100644 src/SMTP/Protocol/Command.php create mode 100644 src/SMTP/Protocol/Response.php create mode 100644 src/SMTP/Server.php create mode 100644 src/SMTP/Transport.php create mode 100644 src/SMTP/Transport/Socket.php create mode 100644 tests/e2e/SMTP/ClientTest.php create mode 100644 tests/resources/server.php create mode 100644 tests/unit/SMTP/Adapter/AdapterTestCase.php create mode 100644 tests/unit/SMTP/Adapter/NativeTest.php create mode 100644 tests/unit/SMTP/Adapter/SwooleTest.php create mode 100644 tests/unit/SMTP/ClientTest.php create mode 100644 tests/unit/SMTP/Connection/BufferedConnection.php create mode 100644 tests/unit/SMTP/Connection/BufferedConnectionTest.php create mode 100644 tests/unit/SMTP/Connection/ConnectionTestCase.php create mode 100644 tests/unit/SMTP/Connection/SocketTest.php create mode 100644 tests/unit/SMTP/Connection/SwooleTest.php create mode 100644 tests/unit/SMTP/Handler/MemoryTest.php create mode 100644 tests/unit/SMTP/Message/AddressTest.php create mode 100644 tests/unit/SMTP/MessageTest.php create mode 100644 tests/unit/SMTP/Protocol/CommandTest.php create mode 100644 tests/unit/SMTP/Protocol/ResponseTest.php create mode 100644 tests/unit/SMTP/ProtocolTest.php create mode 100644 tests/unit/SMTP/Server/SwooleServerTest.php create mode 100644 tests/unit/SMTP/ServerTest.php create mode 100644 tests/unit/SMTP/Transport/SocketTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4924f69 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: [pull_request] + +jobs: + format: + name: Format + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run format check + run: | + docker run --rm -v $PWD:/app composer:2.7 sh -c \ + "composer install --profile --ignore-platform-reqs && composer format:check" + + analyze: + name: Analyze + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run static analysis + run: | + docker run --rm -v $PWD:/app composer:2.7 sh -c \ + "composer install --profile --ignore-platform-reqs && composer analyze" + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run unit tests + run: | + docker run --rm -v $PWD:/app composer:2.7 sh -c \ + "composer install --profile --ignore-platform-reqs && composer test -- --testsuite unit" + + e2e-tests: + name: E2E Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: smtp-dev + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start SMTP Server + run: | + docker compose up -d + sleep 5 + + - name: Run E2E tests + run: docker compose exec -T smtp-server vendor/bin/phpunit --configuration phpunit.xml --testsuite e2e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b08168 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/.idea/ +out.log +.phpunit.result.cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02b9a8a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM composer:2.7 AS composer + +ARG TESTING=false +ENV TESTING=$TESTING + +WORKDIR /usr/local/src/ + +COPY composer.lock /usr/local/src/ +COPY composer.json /usr/local/src/ + +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist + +FROM appwrite/base:0.11.3 AS final + +LABEL maintainer="team@appwrite.io" + +WORKDIR /usr/src/code + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" + +RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini + +RUN echo "memory_limit=1024M" >> $PHP_INI_DIR/php.ini + +COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor + +COPY ./tests /usr/src/code/tests +COPY ./src /usr/src/code/src +COPY ./phpunit.xml /usr/src/code/phpunit.xml +COPY ./phpstan.neon /usr/src/code/phpstan.neon +COPY ./pint.json /usr/src/code/pint.json + +CMD [ "tail", "-f", "/dev/null" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ac621e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 utopia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index be5046b..b590d10 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,117 @@ -# smtp -Lite & fast micro PHP SMTP framework that is easy to learn. +# Utopia SMTP + +[![Tests](https://github.com/utopia-php/smtp/actions/workflows/ci.yml/badge.svg)](https://github.com/utopia-php/smtp/actions/workflows/ci.yml) +[![Packagist Version](https://img.shields.io/packagist/v/utopia-php/smtp.svg)](https://packagist.org/packages/utopia-php/smtp) + +Utopia SMTP is a modern PHP 8.3 toolkit for building SMTP servers and clients. It provides a fully-typed RFC 5321/5322 message encoder/decoder, pluggable handlers, transports, and telemetry hooks so you can receive and relay email with minimal effort. + +Although part of the [Utopia Framework](https://github.com/utopia-php/framework) family, the library is framework-agnostic and can be used in any PHP project. + +## Installation + +```bash +composer require utopia-php/smtp +``` + +The library requires PHP 8.3+ with the `ext-sockets` extension. The Swoole adapter additionally needs the `ext-swoole` extension. + +## Quick start + +Create an SMTP server by wiring an adapter (TCP socket implementation) and a handler (how messages are accepted). The example below uses the native PHP socket adapter and the in-memory handler. + +```php +setDebug(true); + +$server->start(); +``` + +Implement the [`Utopia\SMTP\Handler`](src/SMTP/Handler.php) interface to accept messages from databases, queues, or other stores. + +## Handlers + +- `Memory`: stores accepted messages in memory for testing or simple workloads +- `Proxy`: relays accepted messages to another SMTP server using the bundled client + +Handlers can be combined with any adapter. Implementing the `Handler` interface allows you to plug in custom logic while reusing protocol and telemetry tooling. + +## Adapters + +- `Native`: pure PHP TCP server based on `ext-sockets` +- `Swoole`: non-blocking TCP server built on the Swoole runtime + +Adapters are responsible only for accepting TCP connections. They call back into the server with a `Connection` so your handler logic stays isolated. + +## SMTP client + +The bundled client can deliver messages to any SMTP server. + +```php +send($message); +``` + +## Transports + +- `Socket`: sends messages through the bundled SMTP client (mirrors DNS resolver transports) + +```php +use Utopia\SMTP\Message; +use Utopia\SMTP\Message\Address; +use Utopia\SMTP\Transport\Socket; + +$transport = new Socket('127.0.0.1', 2525); +$transport->send(Message::create( + new Address('sender@example.test'), + new Address('inbox@example.test'), + 'Subject', + 'Body', +)); +``` + +## Telemetry + +`Server::setTelemetry()` accepts any adapter from [`utopia-php/telemetry`](https://github.com/utopia-php/telemetry). Counters (`smtp.sessions.total`, `smtp.messages.total`) and a histogram (`smtp.session.duration`) are emitted automatically. + +## Development + +- Install dependencies: `composer install` +- Static analysis: `composer analyze` +- Coding standards: `composer format:check` (use `composer format` to auto-fix) +- Tests: `composer test` +- Sample server for manual and E2E testing: `docker compose up` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..87f15fe --- /dev/null +++ b/composer.json @@ -0,0 +1,46 @@ +{ + "name": "utopia-php/smtp", + "description": "Lite & fast micro PHP SMTP server abstraction that is **easy to use**.", + "type": "library", + "keywords": ["php", "framework", "upf", "utopia", "smtp", "email"], + "license": "MIT", + "minimum-stability": "stable", + "scripts": { + "format": "./vendor/bin/pint --config pint.json", + "format:check": "./vendor/bin/pint --test --config pint.json", + "analyze": "./vendor/bin/phpstan analyse --level max -c phpstan.neon src tests", + "test": "./vendor/bin/phpunit --configuration phpunit.xml" + }, + "authors": [ + { + "name": "Eldad Fux", + "email": "eldad@appwrite.io" + } + ], + "autoload": { + "psr-4": {"Utopia\\SMTP\\": "src/SMTP"} + }, + "autoload-dev": { + "psr-4": { + "Tests\\Unit\\Utopia\\": "tests/unit/" + } + }, + "require": { + "php": ">=8.3", + "utopia-php/span": "1.1.*", + "utopia-php/telemetry": "*", + "utopia-php/validators": "0.*" + }, + "require-dev": { + "swoole/ide-helper": "5.1.8", + "phpunit/phpunit": "12.5.*", + "laravel/pint": "1.29.*", + "phpstan/phpstan": "2.0.*" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "tbachert/spi": true + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..0e67a4e --- /dev/null +++ b/composer.lock @@ -0,0 +1,3875 @@ +{ + "_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": "4f5fa85b09766f1508f74c66c9a6ef60", + "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": "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": "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.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "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.9" + }, + "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-29T13:25:15+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.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "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.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", + "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.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "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.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+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/span", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/span.git", + "reference": "028406940ca92bdc88099f0b1a123a3b2cbdd4e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/span/zipball/028406940ca92bdc88099f0b1a123a3b2cbdd4e5", + "reference": "028406940ca92bdc88099f0b1a123a3b2cbdd4e5", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "laravel/pint": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.0", + "rector/rector": "^2.3", + "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/1.1.5" + }, + "time": "2026-02-13T18:00:11+00:00" + }, + { + "name": "utopia-php/telemetry", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/telemetry.git", + "reference": "62bbadad03e593b071b8ca63fac2c117c1900991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/62bbadad03e593b071b8ca63fac2c117c1900991", + "reference": "62bbadad03e593b071b8ca63fac2c117c1900991", + "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": { + "laravel/pint": "1.*", + "phpbench/phpbench": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.*", + "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" + ], + "keywords": [ + "framework", + "php", + "upf" + ], + "support": { + "issues": "https://github.com/utopia-php/telemetry/issues", + "source": "https://github.com/utopia-php/telemetry/tree/0.3.0" + }, + "time": "2026-04-01T13:52:56+00:00" + }, + { + "name": "utopia-php/validators", + "version": "0.2.4", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/b4ee60db4dbae5ffbe53968d01f69b6941251576", + "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.2.4" + }, + "time": "2026-05-21T12:47:43+00:00" + } + ], + "packages-dev": [ + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "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-04-20T15:26:14+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.0.4", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "50d276fc3bf1430ec315f2f109bbde2769821524" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/50d276fc3bf1430ec315f2f109bbde2769821524", + "reference": "50d276fc3bf1430ec315f2f109bbde2769821524", + "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" + ], + "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": "2024-12-17T17:14:01+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "876099a072646c7745f673d7aeab5382c4439691" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.1" + }, + "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": "12.5.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/12.5.6" + }, + "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": "2026-04-15T08:23:17+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.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", + "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/6.0.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-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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", + "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/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "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": "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/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.26", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e78c9ad74f73fd3642a23e65ace83746dc8df26d", + "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d", + "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.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.1", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.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/12.5.26" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-21T12:36:53+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-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/4.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/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-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/7.1.8" + }, + "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-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "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 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/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "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" + }, + { + "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/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-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/8.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/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-21T08:45:32+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "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" + }, + { + "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/7.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/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.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/8.0.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/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-29T11:29:25+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "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 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/4.0.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/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.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": "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/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "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" + } + ], + "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/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.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" + }, + { + "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/7.0.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/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "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", + "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/6.0.4" + }, + "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": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.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", + "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/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+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": "swoole/ide-helper", + "version": "5.1.8", + "source": { + "type": "git", + "url": "https://github.com/swoole/ide-helper.git", + "reference": "2806169ec7385e3b5eb484efef1f4764af46d995" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/2806169ec7385e3b5eb484efef1f4764af46d995", + "reference": "2806169ec7385e3b5eb484efef1f4764af46d995", + "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/5.1.8" + }, + "time": "2025-08-04T05:40:28+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "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/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..728f9b6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + smtp-server: + image: smtp-dev + build: + context: . + command: [ "sh", "-c", "php /usr/src/code/tests/resources/server.php" ] + volumes: + - ./src:/usr/src/code/src + - ./tests:/usr/src/code/tests + - ./phpunit.xml:/usr/src/code/phpunit.xml + networks: + - smtp + ports: + - '2525:2525/tcp' +networks: + smtp: diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..68ce85e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + scanDirectories: + - vendor/swoole/ide-helper + excludePaths: + - tests/resources + - tests/unit/SMTP/Connection/SwooleTest.php + - tests/unit/SMTP/Server/SwooleServerTest.php + - vendor diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..7b2ab44 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + tests/unit + + + tests/e2e + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..6115c68 --- /dev/null +++ b/pint.json @@ -0,0 +1,4 @@ +{ + "preset": "psr12", + "exclude": ["tests/resources"] +} diff --git a/src/SMTP/Adapter.php b/src/SMTP/Adapter.php new file mode 100644 index 0000000..b874996 --- /dev/null +++ b/src/SMTP/Adapter.php @@ -0,0 +1,32 @@ + */ + protected array $onWorkerStart = []; + + /** @var callable(Connection $connection): void|null */ + protected mixed $onConnection = null; + + /** @var array */ + protected array $clients = []; + + public function __construct( + protected string $host = '0.0.0.0', + protected int $port = 2525, + protected int $maxClients = 100, + protected int $idleTimeout = 60, + ) { + $server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if (!$server instanceof Socket) { + throw new Exception('Could not start SMTP server.'); + } + + socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1); + $this->server = $server; + } + + /** + * @param callable(int $workerId): void $callback + * @phpstan-param callable(int $workerId): void $callback + */ + public function onWorkerStart(callable $callback): void + { + $this->onWorkerStart[] = $callback; + } + + /** + * @param callable(Connection $connection): void $callback + * @phpstan-param callable(Connection $connection): void $callback + */ + public function onConnection(callable $callback): void + { + $this->onConnection = $callback; + } + + public function start(): void + { + if ($this->onConnection === null) { + throw new Exception('Connection handler not registered.'); + } + + if (socket_bind($this->server, $this->host, $this->port) === false) { + throw new Exception('Could not bind SMTP server.'); + } + + if (socket_listen($this->server, 128) === false) { + throw new Exception('Could not listen on SMTP server.'); + } + + socket_set_nonblock($this->server); + + foreach ($this->onWorkerStart as $callback) { + \call_user_func($callback, 0); + } + + $handler = $this->onConnection; + + /** @phpstan-ignore-next-line */ + while (true) { + $this->closeIdleClients(); + + $readSockets = [$this->server]; + foreach ($this->clients as $client) { + $readSockets[] = $client; + } + + $write = []; + $except = []; + $changed = socket_select($readSockets, $write, $except, 1); + + if ($changed === false || $changed === 0) { + continue; + } + + foreach ($readSockets as $socket) { + if ($socket === $this->server) { + $client = @socket_accept($this->server); + + if ($client instanceof Socket) { + if (count($this->clients) >= $this->maxClients) { + @socket_close($client); + continue; + } + + if (@socket_set_nonblock($client) === false) { + @socket_close($client); + continue; + } + + socket_set_option($client, SOL_SOCKET, SO_KEEPALIVE, 1); + $id = spl_object_id($client); + $this->clients[$id] = $client; + $this->clientActivity[$id] = time(); + + $ip = ''; + $port = 0; + socket_getpeername($client, $ip, $port); + + if (!is_string($ip) || !is_int($port)) { + $this->closeClient($client); + continue; + } + + $connection = new SocketConnection($client, $ip, $port); + \call_user_func($handler, $connection); + $this->closeClient($client); + } + + continue; + } + + $this->clientActivity[spl_object_id($socket)] = time(); + } + } + } + + public function getName(): string + { + return 'native'; + } + + /** @var array */ + protected array $clientActivity = []; + + protected function closeIdleClients(): void + { + $now = time(); + + foreach ($this->clients as $id => $client) { + $lastActivity = $this->clientActivity[$id] ?? 0; + + if (($now - $lastActivity) > $this->idleTimeout) { + $this->closeClient($client); + } + } + } + + protected function closeClient(Socket $client): void + { + $id = spl_object_id($client); + unset($this->clients[$id], $this->clientActivity[$id]); + @socket_close($client); + } +} diff --git a/src/SMTP/Adapter/Swoole.php b/src/SMTP/Adapter/Swoole.php new file mode 100644 index 0000000..95d7de0 --- /dev/null +++ b/src/SMTP/Adapter/Swoole.php @@ -0,0 +1,98 @@ + */ + protected array $onWorkerStart = []; + + /** @var callable(Connection $connection): void|null */ + protected mixed $onConnection = null; + + public function __construct( + protected string $host = '0.0.0.0', + protected int $port = 2525, + protected int $numWorkers = 1, + protected int $maxCoroutines = 3000, + ) { + $this->server = new Server($this->host, $this->port, SWOOLE_PROCESS, SWOOLE_SOCK_TCP); + $this->server->set([ + 'worker_num' => $this->numWorkers, + 'max_coroutine' => $this->maxCoroutines, + 'enable_coroutine' => true, + ]); + } + + /** + * @param callable(int $workerId): void $callback + * @phpstan-param callable(int $workerId): void $callback + */ + public function onWorkerStart(callable $callback): void + { + $this->onWorkerStart[] = $callback; + } + + /** + * @param callable(Connection $connection): void $callback + * @phpstan-param callable(Connection $connection): void $callback + */ + public function onConnection(callable $callback): void + { + $this->onConnection = $callback; + } + + public function start(): void + { + if ($this->onConnection === null) { + throw new Exception('Connection handler not registered.'); + } + + $handler = $this->onConnection; + + $this->server->on('WorkerStart', function ($server, $workerId) { + if (!is_int($workerId)) { + return; + } + + foreach ($this->onWorkerStart as $callback) { + \call_user_func($callback, $workerId); + } + }); + + $this->server->on('Connect', function (Server $server, int $fd) use ($handler) { + Coroutine::create(function () use ($server, $fd, $handler) { + $info = $server->getClientInfo($fd); + if (!is_array($info)) { + $server->close($fd); + + return; + } + + $ip = is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : ''; + $port = is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0; + + $connection = new SwooleConnection($server, $fd, $ip, $port); + \call_user_func($handler, $connection); + }); + }); + + Runtime::enableCoroutine(); + $this->server->start(); + } + + public function getName(): string + { + return 'swoole'; + } +} diff --git a/src/SMTP/Client.php b/src/SMTP/Client.php new file mode 100644 index 0000000..f679b5d --- /dev/null +++ b/src/SMTP/Client.php @@ -0,0 +1,184 @@ +isValid($server)) { + throw new Exception('Server must be an IP address.'); + } + } + + /** + * @param list $rcptTo When empty, recipients are taken from the message. + */ + public function send(Message $message, ?string $mailFrom = null, array $rcptTo = []): void + { + $mailFrom ??= $message->from->address; + $rcptTo = $rcptTo !== [] ? $rcptTo : array_map( + fn ($address) => $address->address, + $message->allRecipients() + ); + + $socket = $this->connect(); + + try { + $this->expect($this->readResponse($socket), 220); + $this->command($socket, 'EHLO localhost'); + $this->expect($this->readResponse($socket), 250); + $this->command($socket, "MAIL FROM:<{$mailFrom}>"); + $this->expect($this->readResponse($socket), 250); + + foreach ($rcptTo as $recipient) { + $this->command($socket, "RCPT TO:<{$recipient}>"); + $this->expect($this->readResponse($socket), 250); + } + + $this->command($socket, 'DATA'); + $this->expect($this->readResponse($socket), 354); + $this->sendData($socket, $message->encode()); + $this->expect($this->readResponse($socket), 250); + $this->command($socket, 'QUIT'); + $this->readResponse($socket); + } finally { + fclose($socket); + } + } + + /** + * @return resource + */ + protected function connect() + { + $targetHost = $this->formatTcpHost($this->server); + $uri = "tcp://{$targetHost}:{$this->port}"; + + $errno = 0; + $errstr = ''; + $socket = @stream_socket_client($uri, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT); + + if ($socket === false) { + $errCode = is_int($errno) ? $errno : 0; + $errMsg = is_string($errstr) ? $errstr : 'Unknown error'; + throw new Exception("Failed to connect to {$this->server}:{$this->port}: $errMsg ($errCode)"); + } + + stream_set_timeout($socket, $this->timeout); + + return $socket; + } + + /** + * @param resource $socket + */ + protected function command(mixed $socket, string $command): void + { + $written = fwrite($socket, $command . "\r\n"); + + if ($written === false) { + throw new Exception('Failed to send SMTP command.'); + } + } + + /** + * @param resource $socket + */ + protected function sendData(mixed $socket, string $payload): void + { + $payload = str_replace(["\r\n", "\r"], "\n", $payload); + $lines = explode("\n", $payload); + + foreach ($lines as $line) { + if (str_starts_with($line, '.')) { + $line = '.' . $line; + } + + $written = fwrite($socket, $line . "\r\n"); + + if ($written === false) { + throw new Exception('Failed to send message data.'); + } + } + + $written = fwrite($socket, ".\r\n"); + + if ($written === false) { + throw new Exception('Failed to end message data.'); + } + } + + /** + * @param resource $socket + */ + protected function readResponse(mixed $socket): Response + { + $line = $this->readLine($socket); + + if ($line === null) { + throw new Exception('SMTP connection closed unexpectedly.'); + } + + $response = Response::decode($line); + + while ($response->multiline) { + $next = $this->readLine($socket); + + if ($next === null) { + throw new Exception('Incomplete multiline SMTP response.'); + } + + $continuation = Response::decode($next); + + if (!$continuation->multiline) { + $response = $continuation; + break; + } + } + + return $response; + } + + /** + * @param resource $socket + */ + protected function readLine(mixed $socket): ?string + { + if (!is_resource($socket)) { + return null; + } + + $line = fgets($socket); + + if ($line === false) { + return null; + } + + return rtrim($line, "\r\n"); + } + + protected function expect(Response $response, int $code): void + { + if ($response->code !== $code) { + throw new Exception("Unexpected SMTP response {$response->code}: {$response->message}"); + } + } + + protected function formatTcpHost(string $host): string + { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return '[' . $host . ']'; + } + + return $host; + } +} diff --git a/src/SMTP/Connection.php b/src/SMTP/Connection.php new file mode 100644 index 0000000..5dede07 --- /dev/null +++ b/src/SMTP/Connection.php @@ -0,0 +1,19 @@ +ip; + } + + public function getPort(): int + { + return $this->port; + } + + public function readLine(): ?string + { + while (!str_contains($this->buffer, "\n")) { + $chunk = @socket_read($this->socket, 8192, PHP_BINARY_READ); + + if ($chunk === false) { + $error = socket_last_error($this->socket); + + if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { + socket_clear_error($this->socket); + usleep(1000); + continue; + } + + if ($this->buffer === '') { + return null; + } + + break; + } + + if ($chunk === '') { + if ($this->buffer === '') { + return null; + } + + break; + } + + $this->buffer .= $chunk; + } + + $position = strpos($this->buffer, "\n"); + if ($position === false) { + return null; + } + + $line = substr($this->buffer, 0, $position + 1); + $this->buffer = substr($this->buffer, $position + 1); + + return rtrim($line, "\r\n"); + } + + public function write(string $data): void + { + $total = strlen($data); + $sent = 0; + + while ($sent < $total) { + $written = @socket_write($this->socket, substr($data, $sent)); + + if ($written === false) { + $error = socket_last_error($this->socket); + + if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) { + socket_clear_error($this->socket); + usleep(1000); + continue; + } + + break; + } + + $sent += $written; + } + } + + public function close(): void + { + @socket_close($this->socket); + } +} diff --git a/src/SMTP/Connection/Swoole.php b/src/SMTP/Connection/Swoole.php new file mode 100644 index 0000000..e5abde8 --- /dev/null +++ b/src/SMTP/Connection/Swoole.php @@ -0,0 +1,78 @@ +ip; + } + + public function getPort(): int + { + return $this->port; + } + + public function readLine(): ?string + { + while (!str_contains($this->buffer, "\n")) { + /** @var string|false $chunk */ + $chunk = $this->server->recv($this->fd, 8192); // @phpstan-ignore method.notFound + + if (!is_string($chunk) || $chunk === '') { + if ($this->buffer === '') { + return null; + } + + break; + } + + $this->buffer .= $chunk; + } + + $position = strpos($this->buffer, "\n"); + if ($position === false) { + return null; + } + + $line = substr($this->buffer, 0, $position + 1); + $this->buffer = substr($this->buffer, $position + 1); + + return rtrim($line, "\r\n"); + } + + public function write(string $data): void + { + $total = strlen($data); + $sent = 0; + + while ($sent < $total) { + $written = $this->server->send($this->fd, substr($data, $sent)); + + if (!is_int($written) || $written === 0) { + break; + } + + $sent += $written; + } + } + + public function close(): void + { + $this->server->close($this->fd); + } +} diff --git a/src/SMTP/Exception/Message/DecodingException.php b/src/SMTP/Exception/Message/DecodingException.php new file mode 100644 index 0000000..2127016 --- /dev/null +++ b/src/SMTP/Exception/Message/DecodingException.php @@ -0,0 +1,10 @@ + $rcptTo + */ + public function handle(Message $message, string $mailFrom, array $rcptTo): Result; +} diff --git a/src/SMTP/Handler/Memory.php b/src/SMTP/Handler/Memory.php new file mode 100644 index 0000000..5f44556 --- /dev/null +++ b/src/SMTP/Handler/Memory.php @@ -0,0 +1,74 @@ +}> */ + protected array $messages = []; + + /** + * @param list $allowedRecipients When set, only these addresses are accepted. + * @param list $allowedSenders When set, only these MAIL FROM addresses are accepted. + * @param bool $rejectAll When true, all messages are rejected. + */ + public function __construct( + protected array $allowedRecipients = [], + protected array $allowedSenders = [], + protected bool $rejectAll = false, + ) { + } + + public function handle(Message $message, string $mailFrom, array $rcptTo): Result + { + if ($this->rejectAll) { + return Result::Rejected; + } + + if ($this->allowedSenders !== [] && !in_array(strtolower($mailFrom), array_map('strtolower', $this->allowedSenders), true)) { + return Result::Rejected; + } + + if ($this->allowedRecipients !== []) { + foreach ($rcptTo as $recipient) { + if (!in_array(strtolower($recipient), array_map('strtolower', $this->allowedRecipients), true)) { + return Result::Rejected; + } + } + } + + $this->messages[] = [ + 'message' => $message, + 'mailFrom' => $mailFrom, + 'rcptTo' => $rcptTo, + ]; + + return Result::Accepted; + } + + public function getName(): string + { + return 'memory'; + } + + /** + * @return list}> + */ + public function all(): array + { + return $this->messages; + } + + public function count(): int + { + return count($this->messages); + } + + public function clear(): void + { + $this->messages = []; + } +} diff --git a/src/SMTP/Handler/Proxy.php b/src/SMTP/Handler/Proxy.php new file mode 100644 index 0000000..8ee0e0c --- /dev/null +++ b/src/SMTP/Handler/Proxy.php @@ -0,0 +1,36 @@ +client = new Client($server, $port, $timeout); + } + + public function handle(Message $message, string $mailFrom, array $rcptTo): Result + { + try { + $this->client->send($message, $mailFrom, $rcptTo); + + return Result::Accepted; + } catch (\Throwable) { + return Result::Rejected; + } + } + + public function getName(): string + { + return "Proxy ({$this->server}:{$this->port})"; + } +} diff --git a/src/SMTP/Handler/Result.php b/src/SMTP/Handler/Result.php new file mode 100644 index 0000000..aa04f3e --- /dev/null +++ b/src/SMTP/Handler/Result.php @@ -0,0 +1,9 @@ + $headers + * @param list
$to + * @param list
$cc + * @param list
$bcc + */ + public function __construct( + public readonly Address $from, + public readonly array $to, + public readonly string $subject, + public readonly string $body, + public readonly array $headers = [], + public readonly array $cc = [], + public readonly array $bcc = [], + public readonly string $contentType = 'text/plain', + ) { + if ($to === []) { + throw new \InvalidArgumentException('Message must have at least one recipient'); + } + } + + public static function create( + Address $from, + Address $to, + string $subject, + string $body, + string $contentType = 'text/plain', + ): self { + return new self($from, [$to], $subject, $body, contentType: $contentType); + } + + public static function decode(string $raw): self + { + $raw = str_replace(["\r\n", "\r"], "\n", $raw); + $lines = explode("\n", $raw); + + if ($raw === '') { + throw new DecodingException('Message body is empty'); + } + + /** @var array $headers */ + $headers = []; + $bodyLines = []; + $inBody = false; + $lastHeaderName = null; + $lastHeaderValue = null; + + foreach ($lines as $line) { + if (!$inBody) { + if ($line === '') { + $inBody = true; + continue; + } + + if ($lastHeaderName !== null && (($line[0] ?? '') === ' ' || ($line[0] ?? '') === "\t")) { + $lastHeaderValue .= ' ' . ltrim($line); + $headers[$lastHeaderName] = $lastHeaderValue; + continue; + } + + if (!str_contains($line, ':')) { + throw new DecodingException('Invalid header line'); + } + + [$name, $value] = explode(':', $line, 2); + $lastHeaderName = strtolower(trim($name)); + $lastHeaderValue = trim($value); + $headers[$lastHeaderName] = $lastHeaderValue; + continue; + } + + $bodyLines[] = $line; + } + + $fromHeader = $headers['from'] ?? null; + if ($fromHeader === null) { + throw new DecodingException('Missing From header'); + } + + $toHeader = $headers['to'] ?? null; + if ($toHeader === null) { + throw new DecodingException('Missing To header'); + } + + $subject = $headers['subject'] ?? ''; + $contentType = $headers['content-type'] ?? 'text/plain'; + $body = implode("\n", $bodyLines); + + return new self( + from: Address::parse($fromHeader), + to: self::parseAddressList($toHeader), + subject: $subject, + body: $body, + headers: self::filterCustomHeaders($headers), + cc: isset($headers['cc']) ? self::parseAddressList($headers['cc']) : [], + bcc: isset($headers['bcc']) ? self::parseAddressList($headers['bcc']) : [], + contentType: self::parseContentType($contentType), + ); + } + + public function encode(): string + { + $lines = [ + 'From: ' . $this->from->encode(), + 'To: ' . self::encodeAddressList($this->to), + 'Subject: ' . $this->encodeHeaderValue($this->subject), + 'Date: ' . gmdate('D, d M Y H:i:s') . ' +0000', + 'MIME-Version: 1.0', + 'Content-Type: ' . $this->contentType . '; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + ]; + + if ($this->cc !== []) { + $lines[] = 'Cc: ' . self::encodeAddressList($this->cc); + } + + if ($this->bcc !== []) { + $lines[] = 'Bcc: ' . self::encodeAddressList($this->bcc); + } + + foreach ($this->headers as $name => $value) { + $lines[] = $name . ': ' . $this->encodeHeaderValue($value); + } + + $lines[] = ''; + $lines[] = $this->body; + + return implode("\r\n", $lines); + } + + /** + * @return list
+ */ + public function allRecipients(): array + { + return array_merge($this->to, $this->cc, $this->bcc); + } + + /** + * @param array $headers + * @return array + */ + private static function filterCustomHeaders(array $headers): array + { + $reserved = ['from', 'to', 'cc', 'bcc', 'subject', 'date', 'mime-version', 'content-type', 'content-transfer-encoding']; + + $custom = []; + foreach ($headers as $name => $value) { + if (!in_array($name, $reserved, true)) { + $custom[$name] = $value; + } + } + + return $custom; + } + + /** + * @return list
+ */ + private static function parseAddressList(string $value): array + { + $addresses = []; + $parts = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $value) ?: []; + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + $addresses[] = Address::parse($part); + } + + if ($addresses === []) { + throw new DecodingException('Recipient list cannot be empty'); + } + + return $addresses; + } + + /** + * @param list
$addresses + */ + private static function encodeAddressList(array $addresses): string + { + return implode(', ', array_map(fn (Address $address) => $address->encode(), $addresses)); + } + + private static function parseContentType(string $value): string + { + $parts = explode(';', $value); + + return trim($parts[0]); + } + + private function encodeHeaderValue(string $value): string + { + if (preg_match('/[^\x20-\x7E]/', $value) === 1) { + return '=?UTF-8?B?' . base64_encode($value) . '?='; + } + + return $value; + } +} diff --git a/src/SMTP/Message/Address.php b/src/SMTP/Message/Address.php new file mode 100644 index 0000000..57bec8f --- /dev/null +++ b/src/SMTP/Message/Address.php @@ -0,0 +1,53 @@ +]+)>$/', $value, $matches) === 1) { + return new self(trim($matches[1])); + } + + if (preg_match('/^(.+?)\s+<([^>]+)>$/', $value, $matches) === 1) { + $name = trim($matches[1], " \t\"'"); + + return new self(trim($matches[2]), $name !== '' ? $name : null); + } + + return new self($value); + } + + public function encode(): string + { + if ($this->name === null) { + return $this->address; + } + + $name = addcslashes($this->name, '"\\'); + + return "\"{$name}\" <{$this->address}>"; + } +} diff --git a/src/SMTP/Protocol.php b/src/SMTP/Protocol.php new file mode 100644 index 0000000..4eeb9a8 --- /dev/null +++ b/src/SMTP/Protocol.php @@ -0,0 +1,235 @@ + */ + protected array $rcptTo = []; + + /** @var list */ + protected array $dataBuffer = []; + + public function __construct( + protected string $hostname = 'localhost', + ) { + } + + public function greeting(): string + { + return "220 {$this->hostname} ESMTP Utopia SMTP\r\n"; + } + + /** + * @return list + */ + public function handle(string $line): array + { + if ($this->state === self::STATE_DATA) { + return $this->handleDataLine($line); + } + + $command = Command::parse($line); + + return match ($command->verb) { + Command::HELO, Command::EHLO => $this->handleHelo($command), + Command::MAIL => $this->handleMail($command), + Command::RCPT => $this->handleRcpt($command), + Command::DATA => $this->handleDataStart(), + Command::RSET => $this->handleRset(), + Command::QUIT => $this->handleQuit(), + Command::NOOP => ["250 OK\r\n"], + Command::VRFY => ["502 VRFY disabled\r\n"], + Command::AUTH => ["502 AUTH not supported\r\n"], + 'EMPTY' => [], + default => ["500 Command not recognized\r\n"], + }; + } + + /** + * @return list + */ + public function finalize(Handler $handler): array + { + if ($this->state !== self::STATE_DATA || $this->dataBuffer === []) { + return []; + } + + return $this->deliver($handler); + } + + public function getState(): int + { + return $this->state; + } + + /** + * @return list + */ + protected function handleHelo(Command $command): array + { + $this->resetTransaction(); + $this->heloName = $command->argument !== '' ? $command->argument : 'localhost'; + $this->state = self::STATE_READY; + + if ($command->verb === Command::EHLO) { + return [ + "250-{$this->hostname} Hello {$this->heloName}\r\n", + "250-SIZE 10485760\r\n", + "250 8BITMIME\r\n", + ]; + } + + return ["250 {$this->hostname} Hello {$this->heloName}\r\n"]; + } + + /** + * @return list + */ + protected function handleMail(Command $command): array + { + if ($this->state < self::STATE_READY) { + return ["503 Send HELO/EHLO first\r\n"]; + } + + if ($this->state >= self::STATE_MAIL && $this->mailFrom !== '') { + return ["503 Nested MAIL not allowed\r\n"]; + } + + $address = $command->pathArgument(); + if ($address === '') { + return ["501 Syntax: MAIL FROM:
\r\n"]; + } + + $this->mailFrom = $address; + $this->state = self::STATE_MAIL; + + return ["250 OK\r\n"]; + } + + /** + * @return list + */ + protected function handleRcpt(Command $command): array + { + if ($this->state < self::STATE_MAIL || $this->mailFrom === '') { + return ["503 Need MAIL FROM first\r\n"]; + } + + $address = $command->pathArgument(); + if ($address === '') { + return ["501 Syntax: RCPT TO:
\r\n"]; + } + + $this->rcptTo[] = $address; + $this->state = self::STATE_RCPT; + + return ["250 OK\r\n"]; + } + + /** + * @return list + */ + protected function handleDataStart(): array + { + if ($this->state < self::STATE_RCPT || $this->rcptTo === []) { + return ["503 Need RCPT TO first\r\n"]; + } + + $this->state = self::STATE_DATA; + $this->dataBuffer = []; + + return ["354 End data with .\r\n"]; + } + + /** + * @return list + */ + protected function handleDataLine(string $line): array + { + if ($line === '.') { + return []; + } + + if (str_starts_with($line, '..')) { + $line = substr($line, 1); + } + + $this->dataBuffer[] = $line; + + return []; + } + + /** + * @return list + */ + protected function deliver(Handler $handler): array + { + $raw = implode("\r\n", $this->dataBuffer); + + try { + $message = Message::decode($raw); + } catch (\Throwable) { + $this->resetTransaction(); + $this->state = self::STATE_READY; + + return ["554 Message rejected\r\n"]; + } + + $result = $handler->handle($message, $this->mailFrom, $this->rcptTo); + $this->resetTransaction(); + $this->state = self::STATE_READY; + + if ($result === Result::Accepted) { + return ["250 Message accepted\r\n"]; + } + + return ["550 Message rejected\r\n"]; + } + + /** + * @return list + */ + protected function handleRset(): array + { + $this->resetTransaction(); + $this->state = $this->heloName !== '' ? self::STATE_READY : self::STATE_GREETING; + + return ["250 OK\r\n"]; + } + + /** + * @return list + */ + protected function handleQuit(): array + { + $this->heloName = 'QUIT'; + + return ["221 Bye\r\n"]; + } + + protected function resetTransaction(): void + { + $this->mailFrom = ''; + $this->rcptTo = []; + $this->dataBuffer = []; + } +} diff --git a/src/SMTP/Protocol/Command.php b/src/SMTP/Protocol/Command.php new file mode 100644 index 0000000..e554412 --- /dev/null +++ b/src/SMTP/Protocol/Command.php @@ -0,0 +1,51 @@ +]*)>/', $this->argument, $matches) === 1) { + return trim($matches[1]); + } + + return trim($this->argument); + } +} diff --git a/src/SMTP/Protocol/Response.php b/src/SMTP/Protocol/Response.php new file mode 100644 index 0000000..bcfc04e --- /dev/null +++ b/src/SMTP/Protocol/Response.php @@ -0,0 +1,52 @@ + 599) { + throw new DecodingException('SMTP response code must be between 100 and 599'); + } + } + + public static function decode(string $line): self + { + $line = rtrim($line, "\r\n"); + + if (strlen($line) < 4 || !ctype_digit(substr($line, 0, 3))) { + throw new DecodingException('Invalid SMTP response line'); + } + + $code = (int) substr($line, 0, 3); + $separator = $line[3] ?? ' '; + $message = trim(substr($line, 4)); + + if ($separator !== ' ' && $separator !== '-') { + throw new DecodingException('Invalid SMTP response separator'); + } + + return new self($code, $message, $separator === '-'); + } + + public function encode(): string + { + return sprintf("%d %s\r\n", $this->code, $this->message); + } + + public function isPositiveCompletion(): bool + { + return $this->code >= 200 && $this->code < 300; + } + + public function isPositiveIntermediate(): bool + { + return $this->code >= 300 && $this->code < 400; + } +} diff --git a/src/SMTP/Server.php b/src/SMTP/Server.php new file mode 100644 index 0000000..1ab9164 --- /dev/null +++ b/src/SMTP/Server.php @@ -0,0 +1,168 @@ + */ + protected array $errors = []; + + protected bool $debug = false; + + protected ?Histogram $duration = null; + protected ?Counter $sessionsTotal = null; + protected ?Counter $messagesTotal = null; + + public function __construct( + Adapter $adapter, + Handler $handler, + protected string $hostname = 'localhost', + ) { + $this->adapter = $adapter; + $this->handler = $handler; + $this->setTelemetry(new NoTelemetry()); + } + + public function setTelemetry(Telemetry $telemetry): void + { + $this->duration = $telemetry->createHistogram( + 'smtp.session.duration', + 's', + null, + ['ExplicitBucketBoundaries' => [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]] + ); + + $this->sessionsTotal = $telemetry->createCounter('smtp.sessions.total'); + $this->messagesTotal = $telemetry->createCounter('smtp.messages.total'); + } + + public function error(callable $handler): self + { + $this->errors[] = $handler; + return $this; + } + + /** + * @param callable(Server $server, int $workerId): void $handler + * @phpstan-param callable(Server $server, int $workerId): void $handler + */ + public function onWorkerStart(callable $handler): self + { + $this->adapter->onWorkerStart(function (int $workerId) use ($handler) { + \call_user_func($handler, $this, $workerId); + }); + + return $this; + } + + public function setDebug(bool $status): self + { + $this->debug = $status; + return $this; + } + + protected function handleError(Throwable $error): void + { + foreach ($this->errors as $handler) { + call_user_func($handler, $error); + } + } + + protected function onConnection(Connection $connection): void + { + $span = Span::init('smtp.session'); + $span->set('client.ip', $connection->getIp()); + $span->set('client.port', $connection->getPort()); + + $start = microtime(true); + $this->sessionsTotal?->add(1); + + $protocol = new Protocol($this->hostname); + $connection->write($protocol->greeting()); + + $close = false; + + try { + while (($line = $connection->readLine()) !== null) { + if ($this->debug) { + printf("C: %s\n", $line); + } + + if ($protocol->getState() === Protocol::STATE_DATA && $line === '.') { + $responses = $protocol->finalize($this->handler); + foreach ($responses as $response) { + $connection->write($response); + if ($this->debug) { + printf("S: %s", $response); + } + } + + if ($responses !== []) { + $this->messagesTotal?->add(1, [ + 'handler' => $this->handler->getName(), + 'accepted' => str_contains($responses[0], '250'), + ]); + } + + continue; + } + + $responses = $protocol->handle($line); + + /** @var list $responses */ + foreach ($responses as $response) { + $connection->write($response); + if ($this->debug) { + printf("S: %s", $response); + } + } + + if (strtoupper(rtrim($line)) === 'QUIT') { + $close = true; + break; + } + } + } catch (Throwable $error) { + $span->setError($error); + $this->handleError($error); + $connection->write("421 Service unavailable\r\n"); + } finally { + $duration = microtime(true) - $start; + $this->duration?->record($duration); + $span->set('smtp.session.duration', $duration); + $span->finish(); + $connection->close(); + } + + if ($close) { + return; + } + } + + public function start(): void + { + try { + $this->adapter->onConnection($this->onConnection(...)); + $this->adapter->start(); + } catch (Throwable $error) { + $this->handleError($error); + } + } +} diff --git a/src/SMTP/Transport.php b/src/SMTP/Transport.php new file mode 100644 index 0000000..fd3e29c --- /dev/null +++ b/src/SMTP/Transport.php @@ -0,0 +1,16 @@ + $rcptTo + */ + public function send(Message $message, ?string $mailFrom = null, array $rcptTo = []): void; +} diff --git a/src/SMTP/Transport/Socket.php b/src/SMTP/Transport/Socket.php new file mode 100644 index 0000000..fd70fd1 --- /dev/null +++ b/src/SMTP/Transport/Socket.php @@ -0,0 +1,30 @@ +client = new Client($server, $port, $timeout); + } + + public function send(Message $message, ?string $mailFrom = null, array $rcptTo = []): void + { + $this->client->send($message, $mailFrom, $rcptTo); + } + + public function getName(): string + { + return "Socket ({$this->server}:{$this->port})"; + } +} diff --git a/tests/e2e/SMTP/ClientTest.php b/tests/e2e/SMTP/ClientTest.php new file mode 100644 index 0000000..aecb9ff --- /dev/null +++ b/tests/e2e/SMTP/ClientTest.php @@ -0,0 +1,45 @@ +send($message, 'sender@appwrite.test', ['inbox@appwrite.test']); + + $this->addToAssertionCount(1); + } + + public function testRejectedRecipientThrows(): void + { + $client = new Client('127.0.0.1', self::PORT); + + $message = Message::create( + from: new Address('sender@appwrite.test'), + to: new Address('unknown@appwrite.test'), + subject: 'Rejected', + body: 'Should fail', + ); + + $this->expectException(\Exception::class); + + $client->send($message, 'sender@appwrite.test', ['unknown@appwrite.test']); + } +} diff --git a/tests/resources/server.php b/tests/resources/server.php new file mode 100644 index 0000000..a25f94b --- /dev/null +++ b/tests/resources/server.php @@ -0,0 +1,26 @@ +setDebug((bool) (getenv('DEBUG') ?: false)); +$server->start(); diff --git a/tests/unit/SMTP/Adapter/AdapterTestCase.php b/tests/unit/SMTP/Adapter/AdapterTestCase.php new file mode 100644 index 0000000..fbcab55 --- /dev/null +++ b/tests/unit/SMTP/Adapter/AdapterTestCase.php @@ -0,0 +1,52 @@ +createAdapter(0); + + $this->assertSame($this->expectedName(), $adapter->getName()); + } + + public function testStartThrowsWhenConnectionHandlerMissing(): void + { + $adapter = $this->createAdapter(0); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Connection handler not registered.'); + + $adapter->start(); + } + + public function testOnWorkerStartAcceptsCallback(): void + { + $adapter = $this->createAdapter(0); + $called = false; + + $adapter->onWorkerStart(function (int $workerId) use (&$called) { + $called = $workerId === 0 || $workerId > 0; + }); + + $this->addToAssertionCount(1); + } + + public function testOnConnectionAcceptsCallback(): void + { + $adapter = $this->createAdapter(0); + + $adapter->onConnection(function () { + }); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/unit/SMTP/Adapter/NativeTest.php b/tests/unit/SMTP/Adapter/NativeTest.php new file mode 100644 index 0000000..685872a --- /dev/null +++ b/tests/unit/SMTP/Adapter/NativeTest.php @@ -0,0 +1,19 @@ +expectException(\Exception::class); + $this->expectExceptionMessage('Server must be an IP address.'); + + new Client('not-an-ip'); + } +} diff --git a/tests/unit/SMTP/Connection/BufferedConnection.php b/tests/unit/SMTP/Connection/BufferedConnection.php new file mode 100644 index 0000000..da46960 --- /dev/null +++ b/tests/unit/SMTP/Connection/BufferedConnection.php @@ -0,0 +1,60 @@ +ip; + } + + public function getPort(): int + { + return $this->port; + } + + public function readLine(): ?string + { + if ($this->offset >= strlen($this->payload)) { + return null; + } + + $remaining = substr($this->payload, $this->offset); + $position = strpos($remaining, "\n"); + + if ($position === false) { + $line = $remaining; + $this->offset = strlen($this->payload); + + return rtrim($line, "\r\n"); + } + + $line = substr($remaining, 0, $position + 1); + $this->offset += $position + 1; + + return rtrim($line, "\r\n"); + } + + public function write(string $data): void + { + } + + public function close(): void + { + } +} diff --git a/tests/unit/SMTP/Connection/BufferedConnectionTest.php b/tests/unit/SMTP/Connection/BufferedConnectionTest.php new file mode 100644 index 0000000..d7716c4 --- /dev/null +++ b/tests/unit/SMTP/Connection/BufferedConnectionTest.php @@ -0,0 +1,14 @@ + $chunks + */ + protected function createConnection(array $chunks): BufferedConnection + { + return new BufferedConnection(implode('', $chunks)); + } +} diff --git a/tests/unit/SMTP/Connection/ConnectionTestCase.php b/tests/unit/SMTP/Connection/ConnectionTestCase.php new file mode 100644 index 0000000..e278cd8 --- /dev/null +++ b/tests/unit/SMTP/Connection/ConnectionTestCase.php @@ -0,0 +1,48 @@ + $chunks + */ + abstract protected function createConnection(array $chunks): Connection; + + public function testReadLineReturnsBufferedLines(): void + { + $connection = $this->createConnection(["220 localhost\r\n", "250 OK\r\n"]); + + $this->assertSame('220 localhost', $connection->readLine()); + $this->assertSame('250 OK', $connection->readLine()); + $this->assertNull($connection->readLine()); + } + + public function testReadLineSplitsAcrossChunks(): void + { + $connection = $this->createConnection(['250-', "OK\r\n"]); + + $this->assertSame('250-OK', $connection->readLine()); + $this->assertNull($connection->readLine()); + } + + public function testGetIpAndPort(): void + { + $connection = $this->createConnection([]); + + $this->assertSame('127.0.0.1', $connection->getIp()); + $this->assertGreaterThan(0, $connection->getPort()); + } + + public function testWriteAndClose(): void + { + $connection = $this->createConnection([]); + $connection->write("221 Bye\r\n"); + $connection->close(); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/unit/SMTP/Connection/SocketTest.php b/tests/unit/SMTP/Connection/SocketTest.php new file mode 100644 index 0000000..a35f003 --- /dev/null +++ b/tests/unit/SMTP/Connection/SocketTest.php @@ -0,0 +1,110 @@ + $chunks + */ + protected function createConnection(array $chunks): Connection + { + $server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if (!$server instanceof PhpSocket) { + $this->markTestSkipped('Could not create TCP socket.'); + } + + socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1); + socket_bind($server, '127.0.0.1', 0); + socket_listen($server, 1); + + $port = 0; + socket_getsockname($server, $address, $port); + $port = is_int($port) ? $port : 0; + + if ($port === 0) { + socket_close($server); + $this->markTestSkipped('Could not resolve ephemeral port.'); + } + + $client = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if (!$client instanceof PhpSocket) { + socket_close($server); + $this->markTestSkipped('Could not create client socket.'); + } + + socket_connect($client, '127.0.0.1', $port); + $peer = socket_accept($server); + + if (!$peer instanceof PhpSocket) { + socket_close($server); + socket_close($client); + $this->markTestSkipped('Could not accept peer socket.'); + } + + foreach ($chunks as $chunk) { + socket_write($peer, $chunk); + } + + socket_close($peer); + socket_close($server); + + return new Socket($client, '127.0.0.1', $port); + } + + public function testReadLineSplitsAcrossChunks(): void + { + $server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if (!$server instanceof PhpSocket) { + $this->markTestSkipped('Could not create TCP socket.'); + } + + socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1); + socket_bind($server, '127.0.0.1', 0); + socket_listen($server, 1); + + $port = 0; + socket_getsockname($server, $address, $port); + $port = is_int($port) ? $port : 0; + + $client = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + if (!$client instanceof PhpSocket || $port === 0) { + socket_close($server); + $this->markTestSkipped('Could not create client socket.'); + } + + socket_connect($client, '127.0.0.1', $port); + $peer = socket_accept($server); + + if (!$peer instanceof PhpSocket) { + socket_close($server); + socket_close($client); + $this->markTestSkipped('Could not accept peer socket.'); + } + + socket_write($peer, '250-'); + + $connection = new Socket($client, '127.0.0.1', $port); + + socket_write($peer, "OK\r\n"); + socket_close($peer); + socket_close($server); + + $this->assertSame('250-OK', $connection->readLine()); + } + + public function testReadLineMatchesBufferedImplementation(): void + { + $payload = "220 localhost\r\n250 OK\r\n"; + $buffered = new BufferedConnection($payload); + $socket = $this->createConnection(["220 localhost\r\n", "250 OK\r\n"]); + + $this->assertSame($buffered->readLine(), $socket->readLine()); + $this->assertSame($buffered->readLine(), $socket->readLine()); + $this->assertNull($socket->readLine()); + } +} diff --git a/tests/unit/SMTP/Connection/SwooleTest.php b/tests/unit/SMTP/Connection/SwooleTest.php new file mode 100644 index 0000000..8bba4e3 --- /dev/null +++ b/tests/unit/SMTP/Connection/SwooleTest.php @@ -0,0 +1,63 @@ +on('Start', function (Server $server) use (&$port) { + $socketName = $server->getSocket()?->getsockname(); + if (is_array($socketName) && isset($socketName['port']) && is_int($socketName['port'])) { + $port = $socketName['port']; + } + }); + + $server->on('Connect', function (Server $server, int $fd) use (&$connection, $port, $payload) { + $connection = new SwooleConnection($server, $fd, '127.0.0.1', 2525); + + Coroutine::create(function () use ($port, $payload) { + usleep(50000); + $client = new Coroutine\Socket(AF_INET, SOCK_STREAM, 0); + if ($client->connect('127.0.0.1', $port)) { + $client->send($payload); + $client->close(); + } + }); + }); + + Coroutine::create(function () use ($server, &$connection, &$lines) { + $server->start(); + + if (!$connection instanceof SwooleConnection) { + return; + } + + $lines[] = $connection->readLine(); + $lines[] = $connection->readLine(); + $lines[] = $connection->readLine(); + }); + }); + + $this->assertSame($buffered->readLine(), $lines[0] ?? null); + $this->assertSame($buffered->readLine(), $lines[1] ?? null); + $this->assertArrayHasKey(2, $lines); + $this->assertNull($lines[2]); + } +} diff --git a/tests/unit/SMTP/Handler/MemoryTest.php b/tests/unit/SMTP/Handler/MemoryTest.php new file mode 100644 index 0000000..eb79492 --- /dev/null +++ b/tests/unit/SMTP/Handler/MemoryTest.php @@ -0,0 +1,30 @@ +handle($message, 'sender@example.com', ['allowed@example.com']); + $rejected = $handler->handle($message, 'sender@example.com', ['blocked@example.com']); + + $this->assertSame(Result::Accepted, $accepted); + $this->assertSame(Result::Rejected, $rejected); + $this->assertSame(1, $handler->count()); + } +} diff --git a/tests/unit/SMTP/Message/AddressTest.php b/tests/unit/SMTP/Message/AddressTest.php new file mode 100644 index 0000000..b7604aa --- /dev/null +++ b/tests/unit/SMTP/Message/AddressTest.php @@ -0,0 +1,40 @@ +'); + + $this->assertSame('user@example.com', $address->address); + $this->assertNull($address->name); + } + + public function testParseNamedAddress(): void + { + $address = Address::parse('Example User '); + + $this->assertSame('user@example.com', $address->address); + $this->assertSame('Example User', $address->name); + } + + public function testEncodeNamedAddress(): void + { + $address = new Address('user@example.com', 'Example User'); + + $this->assertSame('"Example User" ', $address->encode()); + } + + public function testParseThrowsOnEmptyValue(): void + { + $this->expectException(DecodingException::class); + + Address::parse(''); + } +} diff --git a/tests/unit/SMTP/MessageTest.php b/tests/unit/SMTP/MessageTest.php new file mode 100644 index 0000000..a3995d3 --- /dev/null +++ b/tests/unit/SMTP/MessageTest.php @@ -0,0 +1,89 @@ +encode(); + $decoded = Message::decode($encoded); + + $this->assertSame('sender@example.com', $decoded->from->address); + $this->assertSame('Sender', $decoded->from->name); + $this->assertSame('recipient@example.com', $decoded->to[0]->address); + $this->assertSame('Hello SMTP', $decoded->subject); + $this->assertSame("Line one\nLine two", $decoded->body); + } + + public function testDecodeParsesCcHeaders(): void + { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: one@example.com, two@example.com', + 'Cc: cc@example.com', + 'Subject: Subject', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Body', + ]); + + $message = Message::decode($raw); + + $this->assertCount(2, $message->to); + $this->assertCount(1, $message->cc); + $this->assertSame('cc@example.com', $message->cc[0]->address); + } + + public function testAllRecipientsIncludesCcAndBcc(): void + { + $message = new Message( + from: new Address('sender@example.com'), + to: [new Address('one@example.com')], + subject: 'Test', + body: 'Body', + cc: [new Address('cc@example.com')], + bcc: [new Address('bcc@example.com')], + ); + + $recipients = array_map(fn (Address $address) => $address->address, $message->allRecipients()); + + $this->assertEqualsCanonicalizing( + ['one@example.com', 'cc@example.com', 'bcc@example.com'], + $recipients, + ); + } + + public function testDecodeThrowsWhenFromMissing(): void + { + $this->expectException(DecodingException::class); + $this->expectExceptionMessage('Missing From header'); + + Message::decode("To: a@example.com\r\n\r\nBody"); + } + + public function testConstructorRequiresRecipients(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Message( + from: new Address('sender@example.com'), + to: [], + subject: 'Empty', + body: 'Body', + ); + } +} diff --git a/tests/unit/SMTP/Protocol/CommandTest.php b/tests/unit/SMTP/Protocol/CommandTest.php new file mode 100644 index 0000000..cb12dce --- /dev/null +++ b/tests/unit/SMTP/Protocol/CommandTest.php @@ -0,0 +1,25 @@ +assertSame('QUIT', $command->verb); + $this->assertSame('', $command->argument); + } + + public function testParseWithArgument(): void + { + $command = Command::parse("MAIL FROM:\r\n"); + + $this->assertSame('MAIL', $command->verb); + $this->assertSame('sender@example.com', $command->pathArgument()); + } +} diff --git a/tests/unit/SMTP/Protocol/ResponseTest.php b/tests/unit/SMTP/Protocol/ResponseTest.php new file mode 100644 index 0000000..bcaa3cb --- /dev/null +++ b/tests/unit/SMTP/Protocol/ResponseTest.php @@ -0,0 +1,41 @@ +assertSame(250, $response->code); + $this->assertSame('OK', $response->message); + $this->assertFalse($response->multiline); + $this->assertTrue($response->isPositiveCompletion()); + } + + public function testDecodeMultilinePrefix(): void + { + $response = Response::decode("250-Hello localhost\r\n"); + + $this->assertTrue($response->multiline); + } + + public function testEncode(): void + { + $response = new Response(354, 'Start mail input'); + + $this->assertSame("354 Start mail input\r\n", $response->encode()); + } + + public function testDecodeThrowsOnInvalidLine(): void + { + $this->expectException(DecodingException::class); + + Response::decode('invalid'); + } +} diff --git a/tests/unit/SMTP/ProtocolTest.php b/tests/unit/SMTP/ProtocolTest.php new file mode 100644 index 0000000..430fea4 --- /dev/null +++ b/tests/unit/SMTP/ProtocolTest.php @@ -0,0 +1,92 @@ +greeting(); + $responses = array_merge($responses, $protocol->handle('EHLO client.test')); + $responses = array_merge($responses, $protocol->handle('MAIL FROM:')); + $responses = array_merge($responses, $protocol->handle('RCPT TO:')); + + $message = Message::create( + from: new Address('sender@test.com'), + to: new Address('inbox@test.com'), + subject: 'Protocol', + body: 'Hello', + ); + + $dataResponses = $protocol->handle('DATA'); + $this->assertSame(["354 End data with .\r\n"], $dataResponses); + + foreach (explode("\r\n", $message->encode()) as $line) { + $protocol->handle($line); + } + + $responses = $protocol->finalize($handler); + + $this->assertSame(["250 Message accepted\r\n"], $responses); + $this->assertSame(1, $handler->count()); + } + + public function testMailRequiresHelo(): void + { + $protocol = new Protocol('mail.test'); + + $responses = $protocol->handle('MAIL FROM:'); + + $this->assertSame(["503 Send HELO/EHLO first\r\n"], $responses); + } + + public function testRcptRequiresMailFrom(): void + { + $protocol = new Protocol('mail.test'); + $protocol->handle('EHLO client.test'); + + $responses = $protocol->handle('RCPT TO:'); + + $this->assertSame(["503 Need MAIL FROM first\r\n"], $responses); + } + + public function testMemoryHandlerCanReject(): void + { + $handler = new Memory(rejectAll: true); + $protocol = new Protocol('mail.test'); + $protocol->handle('EHLO client.test'); + $protocol->handle('MAIL FROM:'); + $protocol->handle('RCPT TO:'); + $protocol->handle('DATA'); + $protocol->handle('From: sender@test.com'); + $protocol->handle('To: inbox@test.com'); + $protocol->handle('Subject: Test'); + $protocol->handle(''); + $protocol->handle('Body'); + + $responses = $protocol->finalize($handler); + + $this->assertSame(["550 Message rejected\r\n"], $responses); + $this->assertSame(0, $handler->count()); + } + + public function testQuitReturnsBye(): void + { + $protocol = new Protocol('mail.test'); + $protocol->handle('EHLO client.test'); + + $responses = $protocol->handle('QUIT'); + + $this->assertSame(["221 Bye\r\n"], $responses); + } +} diff --git a/tests/unit/SMTP/Server/SwooleServerTest.php b/tests/unit/SMTP/Server/SwooleServerTest.php new file mode 100644 index 0000000..a1e2713 --- /dev/null +++ b/tests/unit/SMTP/Server/SwooleServerTest.php @@ -0,0 +1,73 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open( + ['php', $script], + $descriptor, + $pipes, + dirname($script), + [ + 'PORT' => (string) $port, + 'ADAPTER' => 'swoole', + ], + ); + + if (!is_resource($process)) { + $this->fail('Could not start Swoole SMTP server process.'); + } + + try { + $this->waitForPort($port); + + $client = new Client('127.0.0.1', $port); + $message = Message::create( + new Address('sender@appwrite.test'), + new Address('inbox@appwrite.test'), + 'Swoole SMTP', + 'Delivered through Swoole adapter', + ); + + $client->send($message, 'sender@appwrite.test', ['inbox@appwrite.test']); + $this->addToAssertionCount(1); + } finally { + proc_terminate($process); + proc_close($process); + } + } + + protected function waitForPort(int $port, int $attempts = 50): void + { + for ($i = 0; $i < $attempts; $i++) { + $socket = @fsockopen('127.0.0.1', $port); + if ($socket !== false) { + fclose($socket); + + return; + } + + usleep(100000); + } + + $this->fail("SMTP server did not listen on port {$port}."); + } +} diff --git a/tests/unit/SMTP/ServerTest.php b/tests/unit/SMTP/ServerTest.php new file mode 100644 index 0000000..ee7aed6 --- /dev/null +++ b/tests/unit/SMTP/ServerTest.php @@ -0,0 +1,108 @@ +start(); + + $this->assertSame(1, $handler->count()); + $stored = $handler->all()[0]; + $this->assertSame('sender@test.com', $stored['mailFrom']); + $this->assertSame(['inbox@test.com'], $stored['rcptTo']); + $this->assertSame('Hello server', $stored['message']->body); + } +} + +final class TestAdapter extends Adapter +{ + /** @var callable(Connection $connection): void|null */ + private mixed $handler = null; + + public function onWorkerStart(callable $callback): void + { + $callback(0); + } + + public function onConnection(callable $callback): void + { + $this->handler = $callback; + } + + public function start(): void + { + if ($this->handler === null) { + return; + } + + $message = Message::create( + new Address('sender@test.com'), + new Address('inbox@test.com'), + 'Subject', + 'Hello server', + ); + + $lines = [ + 'EHLO client.test', + 'MAIL FROM:', + 'RCPT TO:', + 'DATA', + ...explode("\r\n", $message->encode()), + '.', + 'QUIT', + ]; + + \call_user_func($this->handler, new TestConnection($lines)); + } +} + +final class TestConnection implements Connection +{ + private int $index = 0; + + /** + * @param list $lines + */ + public function __construct(private readonly array $lines) + { + } + + public function getIp(): string + { + return '127.0.0.1'; + } + + public function getPort(): int + { + return 40000; + } + + public function readLine(): ?string + { + if (!isset($this->lines[$this->index])) { + return null; + } + + return $this->lines[$this->index++]; + } + + public function write(string $data): void + { + } + + public function close(): void + { + } +} diff --git a/tests/unit/SMTP/Transport/SocketTest.php b/tests/unit/SMTP/Transport/SocketTest.php new file mode 100644 index 0000000..495edcd --- /dev/null +++ b/tests/unit/SMTP/Transport/SocketTest.php @@ -0,0 +1,16 @@ +assertSame('Socket (10.0.0.5:2525)', $transport->getName()); + } +}