diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6537ca4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes index 88a22b9..2755315 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,21 @@ -tests export-ignore -docs export-ignore .github export-ignore +benchmarks export-ignore +docs export-ignore +examples export-ignore +tests export-ignore + +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore .readthedocs.yaml export-ignore captainhook.json export-ignore +pest.xml export-ignore +phpbench.json export-ignore +phpcs.xml.dist export-ignore +phpstan.neon.dist export-ignore phpunit.xml export-ignore pint.json export-ignore +psalm.xml export-ignore rector.php export-ignore + +* text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8a5f881 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,70 @@ +name: Bug report +description: Report a reproducible problem +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Please include enough detail to reproduce it. + - type: textarea + id: summary + attributes: + label: Summary + description: What is wrong? + placeholder: Clear and short description of the bug. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Share exact commands, config, and steps. + placeholder: | + 1. Run `composer ic:tests` + 2. ... + 3. Observe ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: What happened instead? Include full error output if possible. + validations: + required: true + - type: input + id: php_version + attributes: + label: PHP version + placeholder: "e.g. 8.3.8" + validations: + required: true + - type: input + id: composer_version + attributes: + label: Composer version + placeholder: "e.g. 2.9.2" + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment details + description: OS, CI provider, shell, and anything else relevant. + placeholder: Ubuntu 24.04, GitHub Actions, bash... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Links, screenshots, logs, or related issues. diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml new file mode 100644 index 0000000..3dcbac9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ci_failure.yml @@ -0,0 +1,48 @@ +name: CI failure +description: Report a reproducible CI or workflow failure +title: "[CI]: " +labels: + - ci +body: + - type: markdown + attributes: + value: | + Use this form when CI fails unexpectedly and can be reproduced. + - type: input + id: workflow + attributes: + label: Workflow/job name + placeholder: security-standards / phpforge + validations: + required: true + - type: input + id: run_url + attributes: + label: Failing run URL + placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/... + validations: + required: true + - type: textarea + id: command + attributes: + label: Failing command + description: Exact command or step that failed. + placeholder: composer ic:ci + validations: + required: true + - type: textarea + id: logs + attributes: + label: Error output + description: Paste the relevant error section. + render: shell + validations: + required: true + - type: textarea + id: local_check + attributes: + label: Local reproduction + description: Can you reproduce locally? If yes, include steps. + placeholder: Yes/No + details + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml new file mode 100644 index 0000000..80b9607 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml @@ -0,0 +1,34 @@ +name: Docs improvement +description: Report missing, unclear, or incorrect documentation +title: "[Docs]: " +labels: + - documentation +body: + - type: textarea + id: location + attributes: + label: Documentation location + description: File path or URL. + placeholder: README.md section "Quick Start" + validations: + required: true + - type: textarea + id: issue + attributes: + label: What is unclear or incorrect? + placeholder: This section says... + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Propose revised wording, structure, or examples. + placeholder: It would be clearer if... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Related links, screenshots, or prior discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..cc29614 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thanks for the idea. Please describe the use case first, then the proposed solution. + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What limitation are you hitting? + placeholder: I need to... + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What should happen? + placeholder: Add a command/config/workflow option that... + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any workaround or alternative approach you evaluated. + - type: textarea + id: impact + attributes: + label: Expected impact + description: Who benefits and what changes for users/CI? + placeholder: This would improve... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Related issues, links, examples, or prior art. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..2ca776f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,40 @@ +name: Question +description: Ask a usage or integration question +title: "[Question]: " +labels: + - question +body: + - type: markdown + attributes: + value: | + Use this form for usage questions. For confirmed defects, use the bug report form. + - type: textarea + id: context + attributes: + label: What are you trying to do? + description: Describe your goal and expected outcome. + placeholder: I want to... + validations: + required: true + - type: textarea + id: attempted + attributes: + label: What have you tried? + description: Include commands, config snippets, or links you already checked. + placeholder: I tried... + validations: + required: true + - type: textarea + id: output + attributes: + label: Current output or behavior + description: Include relevant command output, logs, or errors. + render: shell + - type: textarea + id: environment + attributes: + label: Environment details + description: PHP version, Composer version, OS, CI provider (if relevant). + placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/regression_report.yml b/.github/ISSUE_TEMPLATE/regression_report.yml new file mode 100644 index 0000000..36392bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/regression_report.yml @@ -0,0 +1,51 @@ +name: Regression report +description: Report behavior that previously worked but now fails +title: "[Regression]: " +labels: + - regression + - bug +body: + - type: textarea + id: summary + attributes: + label: Regression summary + placeholder: This worked before, but now... + validations: + required: true + - type: input + id: last_known_good + attributes: + label: Last known working version/commit + placeholder: v1.2.3 or abc1234 + validations: + required: true + - type: input + id: first_bad + attributes: + label: First broken version/commit + placeholder: v1.2.4 or def5678 + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected_actual + attributes: + label: Expected vs actual behavior + placeholder: Expected ..., but got ... + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment details + description: PHP version, Composer version, OS, CI provider (if relevant). + placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..59ae734 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +## Summary + +Describe what changed and why. + +## Related Issues + +Link issues with `Closes #...` or `Relates #...`. + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor +- [ ] Documentation update +- [ ] CI or tooling update +- [ ] Other (describe in summary) + +## Validation + +List the commands you ran and their result. + +```bash +composer ic:tests +``` + +If full suite was not run, explain why and list focused checks. + +## Checklist + +- [ ] I followed `CONTRIBUTING.md`. +- [ ] I added or updated tests for behavior changes. +- [ ] I updated docs/config/examples when needed. +- [ ] I confirmed no security-sensitive data is exposed. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index d162ea8..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: "Security & Standards" - -on: - schedule: - - cron: '0 0 * * 0' - push: - branches: [ '*' ] - pull_request: - branches: [ "main", "master", "develop" ] - -jobs: - run: - runs-on: ${{ matrix.operating-system }} - strategy: - matrix: - operating-system: [ ubuntu-latest ] - php-versions: [ '8.2', '8.3' ] - dependency-version: [ prefer-lowest, prefer-stable ] - - name: PHP ${{ matrix.php-versions }} - ${{ matrix.operating-system }} - ${{ matrix.dependency-version }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer:v2 - coverage: xdebug - - - name: Check PHP Version - run: php -v - - - name: Validate Composer - run: composer validate --strict - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist --optimize-autoloader - - - name: Package Audit - run: composer audit - - - name: Test - run: composer tests \ No newline at end of file diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..47b4673 --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,40 @@ +name: "Security & Standards" + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + phpforge: + uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + permissions: + security-events: write + actions: read + contents: read + with: + php_versions: '["8.4","8.5"]' + dependency_versions: '["prefer-lowest","prefer-stable"]' + php_extensions: "fileinfo" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true + run_svg_report: true + fail_on_skipped_tests: false + enable_redis_service: false + enable_valkey_service: false + enable_memcached_service: false + enable_postgres_service: false + enable_mysql_service: false + enable_scylladb_service: false + enable_elasticsearch_service: false + enable_mongodb_service: false + service_db_name: "phpforge" + service_db_user: "phpforge" + service_db_password: "phpforge" + artifact_retention_days: 61 + diff --git a/.gitignore b/.gitignore index 9f90e11..6991d72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,20 @@ -vendor -example .idea -example.php -test.php +.psalm-cache +.phpunit.cache +.vscode +.windsurf +.codex +*~ +*.patch +*.txt +!docs/requirements.txt +AI_CONTEXT.md composer.lock +example +example.php git-story_media +patch.php +test.php +var +vendor +d2utmp* diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..a807878 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +python: + install: + - requirements: docs/requirements.txt + +sphinx: + configuration: docs/conf.py + +formats: + - pdf + - epub diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9c2638f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,50 @@ +# Code of Conduct + +## Our Commitment + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of age, body size, disability, ethnicity, +gender identity and expression, level of experience, nationality, personal +appearance, race, religion or sexual identity and orientation. + +## Expected Behavior + +Examples of behavior that contributes to a positive environment: + +- Be respectful and constructive. +- Assume good intent and ask clarifying questions. +- Give and receive feedback professionally. +- Focus on what is best for the community and project. + +## Unacceptable Behavior + +Examples of unacceptable behavior include: + +- Harassment, discrimination or personal attacks. +- Trolling, insulting or derogatory comments. +- Publishing private information without consent. +- Any conduct that is inappropriate in a professional setting. + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing this code of +conduct. They may remove, edit or reject comments, commits, code, issues, and +other contributions that violate this policy. + +## Scope + +This code of conduct applies in all project spaces, including: + +- Issue trackers +- Pull requests +- Discussions and chat related to the project +- Any public or private communication where someone represents the project + +## Reporting + +To report unacceptable behavior, contact project maintainers privately. + +## Enforcement + +Maintainers may take any action they deem appropriate, including warnings, +temporary bans or permanent bans from community participation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9950065 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing + +Thanks for contributing. + +## Before You Start + +- Review the project code of conduct. +- For security issues, use private reporting and avoid opening a public issue. +- Check existing issues and pull requests first to avoid duplicates. + +## Local Setup + +Requirements: + +- See `README.md` for current PHP and Composer requirements. + +Install dependencies: + +```bash +composer install +``` + +## Development Workflow + +Typical contributor workflow: + +1. Create a branch from `main`. +2. Make focused changes. +3. Run quality checks locally. +4. Open a pull request with context and verification notes. + +Recommended checks: + +```bash +composer ic:tests +``` + +Useful targeted commands: + +```bash +composer ic:test:syntax +composer ic:test:code +composer ic:test:lint +composer ic:test:sniff +composer ic:test:static +composer ic:test:security +composer ic:test:architecture +``` + +Auto-fix and processing helpers: + +```bash +composer ic:process +``` + +## Pull Request Guidelines + +- Keep pull requests scoped to one logical change. +- Include why the change is needed and what behavior changed. +- Add or update tests when behavior changes. +- Update docs when command behavior, config, or workflow behavior changes. +- Ensure CI is green before requesting review. + +## Reporting Bugs and Requesting Features + +- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests. +- Include reproducible steps, expected behavior, and actual behavior. +- Share environment details (PHP version, OS, Composer version). diff --git a/README.md b/README.md index 992d300..3c83afd 100644 --- a/README.md +++ b/README.md @@ -1 +1,113 @@ -# TalkingBytes \ No newline at end of file +# TalkingBytes + +Transport-agnostic communication toolkit for PHP. + +TalkingBytes provides a shared middleware/event core with protocol modules for: + +- Email (SMTP/sendmail/mail/spool + IMAP/POP3/parser) +- HTTP (cURL + cURL multi) +- Webhook (sign/verify/replay) +- gRPC (adapter + retry + fake caller) + +## Install + +```bash +composer require infocyph/talkingbytes +``` + +Requirements: + +- PHP `>=8.4` +- `ext-curl` +- `ext-fileinfo` +- `ext-openssl` + +## Quick Start + +### HTTP + +```php +use Infocyph\TalkingBytes\Http\HttpClient; + +$result = HttpClient::curl() + ->withBearerToken($token) + ->timeout(10) + ->postJson('https://api.example.com/orders', [ + 'order_id' => 1001, + 'amount' => 500, + ]); + +if ($result->successful) { + $data = $result->response->json(); +} +``` + +### Email + +```php +use Infocyph\TalkingBytes\Email\Email; +use Infocyph\TalkingBytes\Email\EmailMessage; + +$result = Email::sender()->usingNull()->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.com') + ->subject('Hello') + ->text('Hello from TalkingBytes') +); +``` + +### Webhook + +```php +use Infocyph\TalkingBytes\Webhook\Webhook; +use Infocyph\TalkingBytes\Webhook\WebhookMessage; + +$delivery = Webhook::sender($httpClient) + ->withSecret('whsec_test') + ->send( + WebhookMessage::event('order.created') + ->url('https://merchant.example.com/webhook') + ->payload(['order_id' => 1001]) + ); +``` + +### gRPC + +```php +use Infocyph\TalkingBytes\Grpc\GrpcClient; +use Infocyph\TalkingBytes\Grpc\GrpcRequest; + +$result = GrpcClient::transport($transport) + ->call(GrpcRequest::create('Orders/Create', ['order_id' => 1001])); +``` + +## Full Documentation + +Detailed docs are in `docs/` (Read the Docs structure): + +- `docs/getting-started.rst` +- `docs/architecture.rst` +- `docs/email/index.rst` +- `docs/http/index.rst` +- `docs/webhook/index.rst` +- `docs/grpc/index.rst` +- `docs/events.rst` +- `docs/testing.rst` +- `docs/security.rst` +- `docs/performance.rst` +- `docs/extensions.rst` +- `docs/naming.rst` +- `docs/release-checklist.rst` + +## Quality Gates + +Run full quality and test pipeline: + +```bash +composer ic:ci +``` + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md index 533cfce..37a355e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,49 @@ # Security Policy -![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/infocyph/otp) - ## Supported Versions -| Version | Supported | -|--------------|--------------------| -| 4.x | :white_check_mark: | -| 3.x or Older | :x: | +The project currently supports security updates for the latest release. ## Reporting a Vulnerability -Report any vulnerabilities to the [security advisory tracker](https://github.com/infocyph/otp/issues)! +Please report vulnerabilities privately. + +1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`). +2. If private reporting is unavailable, contact maintainers through a private channel. +3. Do not open a public issue for security vulnerabilities. + +Please include: + +- Affected package version(s) +- PHP version and runtime environment +- Reproduction steps or proof of concept +- Impact assessment (confidentiality/integrity/availability) +- Any known workaround + +## Response Process + +- Initial acknowledgment: best effort, typically within a few days +- Triage: best effort, based on maintainer availability +- Fix and release timeline depends on severity and exploitability + +If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise. + +## Protected by PHPForge + +This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects. + +PHPForge helps keep the project reliable by running checks for: + +- Code style and standards +- Tests and syntax validation +- Static analysis and type safety +- Security and taint analysis +- Dependency vulnerability audit +- Architecture boundary validation +- Duplicate-code detection +- API snapshot and comment-policy checks +- Refactor safety checks +- Benchmark and release-readiness checks +- Git hooks and CI workflow protection + +These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release. diff --git a/captainhook.json b/captainhook.json index 8ef4f97..782a292 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,11 +15,15 @@ "options": [] }, { - "action": "composer audit", + "action": "composer normalize --dry-run", "options": [] }, { - "action": "composer tests", + "action": "composer ic:release:audit", + "options": [] + }, + { + "action": "composer ic:ci", "options": [] } ] diff --git a/composer.json b/composer.json index a05e8ab..42d28ef 100644 --- a/composer.json +++ b/composer.json @@ -1,64 +1,59 @@ { - "name": "infocyph/talkingbytes", - "description": "Communication solution in PHP!", - "type": "library", - "license": "MIT", - "authors": [ - { - "name": "abmmhasan", - "email": "abmmhasan@gmail.com" - } - ], - "keywords": [ - "email", - "smtp" - ], - "autoload": { - "psr-4": { - "Infocyph\\TakingBytes\\": "src/" - } - }, - "minimum-stability": "stable", - "prefer-stable": true, - "config": { - "sort-packages": true, - "optimize-autoloader": true, - "allow-plugins": { - "pestphp/pest-plugin": true - } - }, - "require": { - "php": ">=8.0", - "ext-fileinfo": "*" - }, - "require-dev": { - "captainhook/captainhook": "^5.23", - "laravel/pint": "^1.15", - "pestphp/pest": "^2.34", - "rector/rector": "^1.0", - "symfony/var-dumper": "^7.0" - }, - "scripts": { - "test:code": "pest --parallel --processes=10", - "test:refactor": "rector process --dry-run", - "test:lint": "pint --test", - "test:hook": [ - "captainhook hook:post-checkout", - "captainhook hook:pre-commit", - "captainhook hook:post-commit", - "captainhook hook:post-merge", - "captainhook hook:post-rewrite", - "captainhook hook:pre-push" + "name": "infocyph/talkingbytes", + "description": "Transport-agnostic communication toolkit for PHP.", + "license": "MIT", + "type": "library", + "keywords": [ + "communication", + "email", + "smtp", + "http", + "curl", + "grpc", + "webhook", + "api-client", + "transport" ], - "tests": [ - "@test:code", - "@test:lint", - "@test:refactor" + "authors": [ + { + "name": "Infocyph", + "email": "infocyph@gmail.com" + }, + { + "name": "abmmhasan", + "email": "abmmhasan@gmail.com" + } ], - "git:hook": "captainhook install --only-enabled -nf", - "test": "pest", - "refactor": "rector process", - "lint": "pint", - "post-autoload-dump": "@git:hook" - } + "require": { + "php": ">=8.4", + "ext-curl": "*", + "ext-fileinfo": "*", + "ext-openssl": "*" + }, + "require-dev": { + "infocyph/phpforge": "dev-main" + }, + "suggest": { + "ext-grpc": "Required for native gRPC transport.", + "ext-iconv": "Optional fallback for inbound charset conversion when mbstring is unavailable.", + "ext-imap": "Optional for imap_* address parsing and UTF7-IMAP fallback helpers.", + "ext-mbstring": "Recommended for robust charset conversion and encoded header handling.", + "grpc/grpc": "Required for generated PHP gRPC clients." + }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Infocyph\\TalkingBytes\\": "src/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infocyph/phpforge": true, + "pestphp/pest-plugin": true + }, + "optimize-autoloader": true, + "sort-packages": true + } } diff --git a/docs/_static/theme.css b/docs/_static/theme.css new file mode 100644 index 0000000..821e0b5 --- /dev/null +++ b/docs/_static/theme.css @@ -0,0 +1,3 @@ +.highlight-php .k { + color: #0077aa; +} diff --git a/docs/architecture.rst b/docs/architecture.rst new file mode 100644 index 0000000..be5bd45 --- /dev/null +++ b/docs/architecture.rst @@ -0,0 +1,46 @@ +Architecture +============ + +Core design +----------- + +TalkingBytes uses a shared communication core. + +- ``CommunicationRequest`` is the platform request envelope. +- ``CommunicationResult`` is the shared result shape. +- protocol responses (``HttpResponse``, ``GrpcResponse``, email result objects) remain protocol-specific. +- middleware (retry, timeout, auth, logging, rate limit, circuit breaker) is transport-agnostic. + +Event model +----------- + +A shared event bus dispatches protocol events. + +Examples: + +- ``http.request.start`` / ``http.request.finish`` +- ``grpc.request.start`` / ``grpc.request.finish`` +- ``webhook.send.start`` / ``webhook.send.finish`` +- ``email.send.start`` / ``email.send.finish`` +- ``mailbox.command.start`` / ``mailbox.command.finish`` + +Sensitive values are redacted before dispatch. + +Module boundaries +----------------- + +- ``Core``: contracts, middleware pipeline, common result/error types. +- ``Http``: cURL and cURL-multi transport layer. +- ``Grpc``: adapter for callback/native gRPC invocation. +- ``Webhook``: send/verify/receive workflows on top of HTTP. +- ``Email``: outbound transports + inbound parser + mailbox operations. + +Testing strategy +---------------- + +Every module includes fakes/assertion helpers and fake protocol servers where useful. + +- HTTP: fake transport + concurrent pool tests. +- gRPC: fake caller + retry tests. +- Webhook: signature/replay/redaction tests. +- Email: SMTP/IMAP/POP3/parser/bounce/authentication tests. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3d751ee --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import datetime +import os + +project = "TalkingBytes" +author = "Infocyph" +year_now = datetime.date.today().strftime("%Y") +copyright = f"2020-{year_now}, {author}" +version = os.environ.get("READTHEDOCS_VERSION", "latest") +release = version +language = "en" +root_doc = "index" + +extensions = [ + "sphinx.ext.todo", + "sphinx.ext.autosectionlabel", + "sphinx.ext.intersphinx", + "sphinx_copybutton", + "sphinx_design", + "sphinxcontrib.phpdomain", +] +autosectionlabel_prefix_document = True +todo_include_todos = True + +intersphinx_mapping = { + "php": ("https://www.php.net/manual/en/", None), +} + +html_theme = "sphinx_book_theme" +html_theme_options = { + "repository_url": "https://github.com/infocyph/TalkingBytes", + "repository_branch": "main", + "path_to_docs": "docs", + "use_repository_button": True, + "use_issues_button": True, + "use_download_button": True, + "home_page_in_toc": True, + "show_toc_level": 2, +} + +templates_path = ["_templates"] +html_static_path = ["_static"] +html_css_files = ["theme.css"] +html_title = f"TalkingBytes - {version} Documentation" +html_show_sourcelink = True +html_show_sphinx = False +html_last_updated_fmt = "%Y-%m-%d" diff --git a/docs/email/bounce-and-auth.rst b/docs/email/bounce-and-auth.rst new file mode 100644 index 0000000..c4afbbe --- /dev/null +++ b/docs/email/bounce-and-auth.rst @@ -0,0 +1,75 @@ +Bounce and Authentication Parsing +================================= + +Bounce parsing +-------------- + +Use ``BounceParser`` for DSN and plain-text bounce classification. + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Parser\BounceParser; + + $bounceParser = new BounceParser(); + $report = $bounceParser->parse($parsedEmail); // first report convenience + $reports = $bounceParser->parseMany($parsedEmail); // all recipients + +Bounce output uses ``BounceReport`` and ``BounceType``. + +Fields include: + +- recipient +- action +- status code +- diagnostic code +- remote MTA +- original message id +- classification (hard/soft/mailbox-full/user-unknown/etc.) + +Delivery status parser +---------------------- + +``DeliveryStatusParser`` parses ``message/delivery-status`` blocks and supports +multiple recipient sections. + +Authentication-Results parsing +------------------------------ + +Use ``AuthenticationResultsParser`` to parse inbound auth signals: + +- DKIM +- SPF +- DMARC +- ARC + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Parser\AuthenticationResultsParser; + + $results = (new AuthenticationResultsParser()) + ->parse('mx.example.net; dkim=pass header.d=example.com; spf=pass smtp.mailfrom=example.com'); + +Auth result helpers +------------------- + +``AuthenticationResults`` provides convenience methods: + +- ``passedDkim()`` +- ``passedSpf()`` +- ``passedDmarc()`` +- ``passedArc()`` +- ``isAuthenticated()`` +- ``resultFor($method)`` + +DKIM verification +----------------- + +``DkimVerifier`` validates inbound DKIM signatures with resolver abstraction. + +Resolvers: + +- ``DnsDkimPublicKeyResolver`` +- ``StaticDkimPublicKeyResolver`` +- ``CachedDkimPublicKeyResolver`` + +Use ``StaticDkimPublicKeyResolver`` for deterministic tests. diff --git a/docs/email/imap-mailbox.rst b/docs/email/imap-mailbox.rst new file mode 100644 index 0000000..f78f867 --- /dev/null +++ b/docs/email/imap-mailbox.rst @@ -0,0 +1,98 @@ +IMAP Mailbox +============ + +Overview +-------- + +Use ``Email::mailbox()->usingImap(ImapConfig)`` to work with foldered IMAP mailboxes. + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Config\ImapConfig; + use Infocyph\TalkingBytes\Email\Email; + + $mailbox = Email::mailbox()->usingImap( + new ImapConfig('imap.example.com', username: 'user', password: 'secret') + ); + +Folder operations +----------------- + +Mailbox-level methods: + +- ``folders()`` and ``folderDetails()`` +- ``folder($name)`` +- ``status($name)`` +- ``createFolder($name)``, ``renameFolder($from, $to)``, ``deleteFolder($name)`` +- ``subscribeFolder($name)``, ``unsubscribeFolder($name)`` +- ``folderExists($name)`` +- ``archive($sourceFolder, $uid, ?$archiveFolder = null)`` + +Folder object operations +------------------------ + +From ``$inbox = $mailbox->folder('INBOX')``: + +- fetch: ``fetchRaw($uid)``, ``fetchParsed($uid)``, ``fetchHeaders($uid)``, ``fetchSummary($uid)``, ``fetchAttachments($uid)``, ``fetchBodyStructure($uid)`` +- flags: ``markSeen()``, ``markUnread()``, ``addFlag()``, ``removeFlag()`` +- message ops: ``copy()``, ``move()``, ``delete()``, ``expunge()`` +- batch ops: ``markSeenMany()``, ``markUnreadMany()``, ``copyMany()``, ``moveMany()``, ``deleteMany()``, ``addFlagMany()``, ``removeFlagMany()`` +- mailbox maintenance: ``status()``, ``close()``, ``watch()`` (if transport supports IDLE) + +Search/query +------------ + +Build queries with ``MailboxSearch``. + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Mailbox\MailboxSearch; + + $refs = $inbox->query( + MailboxSearch::new() + ->unseen() + ->from('billing@example.com') + ->subjectContains('invoice') + ->newestFirst() + ->limit(25) + ); + +Criteria highlights: + +- dates: ``before()``, ``on()``, ``since()`` +- headers/content: ``from()``, ``to()``, ``cc()``, ``bcc()``, ``subjectContains()``, ``bodyContains()``, ``textContains()`` +- flags: ``seen()``, ``unseen()``, ``answered()``, ``unanswered()``, ``flagged()``, ``unflagged()``, ``deleted()``, ``undeleted()`` +- size: ``largerThan()``, ``smallerThan()`` +- UID and keywords: ``uidRange()``, ``keyword()``, ``unkeyword()`` +- attachment hint: ``hasAttachment()`` + +Cost controls +------------- + +``MailboxSearch`` includes safeguards for expensive operations: + +- ``maxSummaryFetches($n)`` +- ``maxClientSideFilterFetches($n)`` +- ``requireExplicitLimitForExpensiveSearch(true)`` + +These protect sort/filter paths that require extra fetches. + +Security/validation +------------------- + +IMAP commands use guards for: + +- folder names (control char and length checks) +- UIDs (must be positive) +- part numbers for ``BODY.PEEK`` section fetches +- custom flags (injection-safe format) + +Errors +------ + +Common exceptions: + +- ``MailboxConnectionException`` +- ``MailboxAuthenticationException`` +- ``MailboxProtocolException`` +- ``MailboxException`` base type diff --git a/docs/email/inbound-parser.rst b/docs/email/inbound-parser.rst new file mode 100644 index 0000000..09c5eb3 --- /dev/null +++ b/docs/email/inbound-parser.rst @@ -0,0 +1,83 @@ +Inbound Parser +============== + +Primary parser +-------------- + +``RawEmailParser`` parses raw RFC822 messages into ``ParsedEmail``. + +Pipeline components: + +- ``HeaderParser`` +- ``AddressParser`` +- ``MimeParser`` / ``MimePartParser`` +- ``TransferDecoder`` +- ``CharsetDecoder`` +- ``AttachmentExtractor`` + +Basic parse +----------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; + + $parser = new RawEmailParser(); + $parsed = $parser->parse($rawEml, ['source' => 'api']); + +Parsed output +------------- + +``ParsedEmail`` includes: + +- address lists: ``from``, ``to``, ``cc``, ``bcc`` +- headers and helper accessors (``header()``, ``headers()``) +- ``subject``, ``messageId``, ``inReplyTo``, ``references`` +- ``textBody`` and ``htmlBody`` +- flat MIME part list in ``parts`` +- ``attachments`` as ``ReceivedAttachment`` +- ``metadata`` passthrough + +Convenience helpers: + +- ``hasAttachments()`` +- ``attachmentCount()`` +- ``firstAttachment()`` +- ``fromEmail()`` +- ``subjectOrEmpty()`` +- ``isReply()`` +- ``isBounceCandidate()`` + +Attachment object +----------------- + +``ReceivedAttachment`` supports: + +- ``contents()`` +- ``saveTo($path)`` +- ``streamTo($resource)`` +- ``safeFilename()`` +- ``isInline()`` and ``isImage()`` +- ``extension()`` +- ``contentHash($algorithm = 'sha256')`` + +Limits +------ + +Parsing is bounded by ``EmailLimits``: + +- max raw message bytes +- max header bytes/count +- max MIME depth/parts +- max decoded body bytes +- max attachment count/size + +Exceeding limits raises ``EmailParseException``. + +Header behaviors +---------------- + +- duplicate headers are preserved in inbound header bags +- folded headers are unfolded +- encoded words are decoded with tolerant fallback +- malformed shapes are tolerated where possible (parser safety first) diff --git a/docs/email/index.rst b/docs/email/index.rst new file mode 100644 index 0000000..20e61ca --- /dev/null +++ b/docs/email/index.rst @@ -0,0 +1,19 @@ +Email Module +============ + +The Email module covers outbound delivery, inbound parsing, spool ingestion, +IMAP mailbox operations, POP3 mailbox operations, bounce parsing, and inbound +authentication signal parsing. + +.. toctree:: + :maxdepth: 2 + + quickstart + outbound + inbound-parser + spool-receiver + imap-mailbox + pop3-mailbox + bounce-and-auth + limits-security + testing diff --git a/docs/email/limits-security.rst b/docs/email/limits-security.rst new file mode 100644 index 0000000..fcbb166 --- /dev/null +++ b/docs/email/limits-security.rst @@ -0,0 +1,52 @@ +Email Limits and Security +========================= + +Central limits +-------------- + +``EmailLimits`` centralizes parser and payload bounds. + +Main fields: + +- ``maxMessageBytes`` +- ``maxAttachmentBytes`` +- ``maxMimeDepth`` +- ``maxMimeParts`` +- ``maxHeaderBytes`` +- ``maxHeaderCount`` +- ``maxDecodedBodyBytes`` + +These are enforced in raw parse, spool receive, and mailbox fetch paths. + +Header and command safety +------------------------- + +Guards are applied to prevent injection: + +- ``HeaderValueGuard`` for outbound header values +- ``MailboxUidGuard`` for UID commands +- ``ImapPartNumberGuard`` for ``BODY.PEEK[...]`` sections +- ``MailboxFlagGuard`` for flag formats +- ``MailboxFolderNameGuard`` for folder names +- ``Pop3MessageNumberGuard`` for POP3 commands + +Event redaction +--------------- + +Mailbox command events are redacted through ``MailboxCommandRedactor``. +Sensitive fields such as LOGIN/PASS/AUTH payloads are never emitted in clear. + +Transport security +------------------ + +Recommended production defaults: + +- SMTP ``StartTlsRequired`` or ``Ssl`` +- IMAP ``Ssl`` or ``StartTlsRequired`` +- avoid plaintext POP3 where possible + +Attachment safety +----------------- + +Use ``ReceivedAttachment::safeFilename()`` before persisting inbound filenames +from untrusted sources. diff --git a/docs/email/outbound.rst b/docs/email/outbound.rst new file mode 100644 index 0000000..6fa9bcf --- /dev/null +++ b/docs/email/outbound.rst @@ -0,0 +1,104 @@ +Outbound Delivery +================= + +Transports +---------- + +Outbound entrypoint is ``Emailer`` (usually via ``Email::sender()``). + +Supported transports: + +- SMTP: ``usingSmtp(SmtpConfig $config)`` +- sendmail: ``usingSendmail(SendmailConfig $config = new SendmailConfig())`` +- PHP ``mail()``: ``usingMailFunction()`` +- spool writer: ``usingSpool(SpoolConfig $config)`` +- log transport: ``usingLog(LogEmailConfig $config)`` +- null transport: ``usingNull()`` +- fake transport: ``fake()`` + +Message construction +-------------------- + +``EmailMessage`` is immutable. Chain operations return a new instance. + +Core fields: + +- envelope: ``from()``, ``to()``, ``cc()``, ``bcc()`` +- subject/body: ``subject()``, ``text()``, ``html()`` +- headers: ``header()``, ``headers()``, ``withHeaders()``, ``withoutHeader()`` +- metadata: ``tag()``, ``withMetadata()`` + +Attachment methods +------------------ + +- file attachment: ``attach($path, ?$filename = null, $maxSizeBytes = 26214400)`` +- data attachment: ``attachData($content, $name, $mimeType = 'application/octet-stream')`` +- stream attachment: ``attachStream($stream, $name, $mimeType = 'application/octet-stream')`` +- inline file: ``attachInline($path, $contentId, ?$filename = null)`` +- inline data: ``attachInlineData($content, $name, $contentId, $mimeType = 'application/octet-stream')`` +- inline stream: ``attachInlineStream($stream, $name, $contentId, $mimeType = 'application/octet-stream')`` +- helper: ``embed()`` returns ``[EmailMessage, contentId]`` + +DKIM and decorators +------------------- + +``Emailer`` supports decorator composition: + +- ``withDkim(DkimConfig $config)`` +- ``withRetry(RetryPolicy $policy)`` +- ``withFallback(array $fallbackTransports)`` +- ``withRateLimit(RateLimiter $limiter)`` +- ``withLogging(callable $logger)`` +- ``withPsrLogger(object $logger, string $level = 'info')`` + +DSN and routing helpers +----------------------- + +``EmailMessage`` includes DSN and envelope helpers: + +- ``deliveryNotification(success, failure, delay, returnFull, envelopeId)`` +- ``dsnEnvelopeId(?string $envelopeId)`` +- ``returnPath($mailbox)`` and alias ``bounceTo($mailbox)`` +- ``withoutDeliveryNotification()`` + +Template helper +--------------- + +``template()`` provides lightweight variable substitution without a full engine. + +.. code-block:: php + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.com') + ->subject('Welcome') + ->template('

Hello {{name}}

', ['name' => 'Ari'], asHtml: true); + +SMTP configuration +------------------ + +Important ``SmtpConfig`` options: + +- ``security``: ``None``, ``StartTlsOptional``, ``StartTlsRequired``, ``Ssl`` +- ``authMechanism``: ``Auto``, ``Plain``, ``Login``, ``None`` +- ``utf8Policy``: ``Reject``, ``Auto``, ``Require`` +- ``allowEightBitMime`` +- ``captureTranscript`` (debug metadata) +- ``maxMessageBytes`` + +Per-recipient status +-------------------- + +``EmailDeliveryReport`` provides recipient-level outcome with ``EmailRecipientResult``: + +- recipient email +- accepted boolean +- SMTP code +- SMTP response + +Behavior notes +-------------- + +- BCC recipients are part of envelope RCPT flow and are not written into message headers. +- Outbound builder normalizes line endings to CRLF. +- Streaming paths are used for large payload handling in SMTP/sendmail/spool transports. diff --git a/docs/email/pop3-mailbox.rst b/docs/email/pop3-mailbox.rst new file mode 100644 index 0000000..5d52238 --- /dev/null +++ b/docs/email/pop3-mailbox.rst @@ -0,0 +1,59 @@ +POP3 Mailbox +============ + +Overview +-------- + +POP3 is intentionally exposed through ``Pop3Mailbox`` (not the IMAP foldered API). + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Config\Pop3Config; + use Infocyph\TalkingBytes\Email\Email; + + $pop3 = Email::mailbox()->usingPop3( + new Pop3Config('pop.example.com', username: 'user', password: 'secret') + ); + +Capabilities +------------ + +- ``status()`` +- ``listMessageRefs(?int $limit = null)`` +- ``fetchRaw(int $messageNumber)`` +- ``fetchParsed(int $messageNumber)`` +- ``delete(int $messageNumber)`` +- ``receiveOldest()`` and ``receiveNewest()`` +- ``reset()`` (RSET) +- ``logout()`` + +UIDL support +------------ + +``listMessageRefs()`` populates ``MailboxMessageRef::externalId`` from POP3 UIDL. +Use UIDL when you need stable external identity across sessions. + +Message-number guard +-------------------- + +POP3 command targets are validated via ``Pop3MessageNumberGuard``: + +- message number must be positive +- avoids invalid/injection-prone command values + +Protocol limitations +-------------------- + +POP3 limitations are expected: + +- no folders +- no seen/unseen flag model +- no server-side rich search equivalent to IMAP +- message numbers can shift after deletions/commit + +Operational notes +----------------- + +- deletion is committed on ``QUIT`` in typical POP3 flows +- ``reset()`` can clear deletion marks before commit +- multiline response parsing includes dot-termination and dot-unstuffing handling diff --git a/docs/email/quickstart.rst b/docs/email/quickstart.rst new file mode 100644 index 0000000..b0558fa --- /dev/null +++ b/docs/email/quickstart.rst @@ -0,0 +1,84 @@ +Email Quick Start +================= + +Facade entry points +------------------- + +- ``Email::sender()`` for outbound delivery +- ``Email::receiver()`` for inbound source readers (currently spool) +- ``Email::mailbox()`` for network mailbox operations (IMAP, POP3) + +Minimal outbound +---------------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Email; + use Infocyph\TalkingBytes\Email\EmailMessage; + + $result = Email::sender()->usingNull()->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.com') + ->subject('Hello') + ->text('Hello from TalkingBytes') + ); + +SMTP outbound +------------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Config\SmtpConfig; + use Infocyph\TalkingBytes\Email\Config\SmtpCredentials; + use Infocyph\TalkingBytes\Email\Enum\SmtpSecurity; + + $smtp = new SmtpConfig( + host: 'smtp.example.com', + port: 587, + security: SmtpSecurity::StartTlsRequired, + credentials: new SmtpCredentials('user@example.com', 'secret'), + ); + + $sender = Email::sender()->usingSmtp($smtp); + +Spool inbound receive +--------------------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Config\SpoolConfig; + + $receiver = Email::receiver()->usingSpool(new SpoolConfig('/var/mail/inbound')); + $email = $receiver->receive(); + + if ($email !== null) { + $subject = $email->subject; + } + +IMAP mailbox +------------ + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Config\ImapConfig; + + $mailbox = Email::mailbox()->usingImap( + new ImapConfig('imap.example.com', username: 'user', password: 'secret') + ); + + $inbox = $mailbox->folder('INBOX'); + $refs = $inbox->query(); + +POP3 mailbox +------------ + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Config\Pop3Config; + + $pop3 = Email::mailbox()->usingPop3( + new Pop3Config('pop.example.com', username: 'user', password: 'secret') + ); + + $refs = $pop3->listMessageRefs(limit: 10); diff --git a/docs/email/spool-receiver.rst b/docs/email/spool-receiver.rst new file mode 100644 index 0000000..3bf83ed --- /dev/null +++ b/docs/email/spool-receiver.rst @@ -0,0 +1,64 @@ +Spool Receiver +============== + +Overview +-------- + +``SpoolEmailReceiver`` reads ``.eml`` files from a spool directory and parses +messages via ``EmailParser`` (default ``RawEmailParser``). + +Create receiver +--------------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Config\SpoolConfig; + use Infocyph\TalkingBytes\Email\Email; + + $receiver = Email::receiver()->usingSpool( + new SpoolConfig( + directory: '/var/mail/inbound', + processingDirectory: '/var/mail/processing', + lockBeforeRead: true, + maxMessages: 50, + olderThanSeconds: 2, + ), + deleteAfterRead: true, + moveAfterRead: '/var/mail/processed', + failedDirectory: '/var/mail/failed', + ); + +Methods +------- + +- ``peek()`` reads next message without consuming it. +- ``receive()`` receives one parsed email. +- ``receiveParsed()`` same as ``receive()``. +- ``receiveMany(?int $limit = null)`` receives in batch. + +Consumption behavior +-------------------- + +Depending on config/options: + +- may move to processing directory before parse +- on success can delete or move-after-read +- on parse/read failure can quarantine to failed directory +- optional lock-before-read for worker safety + +Spool metadata +-------------- + +Parsed email metadata includes spool fields: + +- ``source = spool`` +- ``path`` / ``original_path`` +- ``processing_path`` +- ``consumed_at`` +- ``size_bytes`` + +Failure handling +---------------- + +- read/parse failures trigger ``email.parse.failed`` and ``email.receive.finish`` with error metadata +- quarantine is best-effort if filesystem operations fail diff --git a/docs/email/testing.rst b/docs/email/testing.rst new file mode 100644 index 0000000..cbbf24f --- /dev/null +++ b/docs/email/testing.rst @@ -0,0 +1,61 @@ +Email Testing +============= + +Outbound fake transport +----------------------- + +Create a fake sender: + +.. code-block:: php + + $sender = \Infocyph\TalkingBytes\Email\Emailer::fake(); + + $sender->send($message); + + $sender->assertable()->assertSentCount(1); + +Available assertion helpers include: + +- ``assertSent()`` +- ``assertNothingSent()`` +- ``assertSentCount()`` +- ``assertSentTo()`` +- ``assertSentFrom()`` +- ``assertSentSubject()`` +- ``assertHasAttachment()`` +- ``assertHasInlineAttachment()`` +- ``assertHeader()`` +- ``assertBodyContains()`` + +Mailbox testing +--------------- + +Use ``FakeMailbox``/``FakeMailboxTransport`` for mailbox behavior in tests. + +Parser testing +-------------- + +The suite covers: + +- raw MIME nesting and transfer decoding +- duplicate/folded/encoded headers +- charset edge cases +- attachment extraction and filename handling +- parser limit enforcement + +Spool testing +------------- + +Spool tests include: + +- peek/receive/receiveMany +- delete/move/failed quarantine behavior +- lock and processing directory paths +- concurrent-safe file handling assumptions + +Event assertions +---------------- + +Attach a listener via ``Email::events($listener)`` and assert event payload +shape for ``email.send.*``, ``email.receive.*``, ``email.parse.failed``, +``mailbox.command.*``, and ``bounce.detected``. diff --git a/docs/events.rst b/docs/events.rst new file mode 100644 index 0000000..bf8433c --- /dev/null +++ b/docs/events.rst @@ -0,0 +1,39 @@ +Events +====== + +Overview +-------- + +TalkingBytes dispatches lifecycle events through a shared communication event bus. + +Set dispatcher +-------------- + +.. code-block:: php + + \Infocyph\TalkingBytes\Core\Event\CommunicationEventBus::listen( + static function (string $event, array $payload): void { + // route to logger/metrics + } + ); + +Reset dispatcher +---------------- + +.. code-block:: php + + \Infocyph\TalkingBytes\Core\Event\CommunicationEventBus::reset(); + +Event families +-------------- + +- HTTP: ``http.request.*``, ``http.retry``, ``http.pool.*`` +- gRPC: ``grpc.request.*``, ``grpc.retry`` +- Webhook: ``webhook.send.*``, ``webhook.retry``, ``webhook.verified``, ``webhook.rejected``, ``webhook.received`` +- Email: ``email.send.*``, ``email.receive.*``, ``email.parse.failed`` +- Mailbox: ``mailbox.command.*`` + +Security note +------------- + +Sensitive values are redacted before events are emitted. Do not rely on events for raw secret access. diff --git a/docs/extensions.rst b/docs/extensions.rst new file mode 100644 index 0000000..7588730 --- /dev/null +++ b/docs/extensions.rst @@ -0,0 +1,25 @@ +Extension Policy +================ + +Required extensions +------------------- + +From ``composer.json``: + +- ``ext-curl`` +- ``ext-fileinfo`` +- ``ext-openssl`` + +Suggested extensions/packages +----------------------------- + +- ``ext-grpc`` for native gRPC transport +- ``grpc/grpc`` for generated PHP gRPC clients +- ``ext-mbstring`` for robust charset conversion +- ``ext-iconv`` as charset fallback when mbstring is unavailable +- ``ext-imap`` for optional address parsing and UTF7-IMAP fallback helpers + +Fallback behavior +----------------- + +Some parser/mailbox helpers degrade gracefully when optional extensions are unavailable. Check test skips in CI logs to detect extension-limited environments. diff --git a/docs/getting-started.rst b/docs/getting-started.rst new file mode 100644 index 0000000..ebaac6b --- /dev/null +++ b/docs/getting-started.rst @@ -0,0 +1,88 @@ +Getting Started +=============== + +Requirements +------------ + +- PHP ``>=8.4`` +- ``ext-curl`` +- ``ext-fileinfo`` +- ``ext-openssl`` + +Install +------- + +.. code-block:: bash + + composer require infocyph/talkingbytes + +Namespace +--------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Http\HttpClient; + +Quick examples +-------------- + +HTTP send (cURL) +~~~~~~~~~~~~~~~~ + +.. code-block:: php + + $result = HttpClient::curl() + ->withBearerToken($token) + ->timeout(10) + ->postJson('https://api.example.com/orders', [ + 'order_id' => 1001, + 'amount' => 500, + ]); + + if ($result->successful) { + $data = $result->response->json(); + } + +gRPC send +~~~~~~~~~ + +.. code-block:: php + + $result = \Infocyph\TalkingBytes\Grpc\GrpcClient::transport($transport) + ->call(\Infocyph\TalkingBytes\Grpc\GrpcRequest::create( + 'OrderService/CreateOrder', + ['order_id' => 1001], + )); + +Webhook receive +~~~~~~~~~~~~~~~ + +.. code-block:: php + + $event = \Infocyph\TalkingBytes\Webhook\Webhook::receiver('whsec_test') + ->receive($rawBody, $headers); + +Email send +~~~~~~~~~~ + +.. code-block:: php + + $smtp = \Infocyph\TalkingBytes\Email\Config\SmtpConfig::fromArray([ + 'host' => 'smtp.example.com', + 'port' => 587, + 'security' => \Infocyph\TalkingBytes\Email\Enum\SmtpSecurity::StartTls, + 'credentials' => [ + 'username' => 'user@example.com', + 'password' => 'secret', + ], + ]); + + $result = \Infocyph\TalkingBytes\Email\Email::sender() + ->usingSmtp($smtp) + ->send( + \Infocyph\TalkingBytes\Email\EmailMessage::make() + ->from('sender@example.com') + ->to('receiver@example.com') + ->subject('Hello') + ->text('Hello from TalkingBytes') + ); diff --git a/docs/grpc/index.rst b/docs/grpc/index.rst new file mode 100644 index 0000000..c58f14b --- /dev/null +++ b/docs/grpc/index.rst @@ -0,0 +1,14 @@ +gRPC Module +=========== + +TalkingBytes gRPC is a lightweight adapter layer around callback/native gRPC +invocation, integrated with communication middleware and result mapping. + +.. toctree:: + :maxdepth: 2 + + quickstart + streaming + retry + native + testing diff --git a/docs/grpc/native.rst b/docs/grpc/native.rst new file mode 100644 index 0000000..93e3d43 --- /dev/null +++ b/docs/grpc/native.rst @@ -0,0 +1,50 @@ +Native Invoker Bridge +===================== + +Purpose +------- + +``GrpcClient::usingNative()`` provides a boundary for generated/ext-grpc +clients while keeping core module protocol-agnostic. + +Interfaces +---------- + +- ``NativeGrpcInvoker`` +- ``NativeGrpcResult`` +- ``NativeGrpcStreamingInvoker`` +- ``GeneratedStubGrpcInvoker`` (built-in adapter) + +Behavior +-------- + +The invoker adapter is responsible for: + +- calling native gRPC stubs +- mapping native status/payload/metadata +- deadline conversion expectations + +This keeps generated-client specifics out of the shared communication core. + +Generated stub adapter +---------------------- + +``GeneratedStubGrpcInvoker`` adapts generated ``grpc/grpc`` stub clients using +duck-typed call objects (``wait()``, ``responses()``/``read()``, ``write()``). + +.. code-block:: php + + use Infocyph\TalkingBytes\Grpc\GrpcClient; + use Infocyph\TalkingBytes\Grpc\Native\GeneratedStubGrpcInvoker; + + $stub = new \Orders\OrderServiceClient('orders.internal:443', [ + 'credentials' => \Grpc\ChannelCredentials::createSsl(), + ]); + + $adapter = new GeneratedStubGrpcInvoker($stub, [ + '/orders.v1.OrderService/Create' => 'Create', + ]); + + $client = GrpcClient::usingNativeStreaming($adapter, $adapter); + +Method map keys are gRPC method paths; values are PHP stub method names. diff --git a/docs/grpc/quickstart.rst b/docs/grpc/quickstart.rst new file mode 100644 index 0000000..2fbef65 --- /dev/null +++ b/docs/grpc/quickstart.rst @@ -0,0 +1,37 @@ +gRPC Quick Start +================ + +Basic call +---------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Grpc\GrpcClient; + use Infocyph\TalkingBytes\Grpc\GrpcRequest; + + $result = GrpcClient::transport($transport) + ->call(GrpcRequest::create('Orders/Create', ['order_id' => 1001])); + + if ($result->successful) { + $payload = $result->response->payload; + } + +Request model +------------- + +``GrpcRequest`` validates: + +- method format +- metadata keys/values +- deadline bounds + +Response model +-------------- + +``GrpcResponse`` includes: + +- ``status`` (``GrpcStatus``) +- payload +- headers and trailers metadata + +Failures are mapped into ``CommunicationResult`` with error metadata. diff --git a/docs/grpc/retry.rst b/docs/grpc/retry.rst new file mode 100644 index 0000000..5937ef9 --- /dev/null +++ b/docs/grpc/retry.rst @@ -0,0 +1,24 @@ +gRPC Retry +========== + +Policy +------ + +Use ``GrpcRetryPolicy`` with ``withGrpcRetry()``. + +.. code-block:: php + + use Infocyph\TalkingBytes\Grpc\GrpcClient; + use Infocyph\TalkingBytes\Grpc\Retry\GrpcRetryPolicy; + + $client = GrpcClient::transport($transport)->withGrpcRetry( + GrpcRetryPolicy::standard(attempts: 3, baseDelayMs: 100) + ); + +Policy supports backoff control, jitter, and retryable status rules. + +Idempotency guidance +-------------------- + +Retry only idempotent operations unless your application has deduplication +semantics for non-idempotent RPC methods. diff --git a/docs/grpc/streaming.rst b/docs/grpc/streaming.rst new file mode 100644 index 0000000..8b29ae8 --- /dev/null +++ b/docs/grpc/streaming.rst @@ -0,0 +1,69 @@ +gRPC Streaming +============== + +Overview +-------- + +TalkingBytes supports streaming through the native invoker boundary. + +To enable it, create client with both unary and streaming native invokers: + +.. code-block:: php + + $client = GrpcClient::usingNativeStreaming($unaryInvoker, $streamingInvoker); + +Check support +------------- + +.. code-block:: php + + if (!$client->supportsStreaming()) { + // fallback path + } + +Server stream +------------- + +.. code-block:: php + + $result = $client->serverStream( + new GrpcRequest('Orders/Stream', ['cursor' => 1]), + static function (mixed $message): void { + // handle each streamed response message + } + ); + +Client stream +------------- + +.. code-block:: php + + $result = $client->clientStream( + method: 'Orders/Upload', + messages: $payloadIterator, + ); + +Bidirectional stream +-------------------- + +.. code-block:: php + + $result = $client->bidiStream( + method: 'Orders/Bidi', + messages: $outboundIterator, + onMessage: static function (mixed $incoming): void { + // handle inbound stream messages + }, + ); + +Binary metadata +--------------- + +Use explicit binary metadata APIs: + +- ``withBinaryValue('trace-bin', $bytes)`` +- ``withBinary('trace-bin', [...])`` +- ``binaryValues('trace-bin')`` +- ``firstBinary('trace-bin')`` + +Non-binary metadata continues to use ``withValue()``, ``with()``, and ``values()``. diff --git a/docs/grpc/testing.rst b/docs/grpc/testing.rst new file mode 100644 index 0000000..2d9b77e --- /dev/null +++ b/docs/grpc/testing.rst @@ -0,0 +1,26 @@ +gRPC Testing +============ + +Utilities +--------- + +- ``FakeGrpcCaller`` +- ``AssertableGrpcCaller`` + +Example +------- + +.. code-block:: php + + use Infocyph\TalkingBytes\Grpc\GrpcClient; + use Infocyph\TalkingBytes\Grpc\GrpcRequest; + use Infocyph\TalkingBytes\Grpc\Testing\FakeGrpcCaller; + + $fake = (new FakeGrpcCaller())->pushOk(['ok' => true]); + + $client = GrpcClient::using($fake); + $client->call(GrpcRequest::create('Orders/Create', ['order_id' => 1])); + + $fake->assert()->assertCallCount(1); + +Assertions cover method, payload, metadata, and call count behavior. diff --git a/docs/http/concurrency.rst b/docs/http/concurrency.rst new file mode 100644 index 0000000..563d4b8 --- /dev/null +++ b/docs/http/concurrency.rst @@ -0,0 +1,38 @@ +Concurrency +=========== + +Entry point +----------- + +Use ``HttpClient::multi($maxConcurrency)`` and send keyed request arrays. + +.. code-block:: php + + use Infocyph\TalkingBytes\Http\HttpClient; + use Infocyph\TalkingBytes\Http\HttpRequest; + + $pool = HttpClient::multi(10)->sendMany([ + 'users' => HttpRequest::get('https://api.example.com/users'), + 'orders' => HttpRequest::get('https://api.example.com/orders'), + ]); + +Pool result helpers +------------------- + +``PoolResult`` provides: + +- ``all()`` +- ``get($key)`` +- ``successful()`` and ``failed()`` +- ``successfulCount()`` and ``failedCount()`` +- ``firstError()`` +- ``hasFailures()`` + +Behavior +-------- + +- user-defined keys are preserved +- max concurrency is enforced +- per-request configuration is respected +- fail-fast mode is supported via request-pool options +- cleanup runs for active handles on early stop/error paths diff --git a/docs/http/index.rst b/docs/http/index.rst new file mode 100644 index 0000000..929e139 --- /dev/null +++ b/docs/http/index.rst @@ -0,0 +1,16 @@ +HTTP Module +=========== + +The HTTP module is a cURL-native transport layer with middleware, retry, +concurrency, streaming, testing fakes, security guards, and event hooks. + +.. toctree:: + :maxdepth: 2 + + quickstart + requests-and-bodies + retry-and-transport + concurrency + streaming + security + testing diff --git a/docs/http/quickstart.rst b/docs/http/quickstart.rst new file mode 100644 index 0000000..99c917b --- /dev/null +++ b/docs/http/quickstart.rst @@ -0,0 +1,39 @@ +HTTP Quick Start +================ + +Basic JSON request +------------------ + +.. code-block:: php + + use Infocyph\TalkingBytes\Http\HttpClient; + + $result = HttpClient::curl() + ->withBearerToken($token) + ->timeout(10) + ->postJson('https://api.example.com/orders', [ + 'order_id' => 1001, + 'amount' => 500, + ]); + + if ($result->successful) { + $data = $result->response->json(); + } + +Client shortcuts +---------------- + +``HttpClient`` helpers include: + +- ``get()``, ``head()``, ``options()``, ``delete()`` +- ``postJson()``, ``putJson()``, ``patchJson()`` +- ``postForm()`` +- ``postRaw()`` + +Configuration entrypoints +------------------------- + +- ``HttpClient::curl()`` +- ``HttpClient::multi($maxConcurrency)`` +- ``HttpClient::fake()`` +- ``HttpClient::fromConfig(HttpClientConfig::fromArray(...))`` diff --git a/docs/http/requests-and-bodies.rst b/docs/http/requests-and-bodies.rst new file mode 100644 index 0000000..40945d9 --- /dev/null +++ b/docs/http/requests-and-bodies.rst @@ -0,0 +1,51 @@ +Requests and Bodies +=================== + +Request model +------------- + +``HttpRequest`` is immutable and validates URL/header safety. + +Factories: + +- ``get()``, ``post()``, ``put()``, ``patch()``, ``delete()``, ``head()``, ``options()`` + +Header helpers +-------------- + +- ``header($name, $value)`` +- ``headers(array $headers)`` +- ``withoutHeader($name)`` +- ``acceptJson()``, ``contentType()``, ``userAgent()`` + +Query helpers +------------- + +- ``query($name, $value)`` +- ``queries(array $pairs)`` +- ``withoutQuery($name)`` + +Body types +---------- + +- JSON: ``json($payload, $flags = 0)`` +- Form: ``form($payload)`` +- Raw: ``raw($body, $contentType)`` +- Multipart: ``multipart()->field()->file()->data()->stream()`` + +Auth helpers +------------ + +At client level: + +- ``withBasicAuth()`` +- ``withBearerToken()`` +- ``withApiKey()`` +- ``withQueryAuth()`` +- ``withAuthenticator()`` + +Signing +------- + +Use ``withSigner(new HmacSha256Signer(...))`` or +``SignedRequestAuth`` depending on integration style. diff --git a/docs/http/retry-and-transport.rst b/docs/http/retry-and-transport.rst new file mode 100644 index 0000000..493d884 --- /dev/null +++ b/docs/http/retry-and-transport.rst @@ -0,0 +1,50 @@ +Retry and Transport +=================== + +Transport stack +--------------- + +Single-request transport uses: + +- ``CurlHandleConfigurator`` for ``curl_setopt`` mapping +- ``CurlTransport`` for execution +- ``CurlResultFactory`` for ``CommunicationResult`` + ``HttpResponse`` creation + +Retry policy +------------ + +Use ``HttpRetryPolicy`` with ``HttpClient::withHttpRetry()``. + +.. code-block:: php + + use Infocyph\TalkingBytes\Http\HttpClient; + use Infocyph\TalkingBytes\Http\Retry\HttpRetryPolicy; + + $client = HttpClient::curl()->withHttpRetry( + HttpRetryPolicy::standard(attempts: 3, maxRetryAfterSeconds: 30) + ); + +Default transient statuses include: + +- 408 +- 425 +- 429 +- 500 +- 502 +- 503 +- 504 + +``Retry-After`` header values are supported in both second and HTTP-date forms. + +Client configuration object +--------------------------- + +Use ``HttpClientConfig::fromArray()`` for centralized defaults: + +- timeout/connect-timeout +- redirect flags +- TLS verify flags +- proxy settings +- user-agent +- max response bytes +- default headers diff --git a/docs/http/security.rst b/docs/http/security.rst new file mode 100644 index 0000000..9aae541 --- /dev/null +++ b/docs/http/security.rst @@ -0,0 +1,39 @@ +HTTP Security +============= + +URL and header validation +------------------------- + +Requests reject unsafe inputs: + +- non-http/https schemes +- control characters and CRLF injection shapes +- invalid header names/values + +SSRF controls +------------- + +Available request controls: + +- ``allowHosts(array $hosts)`` +- ``blockHosts(array $hosts)`` +- ``blockPrivateNetworks(bool $enabled = true)`` + +Redirect safety +--------------- + +When redirects are enabled, destination checks still apply. +Redirect policy defaults to disabled for safer API behavior. + +TLS and certificate controls +---------------------------- + +- peer/host verification enabled by default +- optional CA bundle path and client certificate/key support +- explicit insecure mode exists and should be avoided in production + +Redaction +--------- + +``HttpRedactor`` masks sensitive data in events/log payloads, +including common auth headers and query secrets. diff --git a/docs/http/streaming.rst b/docs/http/streaming.rst new file mode 100644 index 0000000..81211de --- /dev/null +++ b/docs/http/streaming.rst @@ -0,0 +1,46 @@ +Streaming +========= + +Download +-------- + +Two modes: + +- ``downloadTo($path)`` for straightforward writes +- ``streamDownloadTo($path)`` for temp-file streaming + atomic finalize + +Use response size bounds: + +- ``maxResponseBytes($bytes)`` +- ``maxDownloadBytes($bytes)`` + +Upload +------ + +Direct upload helpers: + +- ``uploadFromFile($path)`` +- ``uploadFromStream($stream, $size)`` +- ``maxUploadBytes($bytes)`` + +Multipart +--------- + +Use ``MultipartBody`` explicitly for multipart construction. + +.. code-block:: php + + use Infocyph\TalkingBytes\Http\Body\MultipartBody; + use Infocyph\TalkingBytes\Http\HttpRequest; + + $multipart = MultipartBody::new() + ->addField('name', 'report') + ->addFile('file', '/tmp/report.pdf'); + + $request = HttpRequest::post('https://api.example.com/upload')->multipart($multipart); + +Notes +----- + +- Multipart stream/data parts are materialized into temporary files before cURL transfer. +- Streamed download mode cleans temporary files on failure paths. diff --git a/docs/http/testing.rst b/docs/http/testing.rst new file mode 100644 index 0000000..75d201e --- /dev/null +++ b/docs/http/testing.rst @@ -0,0 +1,40 @@ +HTTP Testing +============ + +Fake transports +--------------- + +Available testing transports: + +- ``FakeHttpTransport`` +- ``AssertableHttpTransport`` +- ``SequenceHttpTransport`` +- ``SpyHttpTransport`` + +Typical flow +------------ + +.. code-block:: php + + $client = \Infocyph\TalkingBytes\Http\HttpClient::fake(); + + $client->send(\Infocyph\TalkingBytes\Http\HttpRequest::get('https://api.example.com/users')); + + $client->assert()->assertRequestCount(1); + +Assertions +---------- + +- request count and URL/method checks +- predicate checks with ``assertRequestedWhere`` +- header/body inspections for JSON/form/multipart cases + +Pool tests +---------- + +Concurrent tests validate: + +- key-preserving result mapping +- fail-fast behavior +- mixed success/failure collection +- pool event lifecycle dispatch diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..36c19e5 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,40 @@ +TalkingBytes Documentation +========================= + +TalkingBytes is a transport-agnostic communication toolkit for PHP. + +It provides: + +- outbound and inbound email (SMTP, sendmail, spool, IMAP, POP3, parser) +- cURL-native HTTP client with retry, streaming, fakes, and concurrency +- webhook sender/receiver with HMAC verification and replay protection +- gRPC adapter with retry, fake callers, and native invoker bridge +- shared middleware pipeline, event bus, testing transports, and resilience primitives + +.. toctree:: + :maxdepth: 2 + :caption: Start Here + + getting-started + architecture + +.. toctree:: + :maxdepth: 2 + :caption: Modules + + email/index + http/index + webhook/index + grpc/index + +.. toctree:: + :maxdepth: 2 + :caption: Cross-Cutting + + events + testing + security + performance + extensions + naming + release-checklist diff --git a/docs/naming.rst b/docs/naming.rst new file mode 100644 index 0000000..8c8c7eb --- /dev/null +++ b/docs/naming.rst @@ -0,0 +1,13 @@ +Naming Map +========== + +Use these boundaries consistently: + +- ``Emailer``: outbound email pipeline +- ``EmailReceiver``: one-by-one inbound source (for example spool) +- ``Mailbox``: IMAP/foldered mailbox operations +- ``Pop3Mailbox``: POP3-specific mailbox operations +- ``RawEmailParser`` and parser stack: raw ``.eml`` parsing +- ``HttpClient``: cURL HTTP entry point +- ``GrpcClient``: gRPC adapter entry point +- ``Webhook``: webhook send/verify/receive entry point diff --git a/docs/performance.rst b/docs/performance.rst new file mode 100644 index 0000000..eff8730 --- /dev/null +++ b/docs/performance.rst @@ -0,0 +1,29 @@ +Performance +=========== + +HTTP +---- + +- use streaming download/upload for large payloads +- set max body/download/upload limits +- use concurrent pool for high-latency fan-out + +Email +----- + +- use streaming message build/send paths for large attachments +- use parser limits to bound inbound complexity +- use lazy IMAP part fetch where attachment payload is deferred + +gRPC +---- + +- set deadlines per call +- enable retry only for idempotent/transient failures + +General +------- + +- keep retries bounded with backoff and jitter +- emit events/metrics for timing and failure analysis +- use fake transports for local and CI determinism diff --git a/docs/release-checklist.rst b/docs/release-checklist.rst new file mode 100644 index 0000000..11f90e9 --- /dev/null +++ b/docs/release-checklist.rst @@ -0,0 +1,30 @@ +Release Checklist +================= + +Pre-release gates +----------------- + +- run ``composer ic:ci`` with zero failures +- verify no sensitive values in emitted events/log metadata +- verify fake transports and smoke tests stay green + +Protocol readiness +------------------ + +- HTTP: streaming, limits, security guards, pool behavior validated +- gRPC: retry and fake/native boundaries validated +- Webhook: sign/verify/replay/redaction behavior validated +- Email: SMTP/IMAP/POP3/parser/bounce/auth flows validated + +Documentation readiness +----------------------- + +- README examples are current +- docs/ pages reflect API and module boundaries +- extension requirements/suggestions are consistent with composer metadata + +Versioning +---------- + +- update changelog/release notes (if maintained) +- tag only after CI and docs checks pass diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..aa47239 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +sphinx>=8.0,<9 +sphinx-book-theme>=1.1 +sphinxcontrib-phpdomain>=0.9 +sphinx-copybutton>=0.5.2 +sphinx-design>=0.6 diff --git a/docs/security.rst b/docs/security.rst new file mode 100644 index 0000000..64dcc66 --- /dev/null +++ b/docs/security.rst @@ -0,0 +1,41 @@ +Security +======== + +Defaults +-------- + +- TLS verification is enabled by default. +- Redirects are disabled by default in HTTP requests. +- Header and URL validation prevents injection primitives. + +HTTP safeguards +--------------- + +- host allow/block controls +- private/reserved network blocking +- redirect destination validation +- response/download/upload size limits +- redaction of auth-like headers and query secrets + +Webhook safeguards +------------------ + +- HMAC signature verification with ``hash_equals`` +- timestamp tolerance window checks +- replay protection hook via ``WebhookReplayStore`` +- event payload redaction for signature/body/secret + +Email safeguards +---------------- + +- mailbox command redaction (LOGIN/PASS/AUTH sensitive values) +- parser limits for message/header/multipart boundaries +- attachment filename sanitization +- IMAP/POP3 command guards for UID/part/message number validation + +Operational guidance +-------------------- + +- Keep ``STARTTLS required`` for SMTP/IMAP in production. +- Use explicit limits for large mailbox scans and inbound parser workloads. +- Treat incoming authentication headers (SPF/DKIM/DMARC) as signal, not trust-on-first-use. diff --git a/docs/testing.rst b/docs/testing.rst new file mode 100644 index 0000000..0b08d73 --- /dev/null +++ b/docs/testing.rst @@ -0,0 +1,40 @@ +Testing +======= + +Overview +-------- + +Each protocol module ships with fake/assertable tools to enable deterministic tests. + +HTTP testing +------------ + +- ``FakeHttpTransport`` +- ``AssertableHttpTransport`` +- ``SequenceHttpTransport`` +- ``SpyHttpTransport`` + +gRPC testing +------------ + +- ``FakeGrpcCaller`` +- ``AssertableGrpcCaller`` + +Webhook testing +--------------- + +- ``FakeWebhookSender`` +- ``AssertableWebhookSender`` +- ``WebhookTestFactory`` for signed payload/header generation + +Email testing +------------- + +- ``FakeEmailTransport`` +- ``AssertableEmailTransport`` +- protocol fake-server tests for SMTP/IMAP/POP3 paths + +Guideline +--------- + +Prefer fake transports in module tests and integration boundaries, and reserve live network tests for environment-specific pipelines. diff --git a/docs/webhook/index.rst b/docs/webhook/index.rst new file mode 100644 index 0000000..e38ff50 --- /dev/null +++ b/docs/webhook/index.rst @@ -0,0 +1,14 @@ +Webhook Module +============== + +Webhook builds on HTTP and provides signed callback delivery, verification, +receiver parsing, and replay protection hooks. + +.. toctree:: + :maxdepth: 2 + + quickstart + sender + verifier-receiver + replay + testing-security diff --git a/docs/webhook/quickstart.rst b/docs/webhook/quickstart.rst new file mode 100644 index 0000000..051ca97 --- /dev/null +++ b/docs/webhook/quickstart.rst @@ -0,0 +1,36 @@ +Webhook Quick Start +=================== + +Send +---- + +.. code-block:: php + + use Infocyph\TalkingBytes\Webhook\Webhook; + use Infocyph\TalkingBytes\Webhook\WebhookMessage; + + $delivery = Webhook::sender($httpClient) + ->withSecret('whsec_test') + ->send( + WebhookMessage::event('order.created') + ->url('https://merchant.example.com/webhook') + ->payload(['order_id' => 1001]) + ); + +Verify + receive +---------------- + +.. code-block:: php + + $event = Webhook::receiver('whsec_test')->receive($rawBody, $headers); + +Header model +------------ + +Standard headers include: + +- ``X-TB-Event`` +- ``X-TB-Delivery`` +- ``X-TB-Timestamp`` +- ``X-TB-Signature`` +- ``X-TB-Attempt`` diff --git a/docs/webhook/replay.rst b/docs/webhook/replay.rst new file mode 100644 index 0000000..40b8281 --- /dev/null +++ b/docs/webhook/replay.rst @@ -0,0 +1,26 @@ +Replay Protection +================= + +Interface +--------- + +Use ``WebhookReplayStore`` to prevent duplicate processing. + +.. code-block:: php + + interface WebhookReplayStore + { + public function seen(string $deliveryId): bool; + public function remember(string $deliveryId, int $ttlSeconds): void; + } + +Built-in implementation +----------------------- + +``InMemoryWebhookReplayStore`` exists for tests and local development. + +Production guidance +------------------- + +Use Redis/database-backed store with TTL. The module intentionally avoids +hard-coding persistence dependencies. diff --git a/docs/webhook/sender.rst b/docs/webhook/sender.rst new file mode 100644 index 0000000..a6214a6 --- /dev/null +++ b/docs/webhook/sender.rst @@ -0,0 +1,42 @@ +Webhook Sender +============== + +Message object +-------------- + +``WebhookMessage`` fields: + +- URL +- event name +- payload (array/json/raw-json string) +- delivery id +- custom headers (non-reserved) +- metadata tags + +Retry profile +------------- + +``WebhookRetryProfile`` controls attempt count and delay strategy. + +- transient status retry behavior +- retry-after support +- attempt header tracking (``X-TB-Attempt``) +- stable delivery id across retries + +Result model +------------ + +``WebhookDeliveryResult`` includes: + +- delivery id +- event name +- target URL +- attempts +- delivered boolean +- final status/error metadata + +Reserved headers +---------------- + +Reserved webhook headers are sender-controlled and protected from override +so event/delivery/signature semantics stay authoritative. diff --git a/docs/webhook/testing-security.rst b/docs/webhook/testing-security.rst new file mode 100644 index 0000000..45c7ebe --- /dev/null +++ b/docs/webhook/testing-security.rst @@ -0,0 +1,34 @@ +Webhook Testing and Security +============================ + +Testing utilities +----------------- + +- ``FakeWebhookSender`` +- ``AssertableWebhookSender`` +- ``WebhookTestFactory`` for signed payload/header fixtures + +Assertions include event, URL, payload, header, signature, and count checks. + +Event lifecycle +--------------- + +Webhook events: + +- ``webhook.send.start`` +- ``webhook.retry`` +- ``webhook.send.finish`` +- ``webhook.send.failed`` +- ``webhook.verified`` +- ``webhook.rejected`` +- ``webhook.received`` + +Redaction guarantees +-------------------- + +Event payloads redact or omit: + +- raw webhook body +- raw signature values +- shared secret values +- sensitive URL query parameters diff --git a/docs/webhook/verifier-receiver.rst b/docs/webhook/verifier-receiver.rst new file mode 100644 index 0000000..c49680e --- /dev/null +++ b/docs/webhook/verifier-receiver.rst @@ -0,0 +1,42 @@ +Verifier and Receiver +===================== + +Verifier +-------- + +``WebhookVerifier`` validates: + +- signature header presence/shape +- timestamp validity and tolerance window +- HMAC value with constant-time ``hash_equals`` + +Signature format is ``t=,v1=``. + +Verification result +------------------- + +``WebhookVerificationResult`` exposes: + +- valid flag +- reason code (for rejection paths) +- parsed timestamp metadata +- redacted signature context (no secret leakage) + +Receiver +-------- + +``WebhookReceiver`` performs: + +1. verify signature +2. decode JSON payload +3. validate event + delivery id +4. optional replay store check +5. return ``WebhookEvent`` + +``WebhookEvent`` fields: + +- event +- delivery id +- payload +- timestamp +- normalized headers/metadata diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 8f842bd..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - ./tests - - - - - - ./src - - - diff --git a/pint.json b/pint.json deleted file mode 100644 index e44f7b4..0000000 --- a/pint.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "preset": "psr12", - "exclude": [ - "tests" - ], - "notPath": [ - "rector.php", - "test.php" - ] -} \ No newline at end of file diff --git a/rector.php b/rector.php deleted file mode 100644 index 1e179e2..0000000 --- a/rector.php +++ /dev/null @@ -1,20 +0,0 @@ -paths([ - __DIR__ . '/src' - ]); - $rectorConfig->sets([ - constant("Rector\Set\ValueObject\LevelSetList::UP_TO_PHP_82") - ]); - $rectorConfig->skip([ - ReadOnlyPropertyRector::class, - MixedTypeRector::class - ]); -}; diff --git a/src/Auth/ApiKeyAuth.php b/src/Auth/ApiKeyAuth.php new file mode 100644 index 0000000..b1ac265 --- /dev/null +++ b/src/Auth/ApiKeyAuth.php @@ -0,0 +1,39 @@ +key) === '') { + throw new InvalidArgumentException('API key name must not be empty.'); + } + + if ($this->value === '') { + throw new InvalidArgumentException('API key value must not be empty.'); + } + + if (!$this->inQuery) { + HeaderBag::assertValidHeaderName($this->key); + } + } + + public function apply(HttpRequest $request): HttpRequest + { + if ($this->inQuery) { + return $request->query($this->key, $this->value); + } + + return $request->header($this->key, $this->value); + } +} diff --git a/src/Auth/AuthenticatorInterface.php b/src/Auth/AuthenticatorInterface.php new file mode 100644 index 0000000..467a119 --- /dev/null +++ b/src/Auth/AuthenticatorInterface.php @@ -0,0 +1,12 @@ +username) === '') { + throw new InvalidArgumentException('Basic auth username must not be empty.'); + } + + if ($this->password === '') { + throw new InvalidArgumentException('Basic auth password must not be empty.'); + } + } + + public function apply(HttpRequest $request): HttpRequest + { + $token = base64_encode($this->username . ':' . $this->password); + + return $request->header('Authorization', 'Basic ' . $token); + } +} diff --git a/src/Auth/BearerTokenAuth.php b/src/Auth/BearerTokenAuth.php new file mode 100644 index 0000000..109b51b --- /dev/null +++ b/src/Auth/BearerTokenAuth.php @@ -0,0 +1,23 @@ +token) === '') { + throw new InvalidArgumentException('Bearer token must not be empty.'); + } + } + + public function apply(HttpRequest $request): HttpRequest + { + return $request->header('Authorization', 'Bearer ' . $this->token); + } +} diff --git a/src/Auth/HeaderAuth.php b/src/Auth/HeaderAuth.php new file mode 100644 index 0000000..a98596a --- /dev/null +++ b/src/Auth/HeaderAuth.php @@ -0,0 +1,32 @@ +header) === '') { + throw new InvalidArgumentException('Header auth name must not be empty.'); + } + + HeaderBag::assertValidHeaderName($this->header); + + if ($this->value === '') { + throw new InvalidArgumentException('Header auth value must not be empty.'); + } + } + + public function apply(HttpRequest $request): HttpRequest + { + return $request->header($this->header, $this->value); + } +} diff --git a/src/Auth/QueryAuth.php b/src/Auth/QueryAuth.php new file mode 100644 index 0000000..f0fbb33 --- /dev/null +++ b/src/Auth/QueryAuth.php @@ -0,0 +1,29 @@ +key) === '') { + throw new InvalidArgumentException('Query auth key must not be empty.'); + } + + if ($this->value === '') { + throw new InvalidArgumentException('Query auth value must not be empty.'); + } + } + + public function apply(HttpRequest $request): HttpRequest + { + return $request->query($this->key, $this->value); + } +} diff --git a/src/Auth/SignedRequestAuth.php b/src/Auth/SignedRequestAuth.php new file mode 100644 index 0000000..64b70a5 --- /dev/null +++ b/src/Auth/SignedRequestAuth.php @@ -0,0 +1,79 @@ +signatureHeader); + HeaderBag::assertValidHeaderName($this->timestampHeader); + HeaderBag::assertValidHeaderName($this->nonceHeader); + } + + public function apply(HttpRequest $request): HttpRequest + { + $timestamp = (string) (($this->clock ?? time(...))()); + $nonce = ($this->nonceGenerator ?? static fn(): string => bin2hex(random_bytes(16)))(); + + $canonical = implode("\n", [ + strtoupper($request->method->value), + $this->pathWithQuery($request->buildUrl()), + $timestamp, + $nonce, + $this->payloadHash($request), + ]); + + $signature = $this->signer->sign($canonical); + + return $request + ->header($this->timestampHeader, $timestamp) + ->header($this->nonceHeader, $nonce) + ->header($this->signatureHeader, $signature); + } + + private function pathWithQuery(string $url): string + { + $path = parse_url($url, PHP_URL_PATH); + $query = parse_url($url, PHP_URL_QUERY); + + $normalizedPath = is_string($path) && $path !== '' ? $path : '/'; + + if (!is_string($query) || $query === '') { + return $normalizedPath; + } + + return $normalizedPath . '?' . $query; + } + + private function payloadHash(HttpRequest $request): string + { + if ($request->body === null) { + return hash('sha256', ''); + } + + $payload = $request->body->toCurlPayload(); + if (is_string($payload)) { + return hash('sha256', $payload); + } + + return 'UNSIGNED-PAYLOAD'; + } +} diff --git a/src/Core/Contract/MiddlewareInterface.php b/src/Core/Contract/MiddlewareInterface.php new file mode 100644 index 0000000..5aee008 --- /dev/null +++ b/src/Core/Contract/MiddlewareInterface.php @@ -0,0 +1,17 @@ +):void $listener + */ + public function __construct(private mixed $listener) {} + + /** + * @param array $payload + */ + public function dispatch(string $event, array $payload = []): void + { + $listener = $this->listener; + $listener($event, $payload); + } +} diff --git a/src/Core/Event/CommunicationEventBus.php b/src/Core/Event/CommunicationEventBus.php new file mode 100644 index 0000000..8b69626 --- /dev/null +++ b/src/Core/Event/CommunicationEventBus.php @@ -0,0 +1,40 @@ + $payload + */ + public static function dispatch(string $event, array $payload = []): void + { + self::dispatcher()->dispatch($event, $payload); + } + + /** + * @param null|callable(string, array):void $listener + */ + public static function listen(?callable $listener): void + { + self::$dispatcher = $listener === null + ? new NullEventDispatcher() + : new CallableEventDispatcher($listener); + } + + public static function useDispatcher(EventDispatcher $dispatcher): void + { + self::$dispatcher = $dispatcher; + } + + private static function dispatcher(): EventDispatcher + { + self::$dispatcher ??= new NullEventDispatcher(); + + return self::$dispatcher; + } +} diff --git a/src/Core/Event/EventDispatcher.php b/src/Core/Event/EventDispatcher.php new file mode 100644 index 0000000..21e9260 --- /dev/null +++ b/src/Core/Event/EventDispatcher.php @@ -0,0 +1,13 @@ + $payload + */ + public function dispatch(string $event, array $payload = []): void; +} diff --git a/src/Core/Event/NullEventDispatcher.php b/src/Core/Event/NullEventDispatcher.php new file mode 100644 index 0000000..279ccd5 --- /dev/null +++ b/src/Core/Event/NullEventDispatcher.php @@ -0,0 +1,16 @@ + $payload + */ + public function dispatch(string $event, array $payload = []): void + { + // Intentionally no-op. + } +} diff --git a/src/Core/Exception/CommunicationException.php b/src/Core/Exception/CommunicationException.php new file mode 100644 index 0000000..56d3cab --- /dev/null +++ b/src/Core/Exception/CommunicationException.php @@ -0,0 +1,9 @@ + $headers + * @param array $options + * @param array $metadata + */ + public function __construct( + public string $transport, + public mixed $payload, + public array $headers = [], + public array $options = [], + public array $metadata = [], + ) {} + + /** + * @param array $headers + */ + public function withHeaders(array $headers): self + { + return new self($this->transport, $this->payload, $headers, $this->options, $this->metadata); + } + + /** + * @param array $metadata + */ + public function withMetadata(array $metadata): self + { + return new self($this->transport, $this->payload, $this->headers, $this->options, $metadata); + } + + /** + * @param array $options + */ + public function withOptions(array $options): self + { + return new self($this->transport, $this->payload, $this->headers, $options, $this->metadata); + } +} diff --git a/src/Core/Middleware/AuthMiddleware.php b/src/Core/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..7599c76 --- /dev/null +++ b/src/Core/Middleware/AuthMiddleware.php @@ -0,0 +1,34 @@ +payload instanceof HttpRequest) { + return $next( + new CommunicationRequest( + $request->transport, + $request->payload->withAuthenticator($this->authenticator), + $request->headers, + $request->options, + $request->metadata, + ), + ); + } + + return $next($request); + } +} diff --git a/src/Core/Middleware/CircuitBreakerMiddleware.php b/src/Core/Middleware/CircuitBreakerMiddleware.php new file mode 100644 index 0000000..10f6994 --- /dev/null +++ b/src/Core/Middleware/CircuitBreakerMiddleware.php @@ -0,0 +1,40 @@ +circuitBreaker->assertCanProceed(); + + try { + $result = $next($request); + } catch (Throwable $throwable) { + $this->circuitBreaker->onFailure(); + + throw $throwable; + } + + if ($result->successful) { + $this->circuitBreaker->onSuccess(); + + return $result; + } + + $this->circuitBreaker->onFailure(); + + return $result; + } +} diff --git a/src/Core/Middleware/HeaderMiddleware.php b/src/Core/Middleware/HeaderMiddleware.php new file mode 100644 index 0000000..273b11f --- /dev/null +++ b/src/Core/Middleware/HeaderMiddleware.php @@ -0,0 +1,52 @@ + $headers + */ + public function __construct(private array $headers) {} + + public function handle(CommunicationRequest $request, Closure $next): CommunicationResult + { + $headers = array_merge($request->headers, $this->headers); + $normalizedHeaders = $this->normalizeHeaders($this->headers); + + if ($request->payload instanceof HttpRequest) { + return $next(new CommunicationRequest( + $request->transport, + $request->payload->headers($normalizedHeaders), + $headers, + $request->options, + $request->metadata, + )); + } + + return $next($request->withHeaders($headers)); + } + + /** + * @param array $headers + * @return array> + */ + private function normalizeHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $value) { + $normalized[$name] = is_array($value) ? array_values($value) : $value; + } + + return $normalized; + } +} diff --git a/src/Core/Middleware/IdempotencyMiddleware.php b/src/Core/Middleware/IdempotencyMiddleware.php new file mode 100644 index 0000000..5c98228 --- /dev/null +++ b/src/Core/Middleware/IdempotencyMiddleware.php @@ -0,0 +1,42 @@ +headers; + $payload = $request->payload; + + $alreadyPresent = array_key_exists($this->headerName, $headers) + || ($payload instanceof HttpRequest && $payload->headers->get($this->headerName) !== null); + + if (!$alreadyPresent) { + $key = bin2hex(random_bytes(16)); + $headers[$this->headerName] = $key; + + if ($payload instanceof HttpRequest) { + $payload = $payload->header($this->headerName, $key); + } + } + + return $next(new CommunicationRequest( + $request->transport, + $payload, + $headers, + $request->options, + $request->metadata, + )); + } +} diff --git a/src/Core/Middleware/LoggingMiddleware.php b/src/Core/Middleware/LoggingMiddleware.php new file mode 100644 index 0000000..a611558 --- /dev/null +++ b/src/Core/Middleware/LoggingMiddleware.php @@ -0,0 +1,46 @@ +): void $logger + */ + public function __construct(private Closure $logger) {} + + public function handle(CommunicationRequest $request, Closure $next): CommunicationResult + { + ($this->logger)('request.start', ['transport' => $request->transport, 'metadata' => $request->metadata]); + + try { + $result = $next($request); + } catch (Throwable $throwable) { + ($this->logger)('request.end', [ + 'transport' => $request->transport, + 'successful' => false, + 'status_code' => null, + 'error' => $throwable->getMessage(), + ]); + + throw $throwable; + } + + ($this->logger)('request.end', [ + 'transport' => $request->transport, + 'successful' => $result->successful, + 'status_code' => $result->statusCode, + 'error' => $result->error, + ]); + + return $result; + } +} diff --git a/src/Core/Middleware/RateLimitMiddleware.php b/src/Core/Middleware/RateLimitMiddleware.php new file mode 100644 index 0000000..41beac3 --- /dev/null +++ b/src/Core/Middleware/RateLimitMiddleware.php @@ -0,0 +1,23 @@ +rateLimiter->assertCanProceed(); + + return $next($request); + } +} diff --git a/src/Core/Middleware/RetryMiddleware.php b/src/Core/Middleware/RetryMiddleware.php new file mode 100644 index 0000000..ee61c84 --- /dev/null +++ b/src/Core/Middleware/RetryMiddleware.php @@ -0,0 +1,22 @@ +policy, static fn(): CommunicationResult => $next($request)); + } +} diff --git a/src/Core/Middleware/TimeoutMiddleware.php b/src/Core/Middleware/TimeoutMiddleware.php new file mode 100644 index 0000000..62c0a3a --- /dev/null +++ b/src/Core/Middleware/TimeoutMiddleware.php @@ -0,0 +1,41 @@ +options; + $options['timeout'] = $this->timeoutSeconds; + + $payload = $request->payload; + + if ($payload instanceof HttpRequest) { + $payload = $payload->timeout($this->timeoutSeconds); + } + + if ($payload instanceof GrpcRequest) { + $payload = $payload->withDeadlineSeconds((float) $this->timeoutSeconds); + } + + return $next(new CommunicationRequest( + $request->transport, + $payload, + $request->headers, + $options, + $request->metadata, + )); + } +} diff --git a/src/Core/Pipeline/MiddlewarePipeline.php b/src/Core/Pipeline/MiddlewarePipeline.php new file mode 100644 index 0000000..f0307fd --- /dev/null +++ b/src/Core/Pipeline/MiddlewarePipeline.php @@ -0,0 +1,42 @@ + $middlewares + */ + public function __construct( + private TransportInterface $transport, + private array $middlewares = [], + ) {} + + public function send(CommunicationRequest $request): CommunicationResult + { + $next = fn(CommunicationRequest $request): CommunicationResult => $this->transport->send($request); + + foreach (array_reverse($this->middlewares) as $middleware) { + $current = $middleware; + $currentNext = $next; + $next = fn(CommunicationRequest $request): CommunicationResult => $current->handle($request, $currentNext); + } + + return $next($request); + } + + /** + * @param list $middlewares + */ + public function withMiddlewares(array $middlewares): self + { + return new self($this->transport, $middlewares); + } +} diff --git a/src/Core/Result/CommunicationResult.php b/src/Core/Result/CommunicationResult.php new file mode 100644 index 0000000..be02936 --- /dev/null +++ b/src/Core/Result/CommunicationResult.php @@ -0,0 +1,42 @@ + $metadata + */ + public function __construct( + public bool $successful, + public ?int $statusCode = null, + public ?string $error = null, + public mixed $response = null, + public array $metadata = [], + ) {} + + /** + * @param array $metadata + */ + public static function failure( + string $error, + ?int $statusCode = null, + mixed $response = null, + array $metadata = [], + ): self { + return new self(false, $statusCode, $error, $response, $metadata); + } + + /** + * @param array $metadata + */ + public static function success( + ?int $statusCode = null, + mixed $response = null, + array $metadata = [], + ): self { + return new self(true, $statusCode, null, $response, $metadata); + } +} diff --git a/src/Core/Support/RetryExecutor.php b/src/Core/Support/RetryExecutor.php new file mode 100644 index 0000000..9ebb6ed --- /dev/null +++ b/src/Core/Support/RetryExecutor.php @@ -0,0 +1,42 @@ +shouldRetry($count, null, $throwable)) { + throw $throwable; + } + + usleep($policy->delayMs($count) * 1000); + $count++; + + continue; + } + + if (!$policy->shouldRetry($count, $result)) { + return $result; + } + + usleep($policy->delayMs($count) * 1000); + $count++; + } + } +} diff --git a/src/Email/Config/ConfigValue.php b/src/Email/Config/ConfigValue.php new file mode 100644 index 0000000..affde8d --- /dev/null +++ b/src/Email/Config/ConfigValue.php @@ -0,0 +1,161 @@ + $config + */ + public static function bool(array $config, string $key, bool $default): bool + { + $value = $config[$key] ?? $default; + + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + + return $default; + } + + if (is_int($value)) { + return $value === 1; + } + + return $default; + } + + /** + * @param array $config + */ + public static function int(array $config, string $key, int $default): int + { + $value = $config[$key] ?? $default; + + if (is_int($value)) { + return $value; + } + + if (is_string($value) && is_numeric($value)) { + return (int) $value; + } + + return $default; + } + + /** + * @param array $config + */ + public static function nullableInt(array $config, string $key): ?int + { + $value = self::nullableRaw($config, $key); + if ($value === null) { + return null; + } + + if (is_int($value)) { + return $value; + } + + if (is_string($value) && is_numeric($value)) { + return (int) $value; + } + + return null; + } + + /** + * @param array $config + */ + public static function nullableString(array $config, string $key): ?string + { + $value = self::nullableRaw($config, $key); + if ($value === null) { + return null; + } + + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + + return null; + } + + /** + * @param array $config + */ + public static function string(array $config, string $key, string $default): string + { + $value = $config[$key] ?? $default; + + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + + return $default; + } + + /** + * @param array $config + * @param list $default + * @return list + */ + public static function stringList(array $config, string $key, array $default): array + { + $fallback = $default; + $value = $config[$key] ?? $fallback; + + if (!is_array($value)) { + return $fallback; + } + + $list = []; + + foreach ($value as $item) { + if (is_string($item)) { + $list[] = $item; + + continue; + } + + if (is_int($item) || is_float($item) || is_bool($item)) { + $list[] = (string) $item; + } + } + + return $list; + } + + /** + * @param array $config + */ + private static function nullableRaw(array $config, string $key): mixed + { + if (!array_key_exists($key, $config)) { + return null; + } + + return $config[$key]; + } +} diff --git a/src/Email/Config/DkimConfig.php b/src/Email/Config/DkimConfig.php new file mode 100644 index 0000000..c9f1374 --- /dev/null +++ b/src/Email/Config/DkimConfig.php @@ -0,0 +1,88 @@ + $headersToSign + */ + public function __construct( + public string $domain, + public string $selector, + public string $privateKey, + public array $headersToSign = ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'], + public DkimAlgorithm $algorithm = DkimAlgorithm::RsaSha256, + ) { + if (trim($this->domain) === '') { + throw new InvalidArgumentException('DKIM domain is required.'); + } + + if (trim($this->selector) === '') { + throw new InvalidArgumentException('DKIM selector is required.'); + } + + if (trim($this->privateKey) === '') { + throw new InvalidArgumentException('DKIM private key is required.'); + } + + if ($this->headersToSign === []) { + throw new InvalidArgumentException('DKIM requires at least one signed header.'); + } + + $this->assertPrivateKeyIsReadable($this->privateKey); + } + + /** + * @param list $headersToSign + */ + public static function fromPrivateKeyPath( + string $domain, + string $selector, + string $privateKeyPath, + array $headersToSign = ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'], + DkimAlgorithm $algorithm = DkimAlgorithm::RsaSha256, + ): self { + if (!is_file($privateKeyPath) || !is_readable($privateKeyPath)) { + throw new InvalidArgumentException(sprintf('DKIM private key path is not readable: %s', $privateKeyPath)); + } + + $privateKey = file_get_contents($privateKeyPath); + if (!is_string($privateKey) || trim($privateKey) === '') { + throw new InvalidArgumentException(sprintf('DKIM private key file is empty: %s', $privateKeyPath)); + } + + return new self($domain, $selector, $privateKey, $headersToSign, $algorithm); + } + + /** + * @param list $headersToSign + */ + public static function fromPrivateKeyString( + string $domain, + string $selector, + string $privateKey, + array $headersToSign = ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'], + DkimAlgorithm $algorithm = DkimAlgorithm::RsaSha256, + ): self { + return new self($domain, $selector, $privateKey, $headersToSign, $algorithm); + } + + private function assertPrivateKeyIsReadable(string $privateKey): void + { + if (!function_exists('openssl_pkey_get_private')) { + throw new RuntimeException('OpenSSL extension is required for DKIM signing.'); + } + + $resource = openssl_pkey_get_private($privateKey); + if ($resource === false) { + throw new InvalidArgumentException('Invalid DKIM private key.'); + } + } +} diff --git a/src/Email/Config/EmailLimits.php b/src/Email/Config/EmailLimits.php new file mode 100644 index 0000000..552c381 --- /dev/null +++ b/src/Email/Config/EmailLimits.php @@ -0,0 +1,39 @@ +assertPositive('maxMessageBytes', $this->maxMessageBytes); + $this->assertPositive('maxAttachmentBytes', $this->maxAttachmentBytes); + $this->assertPositive('maxAttachmentCount', $this->maxAttachmentCount); + $this->assertPositive('maxDecodedBodyBytes', $this->maxDecodedBodyBytes); + $this->assertPositive('maxMimeDepth', $this->maxMimeDepth); + $this->assertPositive('maxMimeParts', $this->maxMimeParts); + $this->assertPositive('maxHeaderBytes', $this->maxHeaderBytes); + $this->assertPositive('maxHeaderCount', $this->maxHeaderCount); + } + + private function assertPositive(string $name, int $value): void + { + if ($value > 0) { + return; + } + + throw new InvalidArgumentException(sprintf('Email limit %s must be greater than zero.', $name)); + } +} diff --git a/src/Email/Config/ImapConfig.php b/src/Email/Config/ImapConfig.php new file mode 100644 index 0000000..3d71c75 --- /dev/null +++ b/src/Email/Config/ImapConfig.php @@ -0,0 +1,53 @@ +host); + MailboxConfigValidator::assertPort('IMAP', $this->port); + MailboxConfigValidator::assertUsername('IMAP', $this->username); + MailboxConfigValidator::assertPassword('IMAP', $this->password); + MailboxConfigValidator::assertTimeout('IMAP', $this->timeoutSeconds); + MailboxConfigValidator::assertRequired('IMAP default folder', $this->defaultFolder); + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + return new self( + host: ConfigValue::string($config, 'host', ''), + port: ConfigValue::int($config, 'port', 993), + security: self::securityFrom($config), + username: ConfigValue::string($config, 'username', ''), + password: ConfigValue::string($config, 'password', ''), + timeoutSeconds: ConfigValue::int($config, 'timeoutSeconds', 10), + defaultFolder: ConfigValue::string($config, 'defaultFolder', 'INBOX'), + ); + } + + /** + * @param array $config + */ + private static function securityFrom(array $config): ImapSecurity + { + $raw = ConfigValue::string($config, 'security', ImapSecurity::Ssl->value); + + return ImapSecurity::tryFrom($raw) ?? ImapSecurity::Ssl; + } +} diff --git a/src/Email/Config/LogEmailConfig.php b/src/Email/Config/LogEmailConfig.php new file mode 100644 index 0000000..386a650 --- /dev/null +++ b/src/Email/Config/LogEmailConfig.php @@ -0,0 +1,42 @@ +directory) === '') { + throw new InvalidArgumentException('Log directory is required.'); + } + + if (trim($this->filenamePrefix) === '') { + throw new InvalidArgumentException('Log filename prefix is required.'); + } + + if ($this->maxMessageBytes !== null && $this->maxMessageBytes < 1) { + throw new InvalidArgumentException('Log max message bytes must be greater than zero when provided.'); + } + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + return new self( + directory: ConfigValue::string($config, 'directory', ''), + dailyFiles: ConfigValue::bool($config, 'dailyFiles', true), + filenamePrefix: ConfigValue::string($config, 'filenamePrefix', 'email'), + maxMessageBytes: ConfigValue::nullableInt($config, 'maxMessageBytes'), + ); + } +} diff --git a/src/Email/Config/MailboxConfigValidator.php b/src/Email/Config/MailboxConfigValidator.php new file mode 100644 index 0000000..e11b72e --- /dev/null +++ b/src/Email/Config/MailboxConfigValidator.php @@ -0,0 +1,48 @@ + 65535) { + throw new InvalidArgumentException(sprintf('%s port must be between 1 and 65535.', $protocol)); + } + } + + public static function assertRequired(string $label, string $value): void + { + if (trim($value) === '') { + throw new InvalidArgumentException(sprintf('%s is required.', $label)); + } + } + + public static function assertTimeout(string $protocol, int $timeoutSeconds): void + { + if ($timeoutSeconds < 1) { + throw new InvalidArgumentException(sprintf('%s timeout must be greater than zero.', $protocol)); + } + } + + public static function assertUsername(string $protocol, string $username): void + { + self::assertRequired(sprintf('%s username', $protocol), $username); + } +} diff --git a/src/Email/Config/Pop3Config.php b/src/Email/Config/Pop3Config.php new file mode 100644 index 0000000..423ec0a --- /dev/null +++ b/src/Email/Config/Pop3Config.php @@ -0,0 +1,79 @@ +validate(); + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + $values = [ + 'host' => ConfigValue::string($config, 'host', ''), + 'port' => ConfigValue::int($config, 'port', 110), + 'security' => self::parseSecurity($config), + 'username' => ConfigValue::string($config, 'username', ''), + 'password' => ConfigValue::string($config, 'password', ''), + 'timeoutSeconds' => ConfigValue::int($config, 'timeoutSeconds', 10), + ]; + + return new self(...$values); + } + + /** + * @param array $config + */ + private static function parseSecurity(array $config): Pop3Security + { + $raw = ConfigValue::string($config, 'security', Pop3Security::None->value); + + return Pop3Security::tryFrom($raw) ?? Pop3Security::None; + } + + private function validate(): void + { + foreach ($this->validators() as $validator) { + $validator(); + } + } + + /** + * @return list + */ + private function validators(): array + { + return [ + function (): void { + MailboxConfigValidator::assertHost('POP3', $this->host); + }, + function (): void { + MailboxConfigValidator::assertUsername('POP3', $this->username); + }, + function (): void { + MailboxConfigValidator::assertPassword('POP3', $this->password); + }, + function (): void { + MailboxConfigValidator::assertPort('POP3', $this->port); + }, + function (): void { + MailboxConfigValidator::assertTimeout('POP3', $this->timeoutSeconds); + }, + ]; + } +} diff --git a/src/Email/Config/SendmailConfig.php b/src/Email/Config/SendmailConfig.php new file mode 100644 index 0000000..a128453 --- /dev/null +++ b/src/Email/Config/SendmailConfig.php @@ -0,0 +1,59 @@ + $extraArguments + */ + public function __construct( + public string $path = '/usr/sbin/sendmail', + public array $extraArguments = ['-t', '-i'], + public int $timeoutSeconds = 15, + public ?int $maxMessageBytes = null, + ) { + if (trim($this->path) === '') { + throw new InvalidArgumentException('Sendmail path is required.'); + } + + if ($this->timeoutSeconds < 1) { + throw new InvalidArgumentException('Sendmail timeout must be greater than zero.'); + } + + if ($this->maxMessageBytes !== null && $this->maxMessageBytes < 1) { + throw new InvalidArgumentException('Sendmail max message bytes must be greater than zero when provided.'); + } + + foreach ($this->extraArguments as $argument) { + if (trim($argument) === '') { + throw new InvalidArgumentException('Sendmail arguments must not contain empty values.'); + } + + if (preg_match('/[\x00\r\n]/', $argument) === 1) { + throw new InvalidArgumentException('Sendmail arguments must not contain control characters.'); + } + + if (preg_match('/\s/', $argument) === 1) { + throw new InvalidArgumentException('Sendmail arguments must not contain whitespace. Split composite values into separate arguments.'); + } + } + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + return new self( + path: ConfigValue::string($config, 'path', '/usr/sbin/sendmail'), + extraArguments: ConfigValue::stringList($config, 'extraArguments', ['-t', '-i']), + timeoutSeconds: ConfigValue::int($config, 'timeoutSeconds', 15), + maxMessageBytes: ConfigValue::nullableInt($config, 'maxMessageBytes'), + ); + } +} diff --git a/src/Email/Config/SmtpConfig.php b/src/Email/Config/SmtpConfig.php new file mode 100644 index 0000000..c86a15c --- /dev/null +++ b/src/Email/Config/SmtpConfig.php @@ -0,0 +1,84 @@ +host) === '') { + throw new InvalidArgumentException('SMTP host is required.'); + } + + if ($this->port < 1 || $this->port > 65535) { + throw new InvalidArgumentException('SMTP port must be between 1 and 65535.'); + } + + if ($this->timeoutSeconds < 1) { + throw new InvalidArgumentException('SMTP timeout must be greater than zero.'); + } + + if (trim($this->localDomain) === '') { + throw new InvalidArgumentException('SMTP local domain is required.'); + } + + if ($this->maxMessageBytes !== null && $this->maxMessageBytes < 1) { + throw new InvalidArgumentException('SMTP max message bytes must be greater than zero when provided.'); + } + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + $security = SmtpSecurity::tryFrom(ConfigValue::string($config, 'security', SmtpSecurity::StartTlsRequired->value)) + ?? SmtpSecurity::StartTlsRequired; + + $authMechanism = SmtpAuthMechanism::tryFrom(ConfigValue::string($config, 'authMechanism', SmtpAuthMechanism::Auto->value)) + ?? SmtpAuthMechanism::Auto; + + $utf8Policy = SmtpUtf8Policy::tryFrom(ConfigValue::string($config, 'utf8Policy', SmtpUtf8Policy::Auto->value)) + ?? SmtpUtf8Policy::Auto; + + $credentials = null; + $credentialsValue = $config['credentials'] ?? null; + if (is_array($credentialsValue)) { + /** @var array $credentialsConfig */ + $credentialsConfig = $credentialsValue; + $credentials = SmtpCredentials::fromArray($credentialsConfig); + } + + return new self( + host: ConfigValue::string($config, 'host', ''), + port: ConfigValue::int($config, 'port', 587), + security: $security, + credentials: $credentials, + timeoutSeconds: ConfigValue::int($config, 'timeoutSeconds', 10), + localDomain: ConfigValue::string($config, 'localDomain', 'localhost'), + authMechanism: $authMechanism, + captureTranscript: ConfigValue::bool($config, 'captureTranscript', false), + utf8Policy: $utf8Policy, + allowEightBitMime: ConfigValue::bool($config, 'allowEightBitMime', true), + maxMessageBytes: ConfigValue::nullableInt($config, 'maxMessageBytes'), + ); + } +} diff --git a/src/Email/Config/SmtpCredentials.php b/src/Email/Config/SmtpCredentials.php new file mode 100644 index 0000000..e8331be --- /dev/null +++ b/src/Email/Config/SmtpCredentials.php @@ -0,0 +1,34 @@ +username) === '') { + throw new InvalidArgumentException('SMTP username is required when credentials are provided.'); + } + + if ($this->password === '') { + throw new InvalidArgumentException('SMTP password is required when credentials are provided.'); + } + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + return new self( + username: ConfigValue::string($config, 'username', ''), + password: ConfigValue::string($config, 'password', ''), + ); + } +} diff --git a/src/Email/Config/SpoolConfig.php b/src/Email/Config/SpoolConfig.php new file mode 100644 index 0000000..6200517 --- /dev/null +++ b/src/Email/Config/SpoolConfig.php @@ -0,0 +1,64 @@ +directory) === '') { + throw new InvalidArgumentException('Spool directory is required.'); + } + + if (trim($this->extension) === '') { + throw new InvalidArgumentException('Spool extension is required.'); + } + + if ($this->maxMessages < 1) { + throw new InvalidArgumentException('Spool max messages must be greater than zero.'); + } + + if ($this->olderThanSeconds !== null && $this->olderThanSeconds < 0) { + throw new InvalidArgumentException('Spool older-than filter must be zero or greater.'); + } + + if ($this->newerThanSeconds !== null && $this->newerThanSeconds < 0) { + throw new InvalidArgumentException('Spool newer-than filter must be zero or greater.'); + } + + if ($this->maxMessageBytes !== null && $this->maxMessageBytes < 1) { + throw new InvalidArgumentException('Spool max message bytes must be greater than zero when provided.'); + } + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + return new self( + directory: ConfigValue::string($config, 'directory', ''), + writeMetadata: ConfigValue::bool($config, 'writeMetadata', true), + processingDirectory: ConfigValue::nullableString($config, 'processingDirectory'), + extension: ConfigValue::string($config, 'extension', 'eml'), + lockBeforeRead: ConfigValue::bool($config, 'lockBeforeRead', false), + maxMessages: ConfigValue::int($config, 'maxMessages', 20), + olderThanSeconds: ConfigValue::nullableInt($config, 'olderThanSeconds'), + newerThanSeconds: ConfigValue::nullableInt($config, 'newerThanSeconds'), + maxMessageBytes: ConfigValue::nullableInt($config, 'maxMessageBytes'), + ); + } +} diff --git a/src/Email/Dkim/CachedDkimPublicKeyResolver.php b/src/Email/Dkim/CachedDkimPublicKeyResolver.php new file mode 100644 index 0000000..e90422e --- /dev/null +++ b/src/Email/Dkim/CachedDkimPublicKeyResolver.php @@ -0,0 +1,26 @@ + + */ + private array $cache = []; + + public function __construct(private readonly DkimPublicKeyResolver $inner) {} + + public function resolve(string $domain, string $selector): ?string + { + $key = strtolower(sprintf('%s._domainkey.%s', trim($selector), trim($domain))); + + if (!array_key_exists($key, $this->cache)) { + $this->cache[$key] = $this->inner->resolve($domain, $selector); + } + + return $this->cache[$key]; + } +} diff --git a/src/Email/Dkim/DkimCanonicalizer.php b/src/Email/Dkim/DkimCanonicalizer.php new file mode 100644 index 0000000..95b4708 --- /dev/null +++ b/src/Email/Dkim/DkimCanonicalizer.php @@ -0,0 +1,33 @@ + + */ + public static function unfoldLines(string $headers): array + { + $lines = preg_split('/\r\n/', $headers) ?: []; + $unfolded = []; + + foreach ($lines as $line) { + if (($line[0] ?? '') === ' ' || ($line[0] ?? '') === "\t") { + $last = array_key_last($unfolded); + if ($last !== null) { + $unfolded[$last] .= ' ' . ltrim($line); + } + + continue; + } + + $unfolded[] = $line; + } + + return $unfolded; + } +} diff --git a/src/Email/Dkim/DkimPublicKeyResolver.php b/src/Email/Dkim/DkimPublicKeyResolver.php new file mode 100644 index 0000000..7feb58a --- /dev/null +++ b/src/Email/Dkim/DkimPublicKeyResolver.php @@ -0,0 +1,10 @@ +canonicalizer->canonicalizeBody($body), true)); + $headerMap = $this->parseHeaders($headers); + + $signedHeaders = []; + $canonicalizedSignedHeaders = []; + + foreach ($config->headersToSign as $headerName) { + $normalized = strtolower($headerName); + if (!array_key_exists($normalized, $headerMap)) { + continue; + } + + $values = $headerMap[$normalized]; + $lastIndex = array_key_last($values); + if ($lastIndex === null) { + continue; + } + + $value = $values[$lastIndex]; + $signedHeaders[] = $normalized; + $canonicalizedSignedHeaders[] = $this->canonicalizer->canonicalizeHeader($normalized, $value); + } + + if ($signedHeaders === []) { + throw new DkimException('Unable to build DKIM signature: no configured headers found in message.'); + } + + if ($config->algorithm->value !== 'rsa-sha256') { + throw new DkimException(sprintf('Unsupported DKIM algorithm for signer: %s', $config->algorithm->value)); + } + + $dkimWithoutSignature = sprintf( + 'v=1; a=%s; c=relaxed/relaxed; d=%s; s=%s; t=%d; h=%s; bh=%s; b=', + $config->algorithm->value, + $config->domain, + $config->selector, + time(), + implode(':', $signedHeaders), + $bodyHash, + ); + + $canonicalizedDkimHeader = $this->canonicalizer->canonicalizeHeader('dkim-signature', $dkimWithoutSignature); + $signingInput = implode("\r\n", [...$canonicalizedSignedHeaders, $canonicalizedDkimHeader]); + $signature = $this->sign($signingInput, $config->privateKey); + + return 'DKIM-Signature: ' . $dkimWithoutSignature . $signature; + } + + /** + * @return array> + */ + private function parseHeaders(string $headers): array + { + $lines = $this->unfoldHeaderLines($headers); + $parsed = []; + + foreach ($lines as $line) { + if ($line === '' || !str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $normalizedName = strtolower(trim($name)); + + if (!array_key_exists($normalizedName, $parsed)) { + $parsed[$normalizedName] = []; + } + + $parsed[$normalizedName][] = ltrim($value); + } + + return $parsed; + } + + private function sign(string $input, string $privateKey): string + { + $resource = openssl_pkey_get_private($privateKey); + + if ($resource === false) { + throw new DkimException('Invalid DKIM private key.'); + } + + $signature = ''; + $result = openssl_sign($input, $signature, $resource, OPENSSL_ALGO_SHA256); + + if ($result !== true) { + throw new DkimException('Failed to generate DKIM signature.'); + } + + if (!is_string($signature)) { + throw new DkimException('DKIM signer produced a non-string signature.'); + } + + return base64_encode($signature); + } + + /** + * @return list + */ + private function unfoldHeaderLines(string $headers): array + { + return DkimHeaderTools::unfoldLines($headers); + } +} diff --git a/src/Email/Dkim/DkimVerifier.php b/src/Email/Dkim/DkimVerifier.php new file mode 100644 index 0000000..64aea1c --- /dev/null +++ b/src/Email/Dkim/DkimVerifier.php @@ -0,0 +1,285 @@ +splitRaw($email->raw); + $headers = $this->parseHeaderLines($headersBlock); + + $dkimHeader = $this->lastHeaderValue($headers, 'dkim-signature'); + if ($dkimHeader === null) { + return new DkimVerificationResult(false, reason: 'DKIM-Signature header not found.'); + } + + $tags = $this->parseTagValueList($dkimHeader); + $domain = $this->nullableString($tags['d'] ?? null); + $selector = $this->nullableString($tags['s'] ?? null); + + if ($domain === null || $selector === null) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM domain/selector missing.'); + } + + $algorithm = strtolower((string) ($tags['a'] ?? 'rsa-sha256')); + if ($algorithm !== 'rsa-sha256') { + return new DkimVerificationResult(false, $domain, $selector, 'Unsupported DKIM algorithm.'); + } + + $canon = strtolower((string) ($tags['c'] ?? 'simple/simple')); + [$headerCanon, $bodyCanon] = array_pad(explode('/', $canon, 2), 2, 'simple'); + + $bodyHash = $this->nullableString($tags['bh'] ?? null); + if ($bodyHash === null) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM body hash missing.'); + } + + $computedBodyHash = base64_encode(hash('sha256', $this->canonicalizeBody($body, $bodyCanon), true)); + if (!hash_equals($bodyHash, $computedBodyHash)) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM body hash mismatch.', [ + 'expected_bh' => $bodyHash, + 'computed_bh' => $computedBodyHash, + ]); + } + + $signature = $this->nullableString($tags['b'] ?? null); + if ($signature === null) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM signature value missing.'); + } + + $h = $this->nullableString($tags['h'] ?? null); + if ($h === null) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM signed header list missing.'); + } + + $keyRecord = $this->resolver->resolve($domain, $selector); + if ($keyRecord === null) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM public key record not found.'); + } + + $publicKeyPem = $this->publicKeyPemFromRecord($keyRecord); + if ($publicKeyPem === null) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM public key record is invalid.'); + } + + $signingInput = $this->buildSigningInput($headers, $h, $dkimHeader, $headerCanon); + if ($signingInput === null) { + return new DkimVerificationResult(false, $domain, $selector, 'Signed header list could not be matched to message headers.'); + } + + $verified = openssl_verify( + $signingInput, + base64_decode($signature, true) ?: '', + $publicKeyPem, + OPENSSL_ALGO_SHA256, + ) === 1; + + if (!$verified) { + return new DkimVerificationResult(false, $domain, $selector, 'DKIM signature verification failed.'); + } + + return new DkimVerificationResult(true, $domain, $selector); + } + + /** + * @param list $headers + */ + private function buildSigningInput(array $headers, string $hValue, string $dkimValue, string $headerCanon): ?string + { + $wantedHeaders = array_values(array_filter(array_map( + static fn(string $name): string => strtolower(trim($name)), + explode(':', strtolower($hValue)), + ), static fn(string $value): bool => $value !== '')); + + if ($wantedHeaders === []) { + return null; + } + + $usedIndices = []; + $canonicalized = []; + + foreach ($wantedHeaders as $wantedHeader) { + $index = $this->findHeaderIndexFromBottom($headers, $wantedHeader, $usedIndices); + if ($index === null) { + return null; + } + + $usedIndices[$index] = true; + $canonicalized[] = $this->canonicalizeHeader( + $headers[$index]['name'], + $headers[$index]['value'], + $headerCanon, + ); + } + + $dkimWithoutSignature = preg_replace('/\bb=([^;]*)/i', 'b=', $dkimValue, 1); + if (!is_string($dkimWithoutSignature)) { + return null; + } + + $canonicalized[] = $this->canonicalizeHeader('DKIM-Signature', $dkimWithoutSignature, $headerCanon); + + return implode("\r\n", $canonicalized); + } + + private function canonicalizeBody(string $body, string $algorithm): string + { + $algorithm = strtolower(trim($algorithm)); + + $normalized = str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $body)); + $lines = explode("\r\n", $normalized); + + if ($algorithm === 'relaxed') { + foreach ($lines as &$line) { + $line = rtrim(preg_replace('/[ \t]+/', ' ', $line) ?? $line, ' '); + } + unset($line); + } + + while ($lines !== [] && end($lines) === '') { + array_pop($lines); + } + + return implode("\r\n", $lines) . "\r\n"; + } + + private function canonicalizeHeader(string $name, string $value, string $algorithm): string + { + $algorithm = strtolower(trim($algorithm)); + + if ($algorithm === 'relaxed') { + $normalizedName = strtolower(trim($name)); + $normalizedValue = preg_replace('/\s+/', ' ', trim($value)) ?? trim($value); + + return sprintf('%s:%s', $normalizedName, $normalizedValue); + } + + return sprintf('%s:%s', trim($name), ltrim($value)); + } + + /** + * @param list $headers + * @param array $usedIndices + */ + private function findHeaderIndexFromBottom(array $headers, string $name, array $usedIndices): ?int + { + for ($index = count($headers) - 1; $index >= 0; $index--) { + if (array_key_exists($index, $usedIndices)) { + continue; + } + + if (strtolower((string) $headers[$index]['name']) !== $name) { + continue; + } + + return $index; + } + + return null; + } + + /** + * @param list $headers + */ + private function lastHeaderValue(array $headers, string $name): ?string + { + for ($index = count($headers) - 1; $index >= 0; $index--) { + if (strtolower((string) $headers[$index]['name']) !== strtolower($name)) { + continue; + } + + return (string) $headers[$index]['value']; + } + + return null; + } + + private function nullableString(?string $value): ?string + { + if ($value === null) { + return null; + } + + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } + + /** + * @return list + */ + private function parseHeaderLines(string $headersBlock): array + { + $parsed = []; + + foreach (DkimHeaderTools::unfoldLines($headersBlock) as $line) { + if (!str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $parsed[] = ['name' => trim($name), 'value' => ltrim($value)]; + } + + return $parsed; + } + + /** + * @return array + */ + private function parseTagValueList(string $headerValue): array + { + $parts = preg_split('/\s*;\s*/', trim($headerValue)) ?: []; + $tags = []; + + foreach ($parts as $part) { + if ($part === '' || !str_contains($part, '=')) { + continue; + } + + [$name, $value] = explode('=', $part, 2); + $tags[strtolower(trim($name))] = trim($value); + } + + return $tags; + } + + private function publicKeyPemFromRecord(string $record): ?string + { + $tags = $this->parseTagValueList($record); + $publicKey = $this->nullableString($tags['p'] ?? null); + + if ($publicKey === null) { + return null; + } + + $base64 = preg_replace('/\s+/', '', $publicKey) ?? $publicKey; + if ($base64 === '') { + return null; + } + + $pem = "-----BEGIN PUBLIC KEY-----\n"; + $pem .= chunk_split($base64, 64, "\n"); + + return $pem . "-----END PUBLIC KEY-----\n"; + } + + /** + * @return array{0:string,1:string} + */ + private function splitRaw(string $raw): array + { + $normalized = str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $raw)); + $parts = preg_split("/\r\n\r\n/", $normalized, 2) ?: []; + + return [$parts[0] ?? '', $parts[1] ?? '']; + } +} diff --git a/src/Email/Dkim/DnsDkimPublicKeyResolver.php b/src/Email/Dkim/DnsDkimPublicKeyResolver.php new file mode 100644 index 0000000..c45d92a --- /dev/null +++ b/src/Email/Dkim/DnsDkimPublicKeyResolver.php @@ -0,0 +1,106 @@ +dnsLookup; + $records = is_callable($lookup) + ? $lookup($recordName, DNS_TXT) + : dns_get_record($recordName, DNS_TXT); + + if (!is_array($records) || $records === []) { + return null; + } + + $dkimRecords = []; + + foreach ($records as $record) { + if (!is_array($record)) { + continue; + } + + $txt = $this->extractTxtRecord($record); + if ($txt === null || trim($txt) === '') { + continue; + } + + if (!str_contains(strtolower($txt), 'v=dkim1')) { + continue; + } + + $dkimRecords[] = $txt; + } + + foreach ($dkimRecords as $dkimRecord) { + $publicKey = $this->extractTag($dkimRecord, 'p'); + if ($publicKey === null || trim($publicKey) === '') { + continue; + } + + return $dkimRecord; + } + + return null; + } + + private function extractTag(string $record, string $tag): ?string + { + $parts = preg_split('/\s*;\s*/', trim($record)) ?: []; + $needle = strtolower($tag) . '='; + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + if (!str_starts_with(strtolower($part), $needle)) { + continue; + } + + return trim(substr($part, strlen($needle))); + } + + return null; + } + + /** + * @param array $record + */ + private function extractTxtRecord(array $record): ?string + { + $entries = $record['entries'] ?? null; + if (is_array($entries) && $entries !== []) { + $chunks = []; + foreach ($entries as $entry) { + if (!is_string($entry)) { + continue; + } + + $chunks[] = $entry; + } + + if ($chunks !== []) { + return implode('', $chunks); + } + } + + $txt = $record['txt'] ?? null; + if (!is_string($txt) || $txt === '') { + return null; + } + + return $txt; + } +} diff --git a/src/Email/Dkim/StaticDkimPublicKeyResolver.php b/src/Email/Dkim/StaticDkimPublicKeyResolver.php new file mode 100644 index 0000000..c28d48b --- /dev/null +++ b/src/Email/Dkim/StaticDkimPublicKeyResolver.php @@ -0,0 +1,20 @@ + $records + */ + public function __construct(private array $records) {} + + public function resolve(string $domain, string $selector): ?string + { + $recordName = strtolower(sprintf('%s._domainkey.%s', trim($selector), trim($domain))); + + return $this->records[$recordName] ?? null; + } +} diff --git a/src/Email/Email.php b/src/Email/Email.php new file mode 100644 index 0000000..e8a2720 --- /dev/null +++ b/src/Email/Email.php @@ -0,0 +1,33 @@ +):void $listener + */ + public static function events(?callable $listener): void + { + CommunicationEventBus::listen($listener); + } + + public static function mailbox(): EmailMailboxFactory + { + return new EmailMailboxFactory(); + } + + public static function receiver(): EmailReceiverFactory + { + return new EmailReceiverFactory(); + } + + public static function sender(): EmailSenderFactory + { + return new EmailSenderFactory(); + } +} diff --git a/src/Email/EmailMailboxFactory.php b/src/Email/EmailMailboxFactory.php new file mode 100644 index 0000000..4085cd1 --- /dev/null +++ b/src/Email/EmailMailboxFactory.php @@ -0,0 +1,23 @@ + $attachments + * @param array $metadata + */ + private function __construct( + private EmailEnvelope $envelope, + private EmailHeaders $headers, + private string $htmlBody, + private string $textBody, + private array $attachments, + private array $metadata, + ) {} + + public static function new(): self + { + return new self(new EmailEnvelope(), new EmailHeaders(), '', '', [], []); + } + + public function assertReadyToSend(): void + { + $this->envelope->assertCanSend(); + $this->headers->assertCanSend(); + + if ($this->htmlBody === '' && $this->textBody === '') { + throw new \LogicException('Email message body is required before sending.'); + } + } + + public function attach(string $filePath, ?string $filename = null, int $maxSizeBytes = 26214400): self + { + $attachments = $this->attachments; + $attachments[] = EmailAttachment::fromPath($filePath, $filename, $maxSizeBytes); + + return new self($this->envelope, $this->headers, $this->htmlBody, $this->textBody, $attachments, $this->metadata); + } + + public function attachData( + string $content, + string $name, + string $mimeType = 'application/octet-stream', + int $maxSizeBytes = 26214400, + ): self { + $attachments = $this->attachments; + $attachments[] = EmailAttachment::fromData($content, $name, $mimeType, maxSizeBytes: $maxSizeBytes); + + return new self($this->envelope, $this->headers, $this->htmlBody, $this->textBody, $attachments, $this->metadata); + } + + public function attachInline(string $filePath, string $contentId, ?string $filename = null, int $maxSizeBytes = 26214400): self + { + $attachments = $this->attachments; + $attachments[] = EmailAttachment::fromPath($filePath, $filename, $maxSizeBytes, 'inline', $contentId); + + return new self($this->envelope, $this->headers, $this->htmlBody, $this->textBody, $attachments, $this->metadata); + } + + public function attachInlineData( + string $content, + string $name, + string $contentId, + string $mimeType = 'application/octet-stream', + int $maxSizeBytes = 26214400, + ): self { + $attachments = $this->attachments; + $attachments[] = EmailAttachment::fromData( + $content, + $name, + $mimeType, + 'inline', + $contentId, + $maxSizeBytes, + ); + + return new self($this->envelope, $this->headers, $this->htmlBody, $this->textBody, $attachments, $this->metadata); + } + + /** + * @param resource $stream + */ + public function attachInlineStream( + mixed $stream, + string $name, + string $contentId, + string $mimeType = 'application/octet-stream', + int $maxSizeBytes = 26214400, + ): self { + $attachments = $this->attachments; + $attachments[] = EmailAttachment::fromStream($stream, $name, $mimeType, 'inline', $contentId, $maxSizeBytes); + + return new self($this->envelope, $this->headers, $this->htmlBody, $this->textBody, $attachments, $this->metadata); + } + + /** + * @return list + */ + public function attachments(): array + { + return $this->attachments; + } + + /** + * @param resource $stream + */ + public function attachStream( + mixed $stream, + string $name, + string $mimeType = 'application/octet-stream', + int $maxSizeBytes = 26214400, + ): self { + $attachments = $this->attachments; + $attachments[] = EmailAttachment::fromStream($stream, $name, $mimeType, maxSizeBytes: $maxSizeBytes); + + return new self($this->envelope, $this->headers, $this->htmlBody, $this->textBody, $attachments, $this->metadata); + } + + public function bcc(string ...$mailboxes): self + { + $bcc = array_merge($this->envelope->bcc, $this->parseMailboxes(...$mailboxes)); + + return new self( + $this->envelope->withBcc($bcc), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function bounceTo(string $mailbox): self + { + return $this->returnPath($mailbox); + } + + public function cc(string ...$mailboxes): self + { + $cc = array_merge($this->envelope->cc, $this->parseMailboxes(...$mailboxes)); + + return new self( + $this->envelope->withCc($cc), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function deliveryNotification( + bool $success = false, + bool $failure = true, + bool $delay = true, + bool $returnFull = true, + ?string $envelopeId = null, + ): self { + return new self( + $this->envelope, + $this->headers->withDeliveryNotification($success, $failure, $delay, $returnFull, $envelopeId), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function dsnEnvelopeId(?string $envelopeId): self + { + return new self( + $this->envelope, + $this->headers->withDsnEnvelopeId($envelopeId), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @return array{0:self,1:string} + */ + public function embed( + string $filePath, + ?string $contentId = null, + ?string $filename = null, + int $maxSizeBytes = 26214400, + ): array { + $resolvedContentId = trim($contentId ?? bin2hex(random_bytes(8)), '<>'); + + return [ + $this->attachInline($filePath, $resolvedContentId, $filename, $maxSizeBytes), + $resolvedContentId, + ]; + } + + public function envelope(): EmailEnvelope + { + return $this->envelope; + } + + public function from(string $email, ?string $name = null): self + { + $from = new EmailAddress($email, $name); + $nextHeaders = $this->headers; + + if ($this->headers->replyTo === null) { + $nextHeaders = $nextHeaders->withReplyTo($from); + } + + return new self($this->envelope->withFrom($from), $nextHeaders, $this->htmlBody, $this->textBody, $this->attachments, $this->metadata); + } + + public function generalHeaders(string $language = '', ?Priority $priority = null, string $mailer = ''): self + { + return new self( + $this->envelope, + $this->headers->withGeneralHeaders($language, $priority, $mailer), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @param string|list $value + */ + public function header(string $name, string|array $value): self + { + return new self( + $this->envelope, + $this->headers->withCustomHeader($name, $this->normalizeHeaderValue($value)), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @param array> $headers + */ + public function headers(array $headers): self + { + return new self( + $this->envelope, + $this->headers->withCustomHeaders($headers), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function headersData(): EmailHeaders + { + return $this->headers; + } + + public function html(string $html): self + { + return new self($this->envelope, $this->headers, $html, $this->textBody, $this->attachments, $this->metadata); + } + + public function htmlBody(): string + { + return $this->htmlBody; + } + + public function listHeaders( + ?string $listId = null, + ?string $unsubscribe = null, + ?string $subscribe = null, + ?string $archive = null, + ?string $unsubscribePost = null, + ): self { + return new self( + $this->envelope, + $this->headers->withListHeaders($listId, $unsubscribe, $subscribe, $archive, $unsubscribePost), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @param list $references + */ + public function messageDetails(?string $messageId = null, ?string $inReplyTo = null, array $references = []): self + { + return new self( + $this->envelope, + $this->headers->withMessageDetails($messageId, $inReplyTo, $references), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @return array + */ + public function metadata(): array + { + return $this->metadata; + } + + public function miscHeaders( + ?bool $confirmedOptIn = null, + ?string $spamStatus = null, + ?string $organization = null, + ?string $dispositionNotificationTo = null, + ): self { + $notificationTo = $dispositionNotificationTo !== null && $dispositionNotificationTo !== '' + ? EmailAddress::fromMailbox($dispositionNotificationTo) + : null; + + return new self( + $this->envelope, + $this->headers->withMiscHeaders($confirmedOptIn, $spamStatus, $organization, $notificationTo), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function oneClickUnsubscribe(string $url): self + { + return $this->withHeadersData($this->headers->withOneClickUnsubscribe($url)); + } + + public function readReceiptTo(string $mailbox): self + { + return $this->withMailboxAddressHeader($mailbox, static fn(EmailHeaders $headers, EmailAddress $address): EmailHeaders => $headers->withReadReceiptTo($address)); + } + + public function replyTo(string $mailbox): self + { + return $this->withMailboxAddressHeader($mailbox, static fn(EmailHeaders $headers, EmailAddress $address): EmailHeaders => $headers->withReplyTo($address)); + } + + public function returnPath(string $mailbox): self + { + return new self( + $this->envelope->withReturnPath(EmailAddress::fromMailbox($mailbox)), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function sender(string $mailbox): self + { + return $this->withMailboxAddressHeader($mailbox, static fn(EmailHeaders $headers, EmailAddress $address): EmailHeaders => $headers->withSender($address)); + } + + public function subject(string $subject): self + { + return new self($this->envelope, $this->headers->withSubject($subject), $this->htmlBody, $this->textBody, $this->attachments, $this->metadata); + } + + public function tag(string $key, mixed $value): self + { + $metadata = $this->metadata; + $metadata[$key] = $value; + + return new self($this->envelope, $this->headers, $this->htmlBody, $this->textBody, $this->attachments, $metadata); + } + + /** + * @param array $variables + */ + public function template( + string $template, + array $variables = [], + bool $asHtml = true, + TemplateRendererInterface $renderer = new ArrayVariableRenderer(), + ): self { + $rendered = $renderer->render($template, $variables); + + if ($asHtml) { + return $this->html($rendered); + } + + return $this->text($rendered); + } + + public function text(string $text): self + { + return new self($this->envelope, $this->headers, $this->htmlBody, $text, $this->attachments, $this->metadata); + } + + public function textBody(): string + { + return $this->textBody; + } + + public function to(string ...$mailboxes): self + { + $to = array_merge($this->envelope->to, $this->parseMailboxes(...$mailboxes)); + + return new self( + $this->envelope->withTo($to), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withBcc(string ...$mailboxes): self + { + return new self( + $this->envelope->withBcc($this->parseMailboxes(...$mailboxes)), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withCc(string ...$mailboxes): self + { + return new self( + $this->envelope->withCc($this->parseMailboxes(...$mailboxes)), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @param array> $headers + */ + public function withHeaders(array $headers): self + { + return new self( + $this->envelope, + $this->headers->withCustomHeaders($headers, true), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @param array $metadata + */ + public function withMetadata(array $metadata): self + { + return new self( + $this->envelope, + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $metadata, + ); + } + + public function withoutDeliveryNotification(): self + { + return new self( + $this->envelope, + $this->headers->withDeliveryNotification(success: false, failure: false, delay: false, returnFull: true), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withoutHeader(string $name): self + { + return new self( + $this->envelope, + $this->headers->withoutCustomHeader($name), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withoutListHeaders(): self + { + return new self( + $this->envelope, + $this->headers->withoutListHeaders(), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withoutReadReceipt(): self + { + return new self( + $this->envelope, + $this->headers->withReadReceiptTo(null), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withoutReplyTo(): self + { + return new self( + $this->envelope, + $this->headers->withoutReplyTo(), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withoutReturnPath(): self + { + return new self( + $this->envelope->withReturnPath(null), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withoutSender(): self + { + return new self( + $this->envelope, + $this->headers->withoutSender(), + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + public function withTo(string ...$mailboxes): self + { + return new self( + $this->envelope->withTo($this->parseMailboxes(...$mailboxes)), + $this->headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @param string|list $value + * @return string|list + */ + private function normalizeHeaderValue(string|array $value): string|array + { + if (is_string($value)) { + return $value; + } + + $normalized = []; + foreach ($value as $item) { + $normalized[] = (string) $item; + } + + return $normalized; + } + + /** + * @return list + */ + private function parseMailboxes(string ...$mailboxes): array + { + $addresses = []; + + foreach ($mailboxes as $mailbox) { + $addresses[] = EmailAddress::fromMailbox($mailbox); + } + + return $addresses; + } + + private function withHeadersData(EmailHeaders $headers): self + { + return new self( + $this->envelope, + $headers, + $this->htmlBody, + $this->textBody, + $this->attachments, + $this->metadata, + ); + } + + /** + * @param callable(EmailHeaders, EmailAddress):EmailHeaders $apply + */ + private function withMailboxAddressHeader(string $mailbox, callable $apply): self + { + $address = EmailAddress::fromMailbox($mailbox); + + return $this->withHeadersData($apply($this->headers, $address)); + } +} diff --git a/src/Email/EmailReceiverFactory.php b/src/Email/EmailReceiverFactory.php new file mode 100644 index 0000000..f5db998 --- /dev/null +++ b/src/Email/EmailReceiverFactory.php @@ -0,0 +1,29 @@ + [], - 'cc' => [], - 'bcc' => [] - ]; - - // Message details grouped - private array $messageDetails = [ - 'messageId' => '', - 'inReplyTo' => '', - 'references' => [] - ]; - - // General headers grouped - private array $generalHeaders = [ - 'language' => '', - 'priority' => null, - 'mailer' => '' - ]; - - // List headers grouped - private array $listHeaders = [ - 'listId' => '', - 'unsubscribe' => '', - 'subscribe' => '', - 'archive' => '' - ]; - - // Miscellaneous headers grouped - private array $miscHeaders = [ - 'confirmedOptIn' => null, - 'spamStatus' => '', - 'organization' => '', - 'dispositionNotificationTo' => '' - ]; - - private string $subject; - private string $htmlContent; - private string $plainText; - private string $replyTo; - private array $attachments = []; - private array $smtpConfig = []; + public static function usingLog(LogEmailConfig $config): self + { + return new self(new LogEmailTransport($config)); + } - /** - * @var bool - */ - private bool $smtpConfigured = false; + public static function usingMailFunction(): self + { + return new self(new MailFunctionTransport()); + } - public function __construct($fromEmail, $fromName) + public static function usingNull(): self { - $this->from = [ - 'email' => $this->encodeNonAscii($fromEmail), - 'name' => $this->encodeNonAscii($fromName) - ]; - $this->replyTo = $this->from['email']; // Default Reply-To + return new self(new NullEmailTransport()); } - public function setSMTP(array $smtpConfig): self + public static function usingSendmail(SendmailConfig $config = new SendmailConfig()): self { - $this->smtpConfig = array_merge([ - 'host' => '', - 'auth' => false, - 'username' => '', - 'password' => '', - 'port' => 25, - 'secure' => null - ], $smtpConfig); - $this->smtpConfigured = true; - return $this; + return new self(new SendmailTransport($config)); } - // Set recipients (to, cc, bcc) - public function setRecipients(array $to, array $cc = [], array $bcc = []): self + public static function usingSmtp(SmtpConfig $config): self { - $this->recipients['to'] = array_map([$this, 'encodeNonAscii'], $to); - $this->recipients['cc'] = array_map([$this, 'encodeNonAscii'], $cc); - $this->recipients['bcc'] = array_map([$this, 'encodeNonAscii'], $bcc); - return $this; + return new self(new SmtpTransport($config)); } - // Set message details (subject, content) - public function setMessage(string $subject, string $htmlContent, string $plainText = ''): self + public static function usingSpool(SpoolConfig $config): self { - $this->subject = $this->encodeMimeHeader($subject); - $this->htmlContent = $htmlContent; - $this->plainText = $plainText; - return $this; + return new self(new SpoolEmailTransport($config)); } - // Set reply-to email - public function setReplyTo(string $email): self + public function assertable(): AssertableEmailTransport { - $this->replyTo = $this->encodeNonAscii($email); - return $this; + if (!$this->transport instanceof FakeEmailTransport) { + throw new \LogicException('Assertable email transport is available only when using Emailer::fake().'); + } + + return new AssertableEmailTransport($this->transport); } - // Set message-specific headers - public function setMessageDetails(string $messageId = '', string $inReplyTo = '', array $references = []): self + public function send(EmailMessage $message): CommunicationResult { - $this->messageDetails['messageId'] = $messageId; - $this->messageDetails['inReplyTo'] = $inReplyTo; - $this->messageDetails['references'] = $references; - return $this; + EmailEventBus::dispatch('email.send.start', [ + 'subject' => $message->headersData()->subject, + 'to_count' => count($message->envelope()->to), + 'cc_count' => count($message->envelope()->cc), + 'bcc_count' => count($message->envelope()->bcc), + ]); + + $startedAt = microtime(true); + $result = $this->transport->send($message); + + EmailEventBus::dispatch('email.send.finish', [ + 'successful' => $result->successful, + 'error' => $result->error, + 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'metadata' => $result->metadata, + ]); + + return $result; } - // Set general headers - public function setGeneralHeaders(string $language = '', int $priority = null, string $mailer = ''): self + public function transport(): EmailTransport { - $this->generalHeaders['language'] = $language; - $this->generalHeaders['priority'] = $priority; - $this->generalHeaders['mailer'] = $mailer; - return $this; + return $this->transport; } - // Set list headers - public function setListHeaders( - string $listId = '', - string $unsubscribe = '', - string $subscribe = '', - string $archive = '' - ): self { - $this->listHeaders['listId'] = $listId; - $this->listHeaders['unsubscribe'] = $unsubscribe; - $this->listHeaders['subscribe'] = $subscribe; - $this->listHeaders['archive'] = $archive; - return $this; + public function withDkim(DkimConfig $config): self + { + return new self(new DkimSigningTransport($this->transport, $config)); } - // Set miscellaneous headers - public function setMiscHeaders( - bool $confirmedOptIn = null, - string $spamStatus = '', - string $organization = '', - string $dispositionNotificationTo = '' - ): self { - $this->miscHeaders['confirmedOptIn'] = $confirmedOptIn; - $this->miscHeaders['spamStatus'] = $spamStatus; - $this->miscHeaders['organization'] = $organization; - $this->miscHeaders['dispositionNotificationTo'] = $dispositionNotificationTo; - return $this; + /** + * @param list $fallbackTransports + */ + public function withFallback(array $fallbackTransports): self + { + return new self(new FallbackEmailTransport($this->transport, $fallbackTransports)); } - // Add attachment - public function attachment($filePath, $filename = null): self + /** + * @param callable(string, array):void $logger + */ + public function withLogging(callable $logger): self { - if (file_exists($filePath)) { - $this->attachments[] = ['path' => $filePath, 'name' => $filename ?: basename($filePath)]; - } - return $this; - } - - // Send the email - public function send() - { - $builder = new EmailBuilder($this->from); - - // Set common headers - $builder->setCommonHeaders( - $this->recipients['to'], - $this->subject, - $this->recipients['cc'], - $this->recipients['bcc'], - $this->replyTo - ) - ->setIdHeaders( - $this->messageDetails['messageId'], - $this->messageDetails['inReplyTo'], - $this->messageDetails['references'] - ) - ->setGeneralHeaders( - $this->generalHeaders['language'], - $this->generalHeaders['priority'], - $this->generalHeaders['mailer'] - ) - ->setListHeaders( - $this->listHeaders['listId'], - $this->listHeaders['unsubscribe'], - $this->listHeaders['subscribe'], - $this->listHeaders['archive'] - ) - ->setMiscHeaders( - $this->miscHeaders['confirmedOptIn'], - $this->miscHeaders['spamStatus'], - $this->miscHeaders['organization'], - $this->miscHeaders['dispositionNotificationTo'] - ); - - // Choose the appropriate sending method (SMTP or generic) - if ($this->smtpConfigured) { - return (new SMTPSender($this->from, $this->smtpConfig))->send( - $this->recipients['to'], - $builder->setBody($this->htmlContent, $this->plainText, $this->attachments), - $builder->getHeaders() - ); - } + return new self(new LoggingEmailTransport($this->transport, $logger)); + } + + public function withPsrLogger(object $logger, string $level = 'info'): self + { + return $this->withLogging(new Psr3LoggerAdapter($logger)->toCallable($level)); + } - return (new GenericSender())->send( - $this->recipients['to'], - $this->subject, - $builder->setBody($this->htmlContent, $this->plainText, $this->attachments), - $builder->getHeaders() - ); + public function withRateLimit(RateLimiter $rateLimiter): self + { + return new self(new RateLimitedEmailTransport($this->transport, $rateLimiter)); } - // Helper to encode non-ASCII characters - private function encodeNonAscii($string) + public function withRetry(RetryPolicy $retryPolicy): self { - return preg_replace_callback('/[^\x20-\x7E]/', function ($matches) { - return '=?UTF-8?B?' . base64_encode($matches[0]) . '?='; - }, $string); + return new self(new RetryEmailTransport($this->transport, $retryPolicy)); } - // Helper to encode MIME headers - private function encodeMimeHeader($text) + public function withTransport(EmailTransport $transport): self { - return '=?UTF-8?B?' . base64_encode($text) . '?='; + return new self($transport); } } diff --git a/src/Email/Enum/BounceType.php b/src/Email/Enum/BounceType.php new file mode 100644 index 0000000..38aaa6e --- /dev/null +++ b/src/Email/Enum/BounceType.php @@ -0,0 +1,26 @@ +):void $listener + */ + public function __construct(mixed $listener) + { + $this->dispatcher = new CallableEventDispatcher($listener); + } + + /** + * @param array $payload + */ + public function dispatch(string $event, array $payload = []): void + { + $this->dispatcher->dispatch($event, $payload); + } +} diff --git a/src/Email/Event/EmailEventBus.php b/src/Email/Event/EmailEventBus.php new file mode 100644 index 0000000..a8f98e6 --- /dev/null +++ b/src/Email/Event/EmailEventBus.php @@ -0,0 +1,31 @@ + $payload + */ + public static function dispatch(string $event, array $payload = []): void + { + CommunicationEventBus::dispatch($event, $payload); + } + + /** + * @param null|callable(string, array):void $listener + */ + public static function listen(?callable $listener): void + { + CommunicationEventBus::listen($listener); + } + + public static function useDispatcher(EmailEventDispatcher $dispatcher): void + { + CommunicationEventBus::useDispatcher($dispatcher); + } +} diff --git a/src/Email/Event/EmailEventDispatcher.php b/src/Email/Event/EmailEventDispatcher.php new file mode 100644 index 0000000..a9b6ccc --- /dev/null +++ b/src/Email/Event/EmailEventDispatcher.php @@ -0,0 +1,9 @@ +dispatcher = new NullEventDispatcher(); + } + + /** + * @param array $payload + */ + public function dispatch(string $event, array $payload = []): void + { + $this->dispatcher->dispatch($event, $payload); + } +} diff --git a/src/Email/Exception/AttachmentException.php b/src/Email/Exception/AttachmentException.php new file mode 100644 index 0000000..55d7500 --- /dev/null +++ b/src/Email/Exception/AttachmentException.php @@ -0,0 +1,7 @@ +):void + */ + public function toCallable(string $level = 'info'): callable + { + $callback = [$this->logger, 'log']; + if (!is_callable($callback)) { + throw new InvalidArgumentException('Logger must expose a callable log method.'); + } + + $loggerCallable = \Closure::fromCallable($callback); + + return function (string $event, array $payload = []) use ($level, $loggerCallable): void { + $loggerCallable($level, $event, $payload); + }; + } +} diff --git a/src/Email/Mailbox/BodyStructureMailboxTransport.php b/src/Email/Mailbox/BodyStructureMailboxTransport.php new file mode 100644 index 0000000..7b3a5d9 --- /dev/null +++ b/src/Email/Mailbox/BodyStructureMailboxTransport.php @@ -0,0 +1,10 @@ +>> + */ + private array $flags = ['INBOX' => []]; + + /** + * @var array> + */ + private array $messages = ['INBOX' => []]; + + /** + * @var array> + */ + private array $seen = ['INBOX' => []]; + + public function addFlag(string $folder, int $uid, string $flag): void + { + $this->ensureMessageExists($folder, $uid); + $normalized = $this->normalizeFlag($flag); + $current = $this->flags[$folder][$uid] ?? []; + if (!in_array($normalized, $current, true)) { + $current[] = $normalized; + } + + $this->flags[$folder][$uid] = $current; + } + + public function bodyStructure(string $folder, int $uid): string + { + $headers = $this->rawHeaders($folder, $uid); + $contentType = $headers['content-type'][0] ?? 'text/plain'; + $disposition = $headers['content-disposition'][0] ?? ''; + $upperDisposition = strtoupper($disposition); + + $normalizedDisposition = ''; + if (str_contains($upperDisposition, 'ATTACHMENT')) { + $normalizedDisposition = 'ATTACHMENT'; + } elseif (str_contains($upperDisposition, 'INLINE')) { + $normalizedDisposition = 'INLINE'; + } + + return sprintf('("CONTENT-TYPE" "%s" "CONTENT-DISPOSITION" "%s")', $contentType, $normalizedDisposition); + } + + public function close(string $folder): void + { + if (!array_key_exists($folder, $this->messages)) { + throw new MailboxProtocolException(sprintf('Folder "%s" does not exist.', $folder)); + } + } + + public function connect(): void + { + // no-op for fake transport + } + + public function copy(string $folder, int $uid, string $targetFolder): void + { + $raw = $this->rawMessage($folder, $uid); + $this->append($targetFolder, $uid, $raw, $this->seen[$folder][$uid] ?? false); + $this->flags[$targetFolder][$uid] = $this->flags[$folder][$uid] ?? []; + } + + public function createFolder(string $folder): void + { + if (array_key_exists($folder, $this->messages)) { + return; + } + + $this->messages[$folder] = []; + $this->seen[$folder] = []; + $this->flags[$folder] = []; + } + + public function delete(string $folder, int $uid): void + { + unset($this->messages[$folder][$uid], $this->seen[$folder][$uid], $this->flags[$folder][$uid]); + } + + public function deleteFolder(string $folder): void + { + unset($this->messages[$folder], $this->seen[$folder], $this->flags[$folder]); + } + + public function envelopeSummary(string $folder, int $uid): MailboxMessageRef + { + $headers = $this->rawHeaders($folder, $uid); + $date = null; + if (($headers['date'][0] ?? null) !== null) { + $date = new HeaderParser()->parseDate($headers['date'][0]); + } + + return new MailboxMessageRef( + uid: $uid, + subject: $headers['subject'][0] ?? null, + date: $date, + from: $headers['from'][0] ?? null, + sizeBytes: strlen($this->rawMessage($folder, $uid)), + messageId: $headers['message-id'][0] ?? null, + ); + } + + public function expunge(string $folder): void + { + if (!array_key_exists($folder, $this->messages)) { + return; + } + + ksort($this->messages[$folder]); + } + + /** + * @return list + */ + public function folderDetails(): array + { + $details = []; + foreach ($this->messages as $folder => $_messages) { + $details[] = new MailboxFolderInfo($folder, $folder, '/', ['\\HASNOCHILDREN']); + } + + return $details; + } + + public function folderExists(string $folder): bool + { + return array_key_exists($folder, $this->messages); + } + + /** + * @return list + */ + public function folders(): array + { + return array_keys($this->messages); + } + + public function logout(): void + { + // no-op for fake transport + } + + public function markSeen(string $folder, int $uid): void + { + $this->ensureMessageExists($folder, $uid); + $this->seen[$folder][$uid] = true; + } + + public function markUnread(string $folder, int $uid): void + { + $this->ensureMessageExists($folder, $uid); + $this->seen[$folder][$uid] = false; + } + + public function move(string $folder, int $uid, string $targetFolder): void + { + $this->copy($folder, $uid, $targetFolder); + $this->delete($folder, $uid); + } + + public function noop(): void + { + // no-op + } + + /** + * @return array> + */ + public function rawHeaders(string $folder, int $uid): array + { + return MailboxRawHeaderMap::fromRawMessage($this->rawMessage($folder, $uid)); + } + + public function rawMessage(string $folder, int $uid): string + { + $this->ensureMessageExists($folder, $uid); + + return $this->messages[$folder][$uid]; + } + + public function removeFlag(string $folder, int $uid, string $flag): void + { + $this->ensureMessageExists($folder, $uid); + $normalized = $this->normalizeFlag($flag); + $current = $this->flags[$folder][$uid] ?? []; + $this->flags[$folder][$uid] = array_values( + array_filter($current, static fn(string $item): bool => $item !== $normalized), + ); + } + + public function renameFolder(string $folder, string $targetFolder): void + { + if (!$this->folderExists($folder)) { + throw new MailboxProtocolException(sprintf('Folder "%s" does not exist.', $folder)); + } + + if ($this->folderExists($targetFolder)) { + throw new MailboxProtocolException(sprintf('Folder "%s" already exists.', $targetFolder)); + } + + $this->messages[$targetFolder] = $this->messages[$folder]; + $this->seen[$targetFolder] = $this->seen[$folder]; + $this->flags[$targetFolder] = $this->flags[$folder]; + unset($this->messages[$folder], $this->seen[$folder], $this->flags[$folder]); + } + + /** + * @return list + */ + public function search(string $folder, MailboxSearch $search): array + { + if (!array_key_exists($folder, $this->messages)) { + return []; + } + + $criteria = strtoupper($search->toCriteriaString()); + $tokens = preg_split('/\s+/', $criteria) ?: []; + $requiresUnseen = in_array('UNSEEN', $tokens, true); + $requiresSeen = in_array('SEEN', $tokens, true); + $uids = []; + + foreach ($this->messages[$folder] as $uid => $raw) { + $isSeen = $this->seen[$folder][$uid] ?? false; + + if ($requiresUnseen && $isSeen) { + continue; + } + + if ($requiresSeen && !$isSeen) { + continue; + } + + if (!$this->matchesHeaderContains($criteria, 'SUBJECT', $raw)) { + continue; + } + + if (!$this->matchesHeaderContains($criteria, 'FROM', $raw)) { + continue; + } + + $uids[] = $uid; + } + + sort($uids); + + if ($search->limit !== null) { + return array_slice($uids, 0, $search->limit); + } + + return $uids; + } + + public function status(string $folder): MailboxStatus + { + $messages = array_key_exists($folder, $this->messages) ? count($this->messages[$folder]) : 0; + $unseen = 0; + + if (array_key_exists($folder, $this->seen)) { + foreach ($this->seen[$folder] as $isSeen) { + if (!$isSeen) { + $unseen++; + } + } + } + + return new MailboxStatus($messages, 0, $unseen); + } + + public function subscribeFolder(string $folder): void + { + if (!$this->folderExists($folder)) { + throw new MailboxProtocolException(sprintf('Folder "%s" does not exist.', $folder)); + } + } + + public function unsubscribeFolder(string $folder): void + { + if (!$this->folderExists($folder)) { + throw new MailboxProtocolException(sprintf('Folder "%s" does not exist.', $folder)); + } + } + + /** + * @param callable(string):void $onEvent + * @param null|callable():bool $shouldStop + */ + public function watch(string $folder, callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void + { + if (!array_key_exists($folder, $this->messages)) { + throw new MailboxProtocolException(sprintf('Folder "%s" does not exist.', $folder)); + } + + $stop = $shouldStop ?? static fn(): bool => false; + $deadline = time() + max(1, $timeoutSeconds); + + while (time() < $deadline && !$stop()) { + $onEvent(sprintf('* %d EXISTS', count($this->messages[$folder]))); + usleep(250000); + } + } + + public function withMessage(string $folder, int $uid, string $raw, bool $seen = false): self + { + $clone = clone $this; + $clone->append($folder, $uid, $raw, $seen); + + return $clone; + } + + private function append(string $folder, int $uid, string $raw, bool $seen): void + { + if (!array_key_exists($folder, $this->messages)) { + $this->messages[$folder] = []; + $this->seen[$folder] = []; + $this->flags[$folder] = []; + } + + $this->messages[$folder][$uid] = $raw; + $this->seen[$folder][$uid] = $seen; + $this->flags[$folder][$uid] ??= []; + } + + private function ensureMessageExists(string $folder, int $uid): void + { + if (array_key_exists($folder, $this->messages) && array_key_exists($uid, $this->messages[$folder])) { + return; + } + + throw new MailboxProtocolException(sprintf('Message UID %d not found in folder "%s".', $uid, $folder)); + } + + private function matchesHeaderContains(string $criteria, string $header, string $raw): bool + { + if (!preg_match('/' . $header . '\s+"([^"]+)"/', $criteria, $matches)) { + return true; + } + + $needle = strtolower($matches[1]); + if ($needle === '') { + return true; + } + + if (preg_match('/^' . $header . ':\s*(.+)$/mi', $raw, $headerMatch) !== 1) { + return false; + } + + return str_contains(strtolower($headerMatch[1]), $needle); + } + + private function normalizeFlag(string $flag): string + { + $trimmed = trim($flag); + MailboxFlagGuard::assertValid($trimmed); + + return str_starts_with($trimmed, '\\') ? strtoupper($trimmed) : $trimmed; + } +} diff --git a/src/Email/Mailbox/ImapCommand.php b/src/Email/Mailbox/ImapCommand.php new file mode 100644 index 0000000..b5b04b4 --- /dev/null +++ b/src/Email/Mailbox/ImapCommand.php @@ -0,0 +1,18 @@ +tag, $this->command); + } +} diff --git a/src/Email/Mailbox/ImapEnvelopeParser.php b/src/Email/Mailbox/ImapEnvelopeParser.php new file mode 100644 index 0000000..d847781 --- /dev/null +++ b/src/Email/Mailbox/ImapEnvelopeParser.php @@ -0,0 +1,260 @@ +lines as $line) { + $start = stripos($line, 'ENVELOPE '); + if ($start === false) { + continue; + } + + $start += strlen('ENVELOPE '); + while (isset($line[$start]) && $line[$start] === ' ') { + $start++; + } + + if (!isset($line[$start]) || $line[$start] !== '(') { + continue; + } + + $envelopeChunk = $this->extractParenthesized($line, $start); + if ($envelopeChunk === null) { + continue; + } + + $fields = $this->splitTopLevel(substr($envelopeChunk, 1, -1)); + if (count($fields) < 10) { + continue; + } + + $date = null; + $dateValue = $this->parseNullableString($fields[0]); + if ($dateValue !== null) { + $date = new HeaderParser()->parseDate($dateValue); + } + + return [ + 'subject' => $this->parseNullableString($fields[1]), + 'from' => $this->parseEnvelopeFromField($fields[2]), + 'date' => $date, + 'messageId' => $this->parseNullableString($fields[9]), + ]; + } + + return null; + } + + private function consumeParenthesis( + string $char, + int &$depth, + string $value, + int $start, + int $index, + ): ?string { + if ($char === '(') { + $depth++; + + return null; + } + + if ($char !== ')') { + return null; + } + + $depth--; + if ($depth === 0) { + return substr($value, $start, $index - $start + 1); + } + + return null; + } + + private function consumeQuotedState(string $char, bool &$inQuote, bool &$escaped): bool + { + if (!$inQuote) { + return false; + } + + if ($escaped) { + $escaped = false; + + return true; + } + + if ($char === '\\') { + $escaped = true; + + return true; + } + + if ($char === '"') { + $inQuote = false; + } + + return true; + } + + private function consumeQuotedStateWithBuffer(string $char, bool &$inQuote, bool &$escaped, string &$buffer): bool + { + if (!$inQuote) { + return false; + } + + $buffer .= $char; + + return $this->consumeQuotedState($char, $inQuote, $escaped); + } + + private function extractParenthesized(string $value, int $start): ?string + { + $depth = 0; + $inQuote = false; + $escaped = false; + $length = strlen($value); + + for ($index = $start; $index < $length; $index++) { + $char = $value[$index]; + + if ($this->consumeQuotedState($char, $inQuote, $escaped)) { + continue; + } + + if ($char === '"') { + $inQuote = true; + + continue; + } + + $chunk = $this->consumeParenthesis($char, $depth, $value, $start, $index); + if ($chunk !== null) { + return $chunk; + } + } + + return null; + } + + /** + * @param list $parts + */ + private function flushToken(array &$parts, string &$buffer): void + { + $trimmed = trim($buffer); + if ($trimmed !== '') { + $parts[] = $trimmed; + } + + $buffer = ''; + } + + private function parseEnvelopeFromField(string $field): ?string + { + $trimmed = trim($field); + if ($trimmed === '' || strtoupper($trimmed) === 'NIL' || $trimmed[0] !== '(') { + return null; + } + + $addressListRaw = substr($trimmed, 1, -1); + $addresses = $this->splitTopLevel($addressListRaw); + if ($addresses === []) { + return null; + } + + $first = trim($addresses[0]); + if ($first === '' || $first[0] !== '(') { + return null; + } + + $tuple = $this->splitTopLevel(substr($first, 1, -1)); + if (count($tuple) < 4) { + return null; + } + + $name = $this->parseNullableString($tuple[0]); + $mailbox = $this->parseNullableString($tuple[2]); + $host = $this->parseNullableString($tuple[3]); + if ($mailbox === null || $host === null) { + return $name; + } + + $email = sprintf('%s@%s', $mailbox, $host); + if ($name === null || $name === '') { + return $email; + } + + return sprintf('%s <%s>', $name, $email); + } + + private function parseNullableString(string $field): ?string + { + $trimmed = trim($field); + if ($trimmed === '' || strtoupper($trimmed) === 'NIL') { + return null; + } + + if (str_starts_with($trimmed, '"') && str_ends_with($trimmed, '"')) { + return stripcslashes(substr($trimmed, 1, -1)); + } + + return $trimmed; + } + + /** + * @return list + */ + private function splitTopLevel(string $value): array + { + $parts = []; + $buffer = ''; + $depth = 0; + $inQuote = false; + $escaped = false; + $index = 0; + $length = strlen($value); + + while ($index < $length) { + $char = $value[$index++]; + if ($this->consumeQuotedStateWithBuffer($char, $inQuote, $escaped, $buffer)) { + continue; + } + + if ($char === '"') { + $inQuote = true; + $buffer .= '"'; + + continue; + } + + if ($char === '(' || $char === ')') { + $depth += $char === '(' ? 1 : -1; + $buffer .= $char; + + continue; + } + + if ($depth === 0 && ctype_space($char)) { + $this->flushToken($parts, $buffer); + + continue; + } + + $buffer .= $char; + } + + $this->flushToken($parts, $buffer); + + return $parts; + } +} diff --git a/src/Email/Mailbox/ImapFetchLiteralMapper.php b/src/Email/Mailbox/ImapFetchLiteralMapper.php new file mode 100644 index 0000000..82153d5 --- /dev/null +++ b/src/Email/Mailbox/ImapFetchLiteralMapper.php @@ -0,0 +1,111 @@ + $sections + */ + public function findBySections(ImapResponse $response, array $sections): ?string + { + $normalizedSections = array_map( + static fn(string $section): string => strtoupper(trim($section)), + $sections, + ); + + $cursor = 0; + foreach ($response->lines as $line) { + $cursor = $this->scanLineForSectionLiterals( + line: $line, + response: $response, + cursor: $cursor, + sections: $normalizedSections, + literal: $literal, + ); + + if ($literal !== null) { + return $literal; + } + } + + return null; + } + + public function firstFetchLiteral(ImapResponse $response): string + { + $cursor = 0; + foreach ($response->lines as $line) { + $literalCount = preg_match_all('/\{(\d+)\}/', $line); + $hasLiteral = is_int($literalCount) && $literalCount > 0; + + if (preg_match('/^\*\s+\d+\s+FETCH\s+\(/i', $line) !== 1) { + if ($hasLiteral) { + $cursor += $literalCount; + } + + continue; + } + + if ($hasLiteral && array_key_exists($cursor, $response->literals)) { + return $response->literals[$cursor]; + } + + $cursor += $hasLiteral ? $literalCount : 0; + } + + return $response->literals !== [] ? $response->literals[0] : ''; + } + + /** + * @param list $sections + */ + private function lineContainsAnySection(string $token, array $sections): bool + { + if ($token === '') { + return false; + } + + return array_any($sections, fn($section): bool => str_contains($token, (string) $section)); + } + + /** + * @param list $sections + */ + private function scanLineForSectionLiterals( + string $line, + ImapResponse $response, + int $cursor, + array $sections, + ?string &$literal, + ): int { + $matches = []; + preg_match_all('/\{(\d+)\}/', $line, $matches, PREG_OFFSET_CAPTURE); + $literalMatchCount = count($matches[0]); + if ($literalMatchCount === 0) { + return $cursor; + } + + for ($index = 0; $index < $literalMatchCount; $index++) { + $offset = $matches[0][$index][1] ?? null; + if (!is_int($offset)) { + $cursor++; + + continue; + } + + $token = strtoupper(trim(substr($line, 0, $offset))); + if ($this->lineContainsAnySection($token, $sections) && array_key_exists($cursor, $response->literals)) { + $literal = $response->literals[$cursor]; + + return $cursor + 1; + } + + $cursor++; + } + + return $cursor; + } +} diff --git a/src/Email/Mailbox/ImapListParser.php b/src/Email/Mailbox/ImapListParser.php new file mode 100644 index 0000000..f4c1c43 --- /dev/null +++ b/src/Email/Mailbox/ImapListParser.php @@ -0,0 +1,60 @@ +parseAttributes($matches[1]); + $delimiterValue = $this->decodeFolderValue($matches[2]); + $rawName = $this->decodeFolderValue($matches[4]); + $decodedName = ImapModifiedUtf7::decode($rawName); + + return new MailboxFolderInfo( + name: $decodedName, + path: $decodedName, + delimiter: $delimiterValue === '' ? null : $delimiterValue, + attributes: $attributes, + ); + } + + private function decodeFolderValue(string $value): string + { + $trimmed = trim($value); + if ($trimmed === 'NIL') { + return ''; + } + + if (str_starts_with($trimmed, '"') && str_ends_with($trimmed, '"')) { + return stripcslashes(substr($trimmed, 1, -1)); + } + + return $trimmed; + } + + /** + * @return list + */ + private function parseAttributes(string $raw): array + { + $attributeChunks = preg_split('/\s+/', trim($raw)) ?: []; + $attributes = []; + foreach ($attributeChunks as $attribute) { + $upper = strtoupper(trim($attribute)); + if ($upper === '') { + continue; + } + + $attributes[] = $upper; + } + + return $attributes; + } +} diff --git a/src/Email/Mailbox/ImapModifiedUtf7.php b/src/Email/Mailbox/ImapModifiedUtf7.php new file mode 100644 index 0000000..c3a4bc5 --- /dev/null +++ b/src/Email/Mailbox/ImapModifiedUtf7.php @@ -0,0 +1,44 @@ + $lines + * @param list $literals + */ + public function __construct( + public string $tag, + public string $status, + public array $lines, + public array $literals = [], + ) {} + + public function isOk(): bool + { + return strtoupper($this->status) === 'OK'; + } +} diff --git a/src/Email/Mailbox/ImapResponseParser.php b/src/Email/Mailbox/ImapResponseParser.php new file mode 100644 index 0000000..c461ae6 --- /dev/null +++ b/src/Email/Mailbox/ImapResponseParser.php @@ -0,0 +1,286 @@ + + */ + public function capabilities(ImapResponse $response): array + { + $capabilities = []; + + foreach ($response->lines as $line) { + if (preg_match('/^\*\s+CAPABILITY\s+(.+)$/i', $line, $matches) !== 1) { + continue; + } + + foreach (preg_split('/\s+/', trim($matches[1])) ?: [] as $capability) { + $capabilities[] = strtoupper($capability); + } + } + + return array_values(array_unique($capabilities)); + } + + /** + * @return array> + */ + public function fetchHeaderMap(ImapResponse $response): array + { + $headerRaw = $this->literalMapper->findBySections($response, ['HEADER', 'BODY.PEEK[HEADER]', 'BODY[HEADER]']) + ?? $this->fetchRawMessage($response); + if ($headerRaw === '') { + return []; + } + + $normalized = str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $headerRaw)); + $headerBlock = explode("\r\n\r\n", $normalized, 2)[0]; + $lines = preg_split('/\r\n/', $headerBlock) ?: []; + $headers = []; + $currentName = null; + + foreach ($lines as $line) { + if ($line === '') { + continue; + } + + if (($line[0] === ' ' || $line[0] === "\t") && $currentName !== null) { + $lastIndex = array_key_last($headers[$currentName]); + $headers[$currentName][$lastIndex] .= ' ' . trim($line); + + continue; + } + + if (!str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $currentName = strtolower(trim($name)); + $headers[$currentName] ??= []; + $headers[$currentName][] = trim($value); + } + + return $headers; + } + + public function fetchRawMessage(ImapResponse $response): string + { + // Prefer explicit RFC822/BODY[] literals when present, then fall back to + // the first FETCH-associated literal for simple one-literal responses. + $rfc822 = $this->literalMapper->findBySections($response, ['RFC822', 'BODY[]', 'BODY.PEEK[]']); + if ($rfc822 !== null) { + return $rfc822; + } + + return $this->literalMapper->firstFetchLiteral($response); + } + + public function fetchSectionLiteral(ImapResponse $response, string $section): string + { + return $this->literalMapper->findBySections($response, [$section]) ?? ''; + } + + /** + * @return list + */ + public function folderDetails(ImapResponse $response): array + { + $folders = []; + foreach ($response->lines as $line) { + $parsed = $this->listParser->parse($line); + if ($parsed === null) { + continue; + } + + $folders[] = $parsed; + } + + return $folders; + } + + /** + * @return list + */ + public function folders(ImapResponse $response): array + { + $folders = []; + foreach ($this->folderDetails($response) as $folder) { + $folders[] = $folder->path; + } + + return $folders; + } + + public function parseBodyStructure(ImapResponse $response): string + { + foreach ($response->lines as $line) { + if (preg_match('/BODYSTRUCTURE\s+(.+)$/i', $line, $matches) === 1) { + return trim($matches[1]); + } + } + + return ''; + } + + public function parseEnvelopeSummary(ImapResponse $response, int $uid): MailboxMessageRef + { + $fetchMeta = $this->parseFetchMeta($response); + + $envelope = $this->envelopeParser->parse($response); + if ($envelope !== null) { + return new MailboxMessageRef( + uid: $uid, + subject: $envelope['subject'], + date: $envelope['date'], + from: $envelope['from'], + flags: $fetchMeta['flags'], + sizeBytes: strlen($this->fetchRawMessage($response)), + messageId: $envelope['messageId'], + sequence: $fetchMeta['sequence'], + ); + } + + $headers = $this->fetchHeaderMap($response); + $subject = $headers['subject'][0] ?? null; + $from = $headers['from'][0] ?? null; + $date = null; + if (($headers['date'][0] ?? null) !== null) { + $date = new HeaderParser()->parseDate($headers['date'][0]); + } + + return new MailboxMessageRef( + uid: $uid, + subject: $subject, + date: $date, + from: $from, + flags: $fetchMeta['flags'], + sizeBytes: strlen($this->fetchRawMessage($response)), + messageId: $headers['message-id'][0] ?? null, + sequence: $fetchMeta['sequence'], + ); + } + + /** + * @return list + */ + public function searchUids(ImapResponse $response): array + { + return $this->parseUidList($response->lines, 'SEARCH'); + } + + /** + * @return list + */ + public function sortUids(ImapResponse $response): array + { + return $this->parseUidList($response->lines, 'SORT'); + } + + public function status(ImapResponse $response): MailboxStatus + { + $values = [ + 'MESSAGES' => 0, + 'RECENT' => 0, + 'UNSEEN' => 0, + 'UIDVALIDITY' => null, + 'UIDNEXT' => null, + ]; + + foreach ($response->lines as $line) { + if (preg_match('/^\*\s+STATUS\s+.+\((.+)\)$/i', $line, $matches) !== 1) { + continue; + } + + $chunks = preg_split('/\s+/', trim($matches[1])) ?: []; + for ($i = 0; $i < count($chunks) - 1; $i += 2) { + $name = strtoupper($chunks[$i]); + if (!array_key_exists($name, $values)) { + continue; + } + + $values[$name] = ctype_digit($chunks[$i + 1]) ? (int) $chunks[$i + 1] : 0; + } + } + + return new MailboxStatus( + messages: $values['MESSAGES'], + recent: $values['RECENT'], + unseen: $values['UNSEEN'], + uidValidity: is_int($values['UIDVALIDITY']) ? $values['UIDVALIDITY'] : null, + uidNext: is_int($values['UIDNEXT']) ? $values['UIDNEXT'] : null, + ); + } + + /** + * @return array{sequence:?int,flags:?list} + */ + private function parseFetchMeta(ImapResponse $response): array + { + foreach ($response->lines as $line) { + if (preg_match('/^\*\s+(\d+)\s+FETCH\s+\((.+)\)$/i', $line, $matches) !== 1) { + continue; + } + + $sequence = ctype_digit($matches[1]) ? (int) $matches[1] : null; + $flags = null; + if (preg_match('/\bFLAGS\s+\(([^)]*)\)/i', $matches[2], $flagMatch) === 1) { + $flagTokens = preg_split('/\s+/', trim($flagMatch[1])) ?: []; + $flagTokens = array_values(array_filter($flagTokens, static fn(string $flag): bool => $flag !== '')); + $flags = $flagTokens === [] ? [] : $flagTokens; + } + + return ['sequence' => $sequence, 'flags' => $flags]; + } + + return ['sequence' => null, 'flags' => null]; + } + + /** + * @return list + */ + private function parseUidChunks(string $raw): array + { + if ($raw === '') { + return []; + } + + $uids = []; + foreach (preg_split('/\s+/', $raw) ?: [] as $chunk) { + if (ctype_digit($chunk)) { + $uids[] = (int) $chunk; + } + } + + return $uids; + } + + /** + * @param list $lines + * @return list + */ + private function parseUidList(array $lines, string $token): array + { + foreach ($lines as $line) { + if (preg_match('/^\*\s+' . preg_quote($token, '/') . '\s*(.*)$/i', $line, $matches) !== 1) { + continue; + } + + return $this->parseUidChunks(trim($matches[1])); + } + + return []; + } +} diff --git a/src/Email/Mailbox/ImapSocketTransport.php b/src/Email/Mailbox/ImapSocketTransport.php new file mode 100644 index 0000000..8ada153 --- /dev/null +++ b/src/Email/Mailbox/ImapSocketTransport.php @@ -0,0 +1,642 @@ + + */ + private array $capabilities = []; + + /** + * @var resource|null + */ + private mixed $connection = null; + + private ?string $selectedFolder = null; + + private int $tagCounter = 1; + + public function __construct( + private readonly ImapConfig $config, + private readonly ImapResponseParser $responseParser = new ImapResponseParser(), + ) {} + + public function __destruct() + { + $this->logout(); + } + + public function addFlag(string $folder, int $uid, string $flag): void + { + $normalizedFlag = $this->normalizeFlag($flag); + $this->runUidFetch($folder, $uid, sprintf('UID STORE %%d +FLAGS (%s)', $normalizedFlag), 'UID STORE +FLAGS'); + } + + public function bodyStructure(string $folder, int $uid): string + { + $response = $this->runUidFetch($folder, $uid, 'UID FETCH %d (BODYSTRUCTURE)', 'UID FETCH BODYSTRUCTURE'); + + return $this->responseParser->parseBodyStructure($response); + } + + public function close(string $folder): void + { + $this->runFolderCommand($folder, 'CLOSE', 'CLOSE'); + $this->selectedFolder = null; + } + + public function connect(): void + { + if (is_resource($this->connection)) { + return; + } + $this->connection = SocketMailboxRuntime::connect( + $this->config->host, + $this->config->port, + $this->config->timeoutSeconds, + 'IMAP', + $this->config->security === ImapSecurity::Ssl, + ); + $this->selectedFolder = null; + + $greeting = $this->readLine(); + if (!str_starts_with(strtoupper($greeting), '* OK')) { + throw new MailboxProtocolException(sprintf('Unexpected IMAP greeting: %s', trim($greeting))); + } + + $this->refreshCapabilities(); + $this->negotiateStartTls(); + $this->login(); + } + + public function copy(string $folder, int $uid, string $targetFolder): void + { + MailboxFolderNameGuard::assertValid($targetFolder); + $this->runUidFetch($folder, $uid, sprintf('UID COPY %%d %s', ImapStringEscaper::quote($targetFolder)), 'UID COPY'); + } + + public function createFolder(string $folder): void + { + $this->runUnselectedFolderCommand($folder, 'CREATE', 'CREATE'); + } + + public function delete(string $folder, int $uid): void + { + $this->runUidFetch($folder, $uid, 'UID STORE %d +FLAGS (\\Deleted)', 'UID STORE +FLAGS \\Deleted'); + } + + public function deleteFolder(string $folder): void + { + $this->runUnselectedFolderCommand($folder, 'DELETE', 'DELETE'); + } + + public function envelopeSummary(string $folder, int $uid): MailboxMessageRef + { + $response = $this->runUidFetch($folder, $uid, 'UID FETCH %d (ENVELOPE BODY.PEEK[HEADER])', 'UID FETCH ENVELOPE'); + + return $this->responseParser->parseEnvelopeSummary($response, $uid); + } + + public function expunge(string $folder): void + { + $this->runFolderCommand($folder, 'EXPUNGE', 'EXPUNGE'); + } + + /** + * @return list + */ + public function folderDetails(): array + { + $response = $this->runCommand('LIST "" *'); + + return $this->responseParser->folderDetails($response); + } + + public function folderExists(string $folder): bool + { + return array_any( + $this->folderDetails(), + static fn(MailboxFolderInfo $details): bool => strcasecmp($details->path, $folder) === 0, + ); + } + + /** + * @return list + */ + public function folders(): array + { + $response = $this->runCommand('LIST "" *'); + + return $this->responseParser->folders($response); + } + + public function logout(): void + { + if (!is_resource($this->connection)) { + return; + } + + try { + $this->runCommand('LOGOUT'); + } catch (\Throwable) { + // Best effort for shutdown. + } + + fclose($this->connection); + $this->connection = null; + $this->capabilities = []; + $this->selectedFolder = null; + } + + public function markSeen(string $folder, int $uid): void + { + $this->runUidFetch($folder, $uid, 'UID STORE %d +FLAGS (\\Seen)', 'UID STORE +FLAGS \\Seen'); + } + + public function markUnread(string $folder, int $uid): void + { + $this->runUidFetch($folder, $uid, 'UID STORE %d -FLAGS (\\Seen)', 'UID STORE -FLAGS \\Seen'); + } + + public function move(string $folder, int $uid, string $targetFolder): void + { + MailboxFolderNameGuard::assertValid($folder); + MailboxFolderNameGuard::assertValid($targetFolder); + MailboxUidGuard::assertValid($uid); + $this->selectFolder($folder); + + if ($this->hasCapability('MOVE')) { + $response = $this->runCommand(sprintf('UID MOVE %d %s', $uid, ImapStringEscaper::quote($targetFolder))); + $this->expectOk($response, 'UID MOVE'); + + return; + } + + $this->copy($folder, $uid, $targetFolder); + $this->delete($folder, $uid); + $this->expunge($folder); + } + + public function noop(): void + { + $response = $this->runCommand('NOOP'); + $this->expectOk($response, 'NOOP'); + } + + /** + * @return array> + */ + public function rawHeaders(string $folder, int $uid): array + { + $response = $this->runUidFetch($folder, $uid, 'UID FETCH %d (BODY.PEEK[HEADER])', 'UID FETCH BODY.PEEK[HEADER]'); + + return $this->responseParser->fetchHeaderMap($response); + } + + public function rawMessage(string $folder, int $uid): string + { + $response = $this->runUidFetch($folder, $uid, 'UID FETCH %d (RFC822)', 'UID FETCH RFC822'); + $raw = $this->responseParser->fetchRawMessage($response); + + if ($raw === '') { + throw new MailboxProtocolException(sprintf('IMAP server returned empty message body for UID %d.', $uid)); + } + + return $raw; + } + + public function rawPart(string $folder, int $uid, string $partNumber): string + { + MailboxFolderNameGuard::assertValid($folder); + MailboxUidGuard::assertValid($uid); + ImapPartNumberGuard::assertValid($partNumber); + $response = $this->runUidFetch( + $folder, + $uid, + sprintf('UID FETCH %%d (BODY.PEEK[%s])', $partNumber), + sprintf('UID FETCH BODY.PEEK[%s]', $partNumber), + ); + $raw = $this->responseParser->fetchSectionLiteral($response, sprintf('BODY.PEEK[%s]', $partNumber)); + if ($raw === '') { + $raw = $this->responseParser->fetchSectionLiteral($response, sprintf('BODY[%s]', $partNumber)); + } + if ($raw === '') { + $raw = $this->responseParser->fetchRawMessage($response); + } + + if ($raw === '') { + throw new MailboxProtocolException(sprintf('IMAP server returned empty body for UID %d part %s.', $uid, $partNumber)); + } + + return $raw; + } + + public function removeFlag(string $folder, int $uid, string $flag): void + { + $normalizedFlag = $this->normalizeFlag($flag); + $this->runUidFetch($folder, $uid, sprintf('UID STORE %%d -FLAGS (%s)', $normalizedFlag), 'UID STORE -FLAGS'); + } + + public function renameFolder(string $folder, string $targetFolder): void + { + MailboxFolderNameGuard::assertValid($folder); + MailboxFolderNameGuard::assertValid($targetFolder); + $this->expectOk($this->runCommand(sprintf( + 'RENAME %s %s', + ImapStringEscaper::quote($folder), + ImapStringEscaper::quote($targetFolder), + )), 'RENAME'); + } + + /** + * @return list + */ + public function search(string $folder, MailboxSearch $search): array + { + MailboxFolderNameGuard::assertValid($folder); + $this->selectFolder($folder); + + $uids = []; + if ($this->hasCapability('SORT') && in_array($search->sortOrder, ['date_asc', 'date_desc'], true)) { + $sortKey = $search->sortOrder === 'date_desc' ? '(REVERSE DATE)' : '(DATE)'; + $sortResponse = $this->expectOk( + $this->runCommand(sprintf('UID SORT %s UTF-8 %s', $sortKey, $search->toCriteriaString())), + 'UID SORT', + ); + $uids = $this->responseParser->sortUids($sortResponse); + } + + if ($uids === []) { + $response = $this->expectOk( + $this->runCommand('UID SEARCH ' . $search->toCriteriaString()), + 'UID SEARCH', + ); + $uids = $this->responseParser->searchUids($response); + } + + if ($search->limit !== null) { + return array_slice($uids, 0, $search->limit); + } + + return $uids; + } + + public function status(string $folder): MailboxStatus + { + $response = $this->runFolderCommand( + $folder, + sprintf('STATUS %s (MESSAGES RECENT UNSEEN UIDVALIDITY UIDNEXT)', ImapStringEscaper::quote($folder)), + 'STATUS', + select: false, + ); + + return $this->responseParser->status($response); + } + + public function subscribeFolder(string $folder): void + { + $this->runUnselectedFolderCommand($folder, 'SUBSCRIBE', 'SUBSCRIBE'); + } + + public function unsubscribeFolder(string $folder): void + { + $this->runUnselectedFolderCommand($folder, 'UNSUBSCRIBE', 'UNSUBSCRIBE'); + } + + /** + * @param callable(string):void $onEvent + * @param null|callable():bool $shouldStop + */ + public function watch(string $folder, callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void + { + $this->selectFolder($folder); + $stop = $shouldStop ?? static fn(): bool => false; + + if (!$this->hasCapability('IDLE')) { + $this->watchWithNoopFallback($onEvent, $timeoutSeconds, $stop); + + return; + } + + $this->watchWithIdle($onEvent, $timeoutSeconds, $stop); + } + + private function authenticateStatus(ImapResponse $response): void + { + if ($response->isOk()) { + return; + } + + throw new MailboxAuthenticationException(implode("\n", $response->lines)); + } + + private function expectOk(ImapResponse $response, string $stage): ImapResponse + { + if ($response->isOk()) { + return $response; + } + + throw new MailboxProtocolException(sprintf( + 'IMAP %s failed: %s', + $stage, + implode(' | ', $response->lines), + )); + } + + private function hasCapability(string $capability): bool + { + return in_array(strtoupper($capability), $this->capabilities, true); + } + + private function login(): void + { + $response = $this->runCommand(sprintf( + 'LOGIN %s %s', + ImapStringEscaper::quote($this->config->username), + ImapStringEscaper::quote($this->config->password), + )); + + $this->authenticateStatus($response); + } + + private function negotiateStartTls(): void + { + if (!in_array($this->config->security, [ImapSecurity::StartTlsOptional, ImapSecurity::StartTlsRequired], true)) { + return; + } + + $mustStartTls = $this->config->security === ImapSecurity::StartTlsRequired; + if (!SocketMailboxRuntime::shouldStartTls($mustStartTls, $this->hasCapability('STARTTLS'), 'imap')) { + return; + } + + $response = $this->runCommand('STARTTLS'); + if (!$response->isOk()) { + throw new MailboxConnectionException('IMAP STARTTLS negotiation command was rejected.'); + } + + SocketMailboxRuntime::enableTls($this->requireConnection(), 'imap'); + + $this->refreshCapabilities(); + } + + private function nextTag(): string + { + $tag = sprintf('A%04d', $this->tagCounter); + $this->tagCounter++; + + return $tag; + } + + private function normalizeFlag(string $flag): string + { + $trimmed = trim($flag); + MailboxFlagGuard::assertValid($trimmed); + + return $trimmed; + } + + private function parseLiteralSize(string $line): ?int + { + if (preg_match('/\{(\d+)\}\s*$/', $line, $matches) !== 1) { + return null; + } + + return (int) $matches[1]; + } + + private function readExact(int $bytes): string + { + $buffer = ''; + $connection = $this->requireConnection(); + + while (strlen($buffer) < $bytes) { + $remaining = max(1, $bytes - strlen($buffer)); + $chunk = fread($connection, $remaining); + if ($chunk === false || $chunk === '') { + /** @var array $meta */ + $meta = stream_get_meta_data($connection); + if (($meta['timed_out'] ?? false) === true) { + throw new MailboxConnectionException('IMAP literal read timed out.'); + } + + throw new MailboxProtocolException(sprintf( + 'Unexpected end of stream while reading IMAP literal (expected %d bytes, received %d bytes).', + $bytes, + strlen($buffer), + )); + } + + $buffer .= $chunk; + } + + return $buffer; + } + + private function readLine(): string + { + return SocketMailboxRuntime::readLine($this->requireConnection(), 'imap'); + } + + private function readTaggedResponse(string $tag): ImapResponse + { + $lines = []; + $literals = []; + $status = 'NO'; + + while (true) { + $line = $this->readLine(); + $trimmed = rtrim($line, "\r\n"); + $lines[] = $trimmed; + + $literalSize = $this->parseLiteralSize($trimmed); + if ($literalSize !== null) { + $literals[] = $this->readExact($literalSize); + } + + if (preg_match('/^' . preg_quote($tag, '/') . '\s+(OK|NO|BAD)\b/i', $trimmed, $matches) === 1) { + $status = strtoupper($matches[1]); + + break; + } + } + + return new ImapResponse($tag, $status, $lines, $literals); + } + + private function refreshCapabilities(): void + { + $response = $this->runCommand('CAPABILITY'); + $this->capabilities = $this->responseParser->capabilities($response); + } + + /** + * @return resource + */ + private function requireConnection() + { + if (!is_resource($this->connection)) { + $this->connect(); + } + + if (!is_resource($this->connection)) { + throw new MailboxConnectionException('IMAP connection is not available.'); + } + + return $this->connection; + } + + private function runCommand(string $command): ImapResponse + { + $this->requireConnection(); + $start = SocketMailboxRuntime::dispatchStart('imap', $command, [ + 'host' => $this->config->host, + 'port' => $this->config->port, + ]); + + $imapCommand = new ImapCommand($this->nextTag(), $command); + $this->write($imapCommand->line() . "\r\n"); + + $response = $this->readTaggedResponse($imapCommand->tag); + SocketMailboxRuntime::dispatchFinish( + 'imap', + $start['command'], + $response->status, + $start['duration_ms'], + ['host' => $this->config->host, 'port' => $this->config->port], + ); + + return $response; + } + + private function runFolderCommand( + string $folder, + string $command, + string $stage, + bool $select = true, + ): ImapResponse { + MailboxFolderNameGuard::assertValid($folder); + if ($select) { + $this->selectFolder($folder); + } + + return $this->expectOk($this->runCommand($command), $stage); + } + + private function runUidFetch(string $folder, int $uid, string $commandPattern, string $stage): ImapResponse + { + MailboxUidGuard::assertValid($uid); + + return $this->runFolderCommand($folder, sprintf($commandPattern, $uid), $stage); + } + + private function runUnselectedFolderCommand(string $folder, string $verb, string $stage): ImapResponse + { + return $this->runFolderCommand( + $folder, + sprintf('%s %s', $verb, ImapStringEscaper::quote($folder)), + $stage, + select: false, + ); + } + + private function selectFolder(string $folder): void + { + if ($this->selectedFolder !== null && strcasecmp($this->selectedFolder, $folder) === 0) { + return; + } + + $response = $this->runCommand('SELECT ' . ImapStringEscaper::quote($folder)); + + if (!$response->isOk()) { + throw new MailboxProtocolException(sprintf('Unable to select IMAP folder "%s".', $folder)); + } + + $this->selectedFolder = $folder; + } + + /** + * @param callable(string):void $onEvent + * @param callable():bool $stop + */ + private function watchWithIdle(callable $onEvent, int $timeoutSeconds, callable $stop): void + { + $tag = $this->nextTag(); + $this->write($tag . " IDLE\r\n"); + $continuation = $this->readLine(); + if (!str_starts_with($continuation, '+')) { + throw new MailboxProtocolException(sprintf('IMAP IDLE was not accepted: %s', trim($continuation))); + } + + $deadline = time() + max(1, $timeoutSeconds); + $socket = $this->requireConnection(); + + while (time() < $deadline) { + if ($stop()) { + break; + } + + $read = [$socket]; + $write = []; + $except = []; + $ready = stream_select($read, $write, $except, 0, 250000); + if ($ready === false) { + throw new MailboxConnectionException('IMAP IDLE stream_select failed.'); + } + + if ($ready < 1) { + continue; + } + + $line = rtrim($this->readLine(), "\r\n"); + if (str_starts_with($line, '* ')) { + $onEvent($line); + } + } + + $this->write("DONE\r\n"); + $doneResponse = $this->readTaggedResponse($tag); + if (!$doneResponse->isOk()) { + throw new MailboxProtocolException('IMAP IDLE did not terminate cleanly.'); + } + } + + /** + * @param callable(string):void $onEvent + * @param callable():bool $stop + */ + private function watchWithNoopFallback(callable $onEvent, int $timeoutSeconds, callable $stop): void + { + $deadline = time() + max(1, $timeoutSeconds); + + while (time() < $deadline) { + if ($stop()) { + return; + } + + $response = $this->runCommand('NOOP'); + foreach ($response->lines as $line) { + if (str_starts_with($line, '* ')) { + $onEvent($line); + } + } + + usleep(250000); + } + } + + private function write(string $value): void + { + SocketMailboxRuntime::write($this->requireConnection(), $value, 'imap'); + } +} diff --git a/src/Email/Mailbox/ImapStringEscaper.php b/src/Email/Mailbox/ImapStringEscaper.php new file mode 100644 index 0000000..e0a302e --- /dev/null +++ b/src/Email/Mailbox/ImapStringEscaper.php @@ -0,0 +1,27 @@ +folderExists($candidate)) { + $target = $candidate; + + break; + } + } + } + + $target ??= 'Archive'; + MailboxFolderNameGuard::assertValid($target); + + if (!$this->folderExists($target)) { + $this->createFolder($target); + } + + $this->transport->move($sourceFolder, $uid, $target); + } + + /** + * @param array{archive?:string,trash?:string,all_mail?:string}|null $strategy + */ + public function archiveWithProviderStrategy(string $sourceFolder, int $uid, ?array $strategy = null): void + { + $strategy ??= []; + $target = $strategy['archive'] ?? $strategy['all_mail'] ?? null; + $this->archive($sourceFolder, $uid, $target); + } + + public function createFolder(string $name): void + { + MailboxFolderNameGuard::assertValid($name); + $this->transport->createFolder($name); + } + + public function deleteFolder(string $name): void + { + MailboxFolderNameGuard::assertValid($name); + $this->transport->deleteFolder($name); + } + + public function folder(string $name): MailboxFolder + { + MailboxFolderNameGuard::assertValid($name); + + return new MailboxFolder($name, $this->transport, $this->parser); + } + + /** + * @return list + */ + public function folderDetails(): array + { + return $this->transport->folderDetails(); + } + + public function folderExists(string $name): bool + { + MailboxFolderNameGuard::assertValid($name); + + return $this->transport->folderExists($name); + } + + /** + * @return list + */ + public function folders(): array + { + return $this->transport->folders(); + } + + public function noop(): void + { + $this->transport->noop(); + } + + public function renameFolder(string $name, string $targetName): void + { + MailboxFolderNameGuard::assertValid($name); + MailboxFolderNameGuard::assertValid($targetName); + $this->transport->renameFolder($name, $targetName); + } + + public function status(string $name): MailboxStatus + { + MailboxFolderNameGuard::assertValid($name); + + return $this->transport->status($name); + } + + public function subscribeFolder(string $name): void + { + MailboxFolderNameGuard::assertValid($name); + $this->transport->subscribeFolder($name); + } + + public function transport(): MailboxTransport + { + return $this->transport; + } + + public function unsubscribeFolder(string $name): void + { + MailboxFolderNameGuard::assertValid($name); + $this->transport->unsubscribeFolder($name); + } + + /** + * @param callable(string):void $onEvent + * @param null|callable():bool $shouldStop + */ + public function watch(string $folder, callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void + { + MailboxFolderNameGuard::assertValid($folder); + $this->folder($folder)->watch($onEvent, $timeoutSeconds, $shouldStop); + } +} diff --git a/src/Email/Mailbox/MailboxCommandRedactor.php b/src/Email/Mailbox/MailboxCommandRedactor.php new file mode 100644 index 0000000..9340cd3 --- /dev/null +++ b/src/Email/Mailbox/MailboxCommandRedactor.php @@ -0,0 +1,57 @@ + 64) { + throw new InvalidArgumentException('Mailbox flag exceeds maximum length of 64 bytes.'); + } + + if (str_starts_with($trimmed, '\\')) { + if (preg_match('/^\\\\[A-Za-z][A-Za-z0-9._-]*$/', $trimmed) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid system mailbox flag: %s', $flag)); + } + + return; + } + + if (preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $trimmed) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid custom mailbox keyword flag: %s', $flag)); + } + } +} diff --git a/src/Email/Mailbox/MailboxFolder.php b/src/Email/Mailbox/MailboxFolder.php new file mode 100644 index 0000000..502d091 --- /dev/null +++ b/src/Email/Mailbox/MailboxFolder.php @@ -0,0 +1,394 @@ +transport->addFlag($this->name, $uid, $flag); + } + + /** + * @param list $uids + */ + public function addFlagMany(array $uids, string $flag): void + { + foreach ($uids as $uid) { + $this->addFlag($uid, $flag); + } + } + + public function close(): void + { + $this->transport->close($this->name); + } + + public function copy(int $uid, string $targetFolder): void + { + MailboxUidGuard::assertValid($uid); + MailboxFolderNameGuard::assertValid($targetFolder); + $this->transport->copy($this->name, $uid, $targetFolder); + } + + /** + * @param list $uids + */ + public function copyMany(array $uids, string $targetFolder): void + { + foreach ($uids as $uid) { + $this->copy($uid, $targetFolder); + } + } + + public function delete(int $uid): void + { + MailboxUidGuard::assertValid($uid); + $this->transport->delete($this->name, $uid); + } + + /** + * @param list $uids + */ + public function deleteMany(array $uids): void + { + foreach ($uids as $uid) { + $this->delete($uid); + } + } + + public function expunge(): void + { + $this->transport->expunge($this->name); + } + + /** + * @return list + */ + public function fetchAttachments(int $uid): array + { + MailboxUidGuard::assertValid($uid); + if ($this->transport instanceof BodyStructureMailboxTransport) { + $structure = $this->transport->bodyStructure($this->name, $uid); + if ($structure !== '' && !str_contains(strtoupper($structure), '"ATTACHMENT"') && !str_contains(strtoupper($structure), '"INLINE"')) { + return []; + } + } + + if (!$this->transport instanceof RawPartMailboxTransport) { + return $this->fetchParsed($uid)->attachments; + } + + $resolverFactory = function (ParsedEmailPart $part) use ($uid) { + if ($part->partNumber === null || $part->partNumber === '') { + return new InMemoryAttachmentContentResolver($part->body); + } + + $encoding = $part->headers['content-transfer-encoding'][0] ?? null; + + return new ImapPartAttachmentContentResolver( + $part->partNumber, + $encoding, + function () use ($uid, $part): string { + /** @var string $content */ + $content = $this->transport->rawPart($this->name, $uid, $part->partNumber); + + return $content; + }, + ); + }; + + $parser = new RawEmailParser( + headerParser: new MessageHeaderParser(), + mimeParser: new MimeParser(new MimePartParser()), + attachmentExtractor: new AttachmentExtractor($resolverFactory), + limits: $this->limits, + ); + + return $parser->parse($this->fetchRaw($uid), [ + 'source' => 'imap', + 'folder' => $this->name, + 'uid' => $uid, + ])->attachments; + } + + public function fetchBodyStructure(int $uid): string + { + MailboxUidGuard::assertValid($uid); + if (!$this->transport instanceof BodyStructureMailboxTransport) { + return ''; + } + + return $this->transport->bodyStructure($this->name, $uid); + } + + /** + * @return array> + */ + public function fetchHeaders(int $uid): array + { + MailboxUidGuard::assertValid($uid); + if ($this->transport instanceof RawHeadersMailboxTransport) { + return $this->transport->rawHeaders($this->name, $uid); + } + + return MailboxRawHeaderMap::fromRawMessage($this->fetchRaw($uid)); + } + + public function fetchParsed(int $uid): ParsedEmail + { + MailboxUidGuard::assertValid($uid); + $raw = $this->fetchRaw($uid); + + return $this->parser->parse($raw, [ + 'source' => 'imap', + 'folder' => $this->name, + 'uid' => $uid, + ]); + } + + public function fetchRaw(int $uid): string + { + MailboxUidGuard::assertValid($uid); + $raw = $this->transport->rawMessage($this->name, $uid); + if (strlen($raw) > $this->limits->maxMessageBytes) { + throw new MailboxProtocolException(sprintf( + 'Mailbox message size exceeds limit (%d bytes) for UID %d.', + $this->limits->maxMessageBytes, + $uid, + )); + } + + return $raw; + } + + public function fetchSummary(int $uid): MailboxMessageRef + { + MailboxUidGuard::assertValid($uid); + if ($this->transport instanceof EnvelopeSummaryMailboxTransport) { + return $this->transport->envelopeSummary($this->name, $uid); + } + + $parsed = $this->fetchParsed($uid); + + return new MailboxMessageRef( + uid: $uid, + subject: $parsed->subject, + date: $parsed->date, + from: $parsed->fromEmail(), + sizeBytes: strlen($parsed->raw), + messageId: $parsed->messageId, + ); + } + + public function markSeen(int $uid): void + { + MailboxUidGuard::assertValid($uid); + $this->transport->markSeen($this->name, $uid); + } + + /** + * @param list $uids + */ + public function markSeenMany(array $uids): void + { + foreach ($uids as $uid) { + $this->markSeen($uid); + } + } + + public function markUnread(int $uid): void + { + MailboxUidGuard::assertValid($uid); + $this->transport->markUnread($this->name, $uid); + } + + /** + * @param list $uids + */ + public function markUnreadMany(array $uids): void + { + foreach ($uids as $uid) { + $this->markUnread($uid); + } + } + + public function move(int $uid, string $targetFolder): void + { + MailboxUidGuard::assertValid($uid); + MailboxFolderNameGuard::assertValid($targetFolder); + $this->transport->move($this->name, $uid, $targetFolder); + } + + /** + * @param list $uids + */ + public function moveMany(array $uids, string $targetFolder): void + { + foreach ($uids as $uid) { + $this->move($uid, $targetFolder); + } + } + + /** + * @return list + */ + public function query(?MailboxSearch $search = null): array + { + $search ??= MailboxSearch::new(); + $uids = $this->transport->search($this->name, $search); + $this->assertQueryCostLimits($search, count($uids)); + $uids = $this->sortUids($uids, $search->sortOrder); + $refs = array_map(static fn(int $uid): MailboxMessageRef => new MailboxMessageRef($uid), $uids); + $refs = $this->sortRefsByDateIfNeeded($refs, $search); + + if ($search->limit !== null) { + return array_slice($refs, 0, $search->limit); + } + + return $refs; + } + + public function removeFlag(int $uid, string $flag): void + { + MailboxUidGuard::assertValid($uid); + $this->transport->removeFlag($this->name, $uid, $flag); + } + + /** + * @param list $uids + */ + public function removeFlagMany(array $uids, string $flag): void + { + foreach ($uids as $uid) { + $this->removeFlag($uid, $flag); + } + } + + public function status(): MailboxStatus + { + return $this->transport->status($this->name); + } + + /** + * @param callable(string):void $onEvent + * @param null|callable():bool $shouldStop + */ + public function watch(callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void + { + if (!$this->transport instanceof WatchableMailboxTransport) { + throw new \RuntimeException('Mailbox transport does not support watch/IDLE operations.'); + } + + $this->transport->watch($this->name, $onEvent, $timeoutSeconds, $shouldStop); + } + + private function assertQueryCostLimits(MailboxSearch $search, int $matchedCount): void + { + if ( + $search->requireExplicitLimitForExpensiveSearch + && $search->limit === null + && $this->isExpensiveSearch($search) + ) { + throw new MailboxProtocolException('Expensive mailbox search requires an explicit limit().'); + } + + if ($search->maxClientSideFilterFetches !== null && $matchedCount > $search->maxClientSideFilterFetches) { + throw new MailboxProtocolException(sprintf( + 'Mailbox search matched %d messages and exceeded maxClientSideFilterFetches=%d.', + $matchedCount, + $search->maxClientSideFilterFetches, + )); + } + } + + private function isExpensiveSearch(MailboxSearch $search): bool + { + if (in_array($search->sortOrder, ['date_desc', 'date_asc'], true)) { + return true; + } + + foreach ($search->criteria as $criterion) { + $normalized = strtoupper($criterion); + if (str_contains($normalized, 'CONTENT-DISPOSITION: ATTACHMENT')) { + return true; + } + } + + return false; + } + + /** + * @param list $refs + * @return list + */ + private function sortRefsByDateIfNeeded(array $refs, MailboxSearch $search): array + { + if (!in_array($search->sortOrder, ['date_desc', 'date_asc'], true)) { + return $refs; + } + + if ($search->maxSummaryFetches !== null && count($refs) > $search->maxSummaryFetches) { + throw new MailboxProtocolException(sprintf( + 'Date sorting requires %d summary fetches and exceeded maxSummaryFetches=%d.', + count($refs), + $search->maxSummaryFetches, + )); + } + + $refs = array_map(fn(MailboxMessageRef $ref): MailboxMessageRef => $this->fetchSummary($ref->uid), $refs); + usort($refs, static function (MailboxMessageRef $left, MailboxMessageRef $right) use ($search): int { + $leftTs = $left->date?->getTimestamp() ?? 0; + $rightTs = $right->date?->getTimestamp() ?? 0; + + return $search->sortOrder === 'date_desc' ? ($rightTs <=> $leftTs) : ($leftTs <=> $rightTs); + }); + + return $refs; + } + + /** + * @param list $uids + * @return list + */ + private function sortUids(array $uids, string $sortOrder): array + { + if ($sortOrder === 'uid_desc') { + rsort($uids); + + return $uids; + } + + if ($sortOrder === 'uid_asc') { + sort($uids); + } + + return $uids; + } +} diff --git a/src/Email/Mailbox/MailboxFolderInfo.php b/src/Email/Mailbox/MailboxFolderInfo.php new file mode 100644 index 0000000..3d62f57 --- /dev/null +++ b/src/Email/Mailbox/MailboxFolderInfo.php @@ -0,0 +1,33 @@ + $attributes + */ + public function __construct( + public string $name, + public string $path, + public ?string $delimiter, + public array $attributes = [], + ) {} + + public function hasChildren(): bool + { + return in_array('\\HASCHILDREN', $this->attributes, true); + } + + public function isSelectable(): bool + { + return !in_array('\\NOSELECT', $this->attributes, true); + } + + public function noInferiors(): bool + { + return in_array('\\NOINFERIORS', $this->attributes, true); + } +} diff --git a/src/Email/Mailbox/MailboxFolderNameGuard.php b/src/Email/Mailbox/MailboxFolderNameGuard.php new file mode 100644 index 0000000..0481a3f --- /dev/null +++ b/src/Email/Mailbox/MailboxFolderNameGuard.php @@ -0,0 +1,26 @@ + 255) { + throw new InvalidArgumentException('Mailbox folder name exceeds maximum length of 255 bytes.'); + } + } +} diff --git a/src/Email/Mailbox/MailboxMessageRef.php b/src/Email/Mailbox/MailboxMessageRef.php new file mode 100644 index 0000000..6637d6d --- /dev/null +++ b/src/Email/Mailbox/MailboxMessageRef.php @@ -0,0 +1,25 @@ +|null $flags + */ + public function __construct( + public int $uid, + public ?string $subject = null, + public ?DateTimeImmutable $date = null, + public ?string $from = null, + public ?array $flags = null, + public ?int $sizeBytes = null, + public ?string $messageId = null, + public ?int $sequence = null, + public ?string $externalId = null, + ) {} +} diff --git a/src/Email/Mailbox/MailboxRawHeaderMap.php b/src/Email/Mailbox/MailboxRawHeaderMap.php new file mode 100644 index 0000000..25521e2 --- /dev/null +++ b/src/Email/Mailbox/MailboxRawHeaderMap.php @@ -0,0 +1,21 @@ +> + */ + public static function fromRawMessage(string $raw): array + { + $normalized = str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $raw)); + [$headerBlock] = explode("\r\n\r\n", $normalized, 2); + + return new HeaderParser()->parse($headerBlock)->asMap(); + } +} diff --git a/src/Email/Mailbox/MailboxSearch.php b/src/Email/Mailbox/MailboxSearch.php new file mode 100644 index 0000000..5d4719c --- /dev/null +++ b/src/Email/Mailbox/MailboxSearch.php @@ -0,0 +1,254 @@ + $criteria + */ + private function __construct( + public array $criteria = [], + public ?int $limit = null, + public string $sortOrder = 'uid_asc', + public ?int $maxSummaryFetches = null, + public ?int $maxClientSideFilterFetches = null, + public bool $requireExplicitLimitForExpensiveSearch = false, + ) {} + + public static function new(): self + { + return new self(); + } + + public function all(): self + { + return $this->append('ALL'); + } + + public function answered(): self + { + return $this->append('ANSWERED'); + } + + public function bcc(string $value): self + { + return $this->append('BCC ' . ImapStringEscaper::quote($value)); + } + + public function before(DateTimeInterface $date): self + { + return $this->append('BEFORE ' . $date->format('d-M-Y')); + } + + public function bodyContains(string $value): self + { + return $this->append('BODY ' . ImapStringEscaper::quote($value)); + } + + public function cc(string $value): self + { + return $this->append('CC ' . ImapStringEscaper::quote($value)); + } + + public function deleted(): self + { + return $this->append('DELETED'); + } + + public function flagged(): self + { + return $this->append('FLAGGED'); + } + + public function from(string $value): self + { + return $this->append('FROM ' . ImapStringEscaper::quote($value)); + } + + public function hasAttachment(): self + { + return $this->append('BODY "Content-Disposition: attachment"'); + } + + public function keyword(string $value): self + { + return $this->append('KEYWORD ' . ImapStringEscaper::quote($value)); + } + + public function largerThan(int $bytes): self + { + if ($bytes < 1) { + return $this; + } + + return $this->append('LARGER ' . $bytes); + } + + public function limit(int $value): self + { + return $this->recreate(limit: $value > 0 ? $value : null); + } + + public function maxClientSideFilterFetches(int $value): self + { + return $this->recreate(maxClientSideFilterFetches: $value > 0 ? $value : null); + } + + public function maxSummaryFetches(int $value): self + { + return $this->recreate(maxSummaryFetches: $value > 0 ? $value : null); + } + + public function newestFirst(): self + { + return $this->recreate(sortOrder: 'uid_desc'); + } + + public function oldestFirst(): self + { + return $this->recreate(sortOrder: 'uid_asc'); + } + + public function on(DateTimeInterface $date): self + { + return $this->append('ON ' . $date->format('d-M-Y')); + } + + public function requireExplicitLimitForExpensiveSearch(bool $enabled = true): self + { + return $this->recreate(requireExplicitLimitForExpensiveSearch: $enabled); + } + + public function seen(): self + { + return $this->append('SEEN'); + } + + public function since(DateTimeInterface $date): self + { + return $this->append('SINCE ' . $date->format('d-M-Y')); + } + + public function smallerThan(int $bytes): self + { + if ($bytes < 1) { + return $this; + } + + return $this->append('SMALLER ' . $bytes); + } + + public function sortByDateAsc(): self + { + return $this->recreate(sortOrder: 'date_asc'); + } + + public function sortByDateDesc(): self + { + return $this->recreate(sortOrder: 'date_desc'); + } + + public function subjectContains(string $value): self + { + return $this->append('SUBJECT ' . ImapStringEscaper::quote($value)); + } + + public function textContains(string $value): self + { + return $this->append('TEXT ' . ImapStringEscaper::quote($value)); + } + + public function to(string $value): self + { + return $this->append('TO ' . ImapStringEscaper::quote($value)); + } + + public function toCriteriaString(): string + { + if ($this->criteria === []) { + return 'ALL'; + } + + return implode(' ', $this->criteria); + } + + public function uidAsc(): self + { + return $this->recreate(sortOrder: 'uid_asc'); + } + + public function uidDesc(): self + { + return $this->recreate(sortOrder: 'uid_desc'); + } + + public function uidRange(int $start, int $end): self + { + if ($start < 1 || $end < 1) { + return $this; + } + + $range = $start <= $end ? sprintf('%d:%d', $start, $end) : sprintf('%d:%d', $end, $start); + + return $this->append('UID ' . $range); + } + + public function unanswered(): self + { + return $this->append('UNANSWERED'); + } + + public function undeleted(): self + { + return $this->append('UNDELETED'); + } + + public function unflagged(): self + { + return $this->append('UNFLAGGED'); + } + + public function unkeyword(string $value): self + { + return $this->append('UNKEYWORD ' . ImapStringEscaper::quote($value)); + } + + public function unseen(): self + { + return $this->append('UNSEEN'); + } + + private function append(string $criterion): self + { + $criteria = $this->criteria; + $criteria[] = $criterion; + + return $this->recreate(criteria: $criteria); + } + + /** + * @param list|null $criteria + */ + private function recreate( + ?array $criteria = null, + ?int $limit = null, + ?string $sortOrder = null, + ?int $maxSummaryFetches = null, + ?int $maxClientSideFilterFetches = null, + ?bool $requireExplicitLimitForExpensiveSearch = null, + ): self { + return new self( + $criteria ?? $this->criteria, + $limit ?? $this->limit, + $sortOrder ?? $this->sortOrder, + $maxSummaryFetches ?? $this->maxSummaryFetches, + $maxClientSideFilterFetches ?? $this->maxClientSideFilterFetches, + $requireExplicitLimitForExpensiveSearch ?? $this->requireExplicitLimitForExpensiveSearch, + ); + } +} diff --git a/src/Email/Mailbox/MailboxStatus.php b/src/Email/Mailbox/MailboxStatus.php new file mode 100644 index 0000000..31ac239 --- /dev/null +++ b/src/Email/Mailbox/MailboxStatus.php @@ -0,0 +1,16 @@ + + */ + public function folderDetails(): array; + + public function folderExists(string $folder): bool; + + /** + * @return list + */ + public function folders(): array; + + public function logout(): void; + + public function markSeen(string $folder, int $uid): void; + + public function markUnread(string $folder, int $uid): void; + + public function move(string $folder, int $uid, string $targetFolder): void; + + public function noop(): void; + + public function rawMessage(string $folder, int $uid): string; + + public function removeFlag(string $folder, int $uid, string $flag): void; + + public function renameFolder(string $folder, string $targetFolder): void; + + /** + * @return list + */ + public function search(string $folder, MailboxSearch $search): array; + + public function status(string $folder): MailboxStatus; + + public function subscribeFolder(string $folder): void; + + public function unsubscribeFolder(string $folder): void; +} diff --git a/src/Email/Mailbox/MailboxUidGuard.php b/src/Email/Mailbox/MailboxUidGuard.php new file mode 100644 index 0000000..81d2f0c --- /dev/null +++ b/src/Email/Mailbox/MailboxUidGuard.php @@ -0,0 +1,17 @@ +transport->delete($messageNumber); + } + + public function fetchParsed(int $messageNumber): ParsedEmail + { + Pop3MessageNumberGuard::assertValid($messageNumber); + $raw = $this->fetchRaw($messageNumber); + + return $this->parser->parse($raw, [ + 'source' => 'pop3', + 'folder' => 'INBOX', + 'uid' => $messageNumber, + ]); + } + + public function fetchRaw(int $messageNumber): string + { + Pop3MessageNumberGuard::assertValid($messageNumber); + + return $this->transport->rawMessage($messageNumber); + } + + /** + * @return list + */ + public function listMessageRefs(?int $limit = null): array + { + $sizes = $this->transport->list(); + $uidls = $this->transport->uidl(); + + $refs = []; + foreach ($sizes as $messageNumber => $size) { + $refs[] = new MailboxMessageRef( + uid: $messageNumber, + sizeBytes: $size, + externalId: $uidls[$messageNumber] ?? null, + ); + } + + if ($limit !== null && $limit > 0) { + return array_slice($refs, 0, $limit); + } + + return $refs; + } + + public function logout(): void + { + $this->transport->logout(); + } + + public function receiveNewest(): ?ParsedEmail + { + $uids = $this->transport->search(MailboxSearch::new()->all()); + if ($uids === []) { + return null; + } + + rsort($uids); + + return $this->fetchParsed($uids[0]); + } + + public function receiveOldest(): ?ParsedEmail + { + $uids = $this->transport->search(MailboxSearch::new()->all()->limit(1)); + if ($uids === []) { + return null; + } + + return $this->fetchParsed($uids[0]); + } + + public function reset(): void + { + $this->transport->reset(); + } + + public function status(): MailboxStatus + { + return $this->transport->status(); + } + + public function transport(): Pop3Transport + { + return $this->transport; + } +} diff --git a/src/Email/Mailbox/Pop3MessageNumberGuard.php b/src/Email/Mailbox/Pop3MessageNumberGuard.php new file mode 100644 index 0000000..3722efa --- /dev/null +++ b/src/Email/Mailbox/Pop3MessageNumberGuard.php @@ -0,0 +1,17 @@ + + */ + private array $capabilities = []; + + /** + * @var resource|null + */ + private mixed $connection = null; + + public function __construct(private readonly Pop3Config $config) {} + + public function __destruct() + { + $this->logout(); + } + + /** + * @return list + */ + public function capabilities(): array + { + $this->connect(); + + return $this->capabilities; + } + + public function connect(): void + { + if (is_resource($this->connection)) { + return; + } + $this->connection = SocketMailboxRuntime::connect( + $this->config->host, + $this->config->port, + $this->config->timeoutSeconds, + 'POP3', + $this->config->security === Pop3Security::Ssl, + ); + + $greeting = $this->readLine(); + if (!$this->isOkResponse($greeting)) { + throw new MailboxProtocolException(sprintf('Unexpected POP3 greeting: %s', trim($greeting))); + } + + $this->refreshCapabilities(); + $this->negotiateStartTls(); + $this->login(); + } + + public function delete(int $messageNumber): void + { + Pop3MessageNumberGuard::assertValid($messageNumber); + $response = $this->runSingleCommand(sprintf('DELE %d', $messageNumber)); + $this->expectOk($response, 'DELE'); + } + + /** + * @return array + */ + public function list(): array + { + $status = $this->runSingleCommand('LIST'); + $this->expectOk($status, 'LIST'); + + $result = []; + foreach ($this->readMultilineResponse() as $line) { + if (preg_match('/^(\d+)\s+(\d+)$/', $line, $matches) !== 1) { + continue; + } + + $messageNumber = (int) $matches[1]; + $size = (int) $matches[2]; + if ($messageNumber < 1) { + continue; + } + + $result[$messageNumber] = $size; + } + + ksort($result); + + return $result; + } + + public function logout(): void + { + if (!is_resource($this->connection)) { + return; + } + + try { + $this->runSingleCommand('QUIT'); + } catch (\Throwable) { + // Best effort for shutdown. + } + + $this->closeConnection(); + } + + public function noop(): void + { + $response = $this->runSingleCommand('NOOP'); + $this->expectOk($response, 'NOOP'); + } + + public function rawMessage(int $messageNumber): string + { + Pop3MessageNumberGuard::assertValid($messageNumber); + $status = $this->runSingleCommand(sprintf('RETR %d', $messageNumber)); + $this->expectOk($status, 'RETR'); + $lines = $this->readMultilineResponse(); + + return implode("\r\n", $lines); + } + + public function reset(): void + { + $response = $this->runSingleCommand('RSET'); + $this->expectOk($response, 'RSET'); + } + + /** + * @return list + */ + public function search(MailboxSearch $search): array + { + $criteria = strtoupper($search->toCriteriaString()); + if ($criteria !== 'ALL') { + throw new MailboxProtocolException('POP3 search only supports ALL criteria.'); + } + $items = $this->list(); + + $uids = []; + foreach ($items as $messageNumber => $_size) { + $uids[] = $messageNumber; + } + + sort($uids); + + if ($search->limit !== null) { + return array_slice($uids, 0, $search->limit); + } + + return $uids; + } + + public function status(): MailboxStatus + { + $response = $this->runSingleCommand('STAT'); + $this->expectOk($response, 'STAT'); + + if (preg_match('/^\+OK\s+(\d+)\s+\d+/i', $response, $matches) !== 1) { + throw new MailboxProtocolException(sprintf('Unexpected POP3 STAT response: %s', trim($response))); + } + + $messages = (int) $matches[1]; + + return new MailboxStatus($messages, 0, 0); + } + + /** + * @return array + */ + public function uidl(): array + { + if (!$this->hasCapability('UIDL')) { + return []; + } + + $status = $this->runSingleCommand('UIDL'); + $this->expectOk($status, 'UIDL'); + + $result = []; + foreach ($this->readMultilineResponse() as $line) { + if (preg_match('/^(\d+)\s+(\S+)$/', $line, $matches) !== 1) { + continue; + } + + $messageNumber = (int) $matches[1]; + if ($messageNumber < 1) { + continue; + } + + $result[$messageNumber] = $matches[2]; + } + + ksort($result); + + return $result; + } + + /** + * @param callable(string):void $onEvent + * @param null|callable():bool $shouldStop + */ + public function watch(callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void + { + $stop = $shouldStop ?? static fn(): bool => false; + $deadline = time() + max(1, $timeoutSeconds); + + while (time() < $deadline && !$stop()) { + $status = $this->status(); + $onEvent(sprintf('+OK %d messages', $status->messages)); + usleep(250000); + } + } + + private function closeConnection(): void + { + if (is_resource($this->connection)) { + fclose($this->connection); + } + + $this->connection = null; + $this->capabilities = []; + } + + /** + * @template T of \Throwable + * + * @param class-string $exceptionClass + */ + private function expectOk(string $response, string $stage, string $exceptionClass = MailboxProtocolException::class): string + { + if ($this->isOkResponse($response)) { + return $response; + } + + throw new $exceptionClass(sprintf('POP3 %s failed: %s', $stage, trim($response))); + } + + private function hasCapability(string $name): bool + { + return in_array(strtoupper($name), $this->capabilities, true); + } + + private function isOkResponse(string $response): bool + { + return str_starts_with(strtoupper($response), '+OK'); + } + + private function login(): void + { + foreach ([ + 'USER' => $this->config->username, + 'PASS' => $this->config->password, + ] as $verb => $value) { + $response = $this->runSingleCommand(sprintf('%s %s', $verb, $value)); + $this->expectOk($response, $verb, MailboxAuthenticationException::class); + } + } + + private function negotiateStartTls(): void + { + $mustUseTls = match ($this->config->security) { + Pop3Security::StartTlsRequired => true, + Pop3Security::StartTlsOptional => false, + default => null, + }; + if ($mustUseTls === null) { + return; + } + + if (!SocketMailboxRuntime::shouldStartTls($mustUseTls, $this->hasCapability('STLS'), 'pop3')) { + return; + } + + $response = $this->runSingleCommand('STLS'); + $this->expectOk($response, 'STLS', MailboxConnectionException::class); + + SocketMailboxRuntime::enableTls($this->requireConnection(), 'pop3'); + + $this->refreshCapabilities(); + } + + private function readLine(): string + { + return SocketMailboxRuntime::readLine($this->requireConnection(), 'pop3'); + } + + /** + * @return list + */ + private function readMultilineResponse(): array + { + $lines = []; + + while (true) { + $line = $this->readLine(); + $trimmed = rtrim($line, "\r\n"); + + if ($trimmed === '.') { + break; + } + + if (str_starts_with($trimmed, '..')) { + $trimmed = substr($trimmed, 1); + } + + $lines[] = $trimmed; + } + + return $lines; + } + + private function refreshCapabilities(): void + { + $this->write("CAPA\r\n"); + $status = $this->readLine(); + + if (!$this->isOkResponse($status)) { + $this->capabilities = []; + + return; + } + + $capabilities = []; + foreach ($this->readMultilineResponse() as $line) { + $parts = preg_split('/\s+/', trim($line)) ?: []; + if ($parts === []) { + continue; + } + + $capabilities[] = strtoupper($parts[0]); + } + + $this->capabilities = $capabilities; + } + + /** + * @return resource + */ + private function requireConnection() + { + if (!is_resource($this->connection)) { + $this->connect(); + } + + if (!is_resource($this->connection)) { + throw new MailboxConnectionException('POP3 connection is not available.'); + } + + return $this->connection; + } + + private function runSingleCommand(string $command): string + { + $start = SocketMailboxRuntime::dispatchStart('pop3', $command, [ + 'host' => $this->config->host, + 'port' => $this->config->port, + ]); + $this->write($command . "\r\n"); + $response = $this->readLine(); + SocketMailboxRuntime::dispatchFinish( + 'pop3', + $start['command'], + trim($response), + $start['duration_ms'], + ['host' => $this->config->host, 'port' => $this->config->port], + ); + + return $response; + } + + private function write(string $value): void + { + SocketMailboxRuntime::write($this->requireConnection(), $value, 'pop3'); + } +} diff --git a/src/Email/Mailbox/Pop3Transport.php b/src/Email/Mailbox/Pop3Transport.php new file mode 100644 index 0000000..4f2b08f --- /dev/null +++ b/src/Email/Mailbox/Pop3Transport.php @@ -0,0 +1,48 @@ + + */ + public function capabilities(): array; + + public function connect(): void; + + public function delete(int $messageNumber): void; + + /** + * @return array + */ + public function list(): array; + + public function logout(): void; + + public function noop(): void; + + public function rawMessage(int $messageNumber): string; + + public function reset(): void; + + /** + * @return list + */ + public function search(MailboxSearch $search): array; + + public function status(): MailboxStatus; + + /** + * @return array + */ + public function uidl(): array; + + /** + * @param callable(string):void $onEvent + * @param null|callable():bool $shouldStop + */ + public function watch(callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void; +} diff --git a/src/Email/Mailbox/RawHeadersMailboxTransport.php b/src/Email/Mailbox/RawHeadersMailboxTransport.php new file mode 100644 index 0000000..5057402 --- /dev/null +++ b/src/Email/Mailbox/RawHeadersMailboxTransport.php @@ -0,0 +1,13 @@ +> + */ + public function rawHeaders(string $folder, int $uid): array; +} diff --git a/src/Email/Mailbox/RawPartMailboxTransport.php b/src/Email/Mailbox/RawPartMailboxTransport.php new file mode 100644 index 0000000..c7083b1 --- /dev/null +++ b/src/Email/Mailbox/RawPartMailboxTransport.php @@ -0,0 +1,10 @@ + $protocol, + 'host' => $endpoint['host'], + 'port' => $endpoint['port'], + 'command' => $command, + 'status' => $status, + 'duration_ms' => (int) round((microtime(true) * 1000) - $startedAtMs), + ]); + } + + /** + * @param array{host:string,port:int} $endpoint + * @return array{command:string,duration_ms:int} + */ + public static function dispatchStart(string $protocol, string $command, array $endpoint): array + { + $redactedCommand = MailboxCommandRedactor::redact($protocol, $command); + EmailEventBus::dispatch('mailbox.command.start', [ + 'protocol' => $protocol, + 'host' => $endpoint['host'], + 'port' => $endpoint['port'], + 'command' => $redactedCommand, + ]); + + return [ + 'command' => $redactedCommand, + 'duration_ms' => (int) round(microtime(true) * 1000), + ]; + } + + /** + * @param resource $connection + */ + public static function enableTls(mixed $connection, string $protocol): void + { + if (!stream_socket_enable_crypto($connection, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new MailboxConnectionException(sprintf('Unable to enable TLS on %s socket.', strtoupper($protocol))); + } + } + + /** + * @param resource $connection + * @param positive-int $maxLength + */ + public static function readLine(mixed $connection, string $protocol, int $maxLength = 8192): string + { + $line = fgets($connection, max(1, $maxLength)); + if ($line === false) { + /** @var array $meta */ + $meta = stream_get_meta_data($connection); + if (($meta['timed_out'] ?? false) === true) { + throw new MailboxConnectionException(sprintf('%s server response timed out.', strtoupper($protocol))); + } + + throw new MailboxConnectionException(sprintf('Failed to read from %s socket.', strtoupper($protocol))); + } + + return $line; + } + + public static function shouldStartTls(bool $required, bool $supported, string $protocol): bool + { + if ($supported) { + return true; + } + + if ($required) { + throw new MailboxConnectionException(sprintf('%s STARTTLS is required but not supported by server.', strtoupper($protocol))); + } + + return false; + } + + /** + * @param resource $connection + */ + public static function write(mixed $connection, string $value, string $protocol): void + { + $remaining = $value; + while ($remaining !== '') { + $written = fwrite($connection, $remaining); + if ($written === false || $written === 0) { + throw new MailboxConnectionException(sprintf('Failed writing to %s socket.', strtoupper($protocol))); + } + + $remaining = substr($remaining, $written); + } + } +} diff --git a/src/Email/Mailbox/WatchableMailboxTransport.php b/src/Email/Mailbox/WatchableMailboxTransport.php new file mode 100644 index 0000000..d8aa528 --- /dev/null +++ b/src/Email/Mailbox/WatchableMailboxTransport.php @@ -0,0 +1,14 @@ +parseWithImap($headerValue); + if ($imapAddresses !== null) { + return new EmailAddressList($imapAddresses); + } + + return new EmailAddressList($this->parseWithoutImap($headerValue)); + } + + private function fallbackAddress(string $raw): InboundEmailAddress + { + if (preg_match('/([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})/i', $raw, $match) === 1) { + return new InboundEmailAddress($match[1], null, $raw); + } + + return new InboundEmailAddress(null, null, $raw); + } + + private function parseMailbox(object $mailbox, string $rawHeader): InboundEmailAddress + { + $mailboxName = $this->readObjectString($mailbox, 'mailbox'); + $hostName = $this->readObjectString($mailbox, 'host'); + + $email = null; + if ($mailboxName !== null && $hostName !== null && $mailboxName !== '' && $hostName !== '' && $hostName !== '.SYNTAX-ERROR.') { + $email = sprintf('%s@%s', $mailboxName, $hostName); + } + + $personal = $this->readObjectString($mailbox, 'personal'); + $raw = $rawHeader; + + return new InboundEmailAddress($email, $personal, $raw); + } + + /** + * @return list|null + */ + private function parseWithImap(string $headerValue): ?array + { + if (!function_exists('imap_rfc822_parse_adrlist')) { + return null; + } + + $mailboxes = imap_rfc822_parse_adrlist($headerValue, ''); + if ($mailboxes === []) { + return null; + } + + $addresses = []; + + foreach ($mailboxes as $mailbox) { + if (!is_object($mailbox)) { + continue; + } + + $addresses[] = $this->parseMailbox($mailbox, $headerValue); + } + + if ($addresses === []) { + return null; + } + + return $addresses; + } + + /** + * @return list + */ + private function parseWithoutImap(string $headerValue): array + { + $normalized = $this->stripGroups($headerValue); + $tokens = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $normalized) ?: []; + $addresses = []; + + foreach ($tokens as $token) { + $token = $this->stripComments(trim($token)); + if ($token === '') { + continue; + } + + if (preg_match('/^(?:"?([^"]*)"?\s*)?<([^>]+)>$/', $token, $matches) === 1) { + $name = trim($matches[1]); + $email = trim($matches[2]); + $addresses[] = new InboundEmailAddress( + $this->validEmailOrNull($email), + $name !== '' ? trim($name, '"') : null, + $token, + ); + + continue; + } + + $addresses[] = $this->fallbackAddress($token); + } + + return $addresses; + } + + private function readObjectString(object $mailbox, string $property): ?string + { + if (!property_exists($mailbox, $property)) { + return null; + } + + $value = $mailbox->{$property}; + + return is_string($value) ? $value : null; + } + + private function stripComments(string $value): string + { + return trim((string) preg_replace('/\s*\([^()]*\)\s*/', ' ', $value)); + } + + private function stripGroups(string $value): string + { + return (string) preg_replace('/[^:;,]+:\s*([^;]*);/m', '$1', $value); + } + + private function validEmailOrNull(string $value): ?string + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + return filter_var($normalized, FILTER_VALIDATE_EMAIL) !== false ? $normalized : null; + } +} diff --git a/src/Email/Parser/AttachmentExtractor.php b/src/Email/Parser/AttachmentExtractor.php new file mode 100644 index 0000000..95a8a35 --- /dev/null +++ b/src/Email/Parser/AttachmentExtractor.php @@ -0,0 +1,70 @@ + + */ + public function extract(ParsedEmailPart $root): array + { + $attachments = []; + $this->walk($root, $attachments); + + return $attachments; + } + + /** + * @param list $attachments + */ + private function walk(ParsedEmailPart $part, array &$attachments): void + { + if ($part->children !== []) { + foreach ($part->children as $child) { + $this->walk($child, $attachments); + } + + return; + } + + $hasFilename = $part->filename !== null && $part->filename !== ''; + $isInlineWithCid = $part->disposition === 'inline' && $part->contentId !== null; + $isCidNonBodyText = $part->contentId !== null + && !in_array($part->contentType, ['text/plain', 'text/html'], true); + $isAttachment = $part->disposition === 'attachment' + || $hasFilename + || $isInlineWithCid + || $isCidNonBodyText; + + if (!$isAttachment) { + return; + } + + $filename = $part->filename ?? 'attachment.bin'; + $resolver = $this->resolverFactory !== null + ? ($this->resolverFactory)($part) + : new InMemoryAttachmentContentResolver($part->body); + + $attachments[] = new ReceivedAttachment( + $filename, + $part->contentType, + strlen($part->body), + $part->contentId, + $part->inline, + $resolver, + ); + } +} diff --git a/src/Email/Parser/AuthenticationResultsParser.php b/src/Email/Parser/AuthenticationResultsParser.php new file mode 100644 index 0000000..c49c13e --- /dev/null +++ b/src/Email/Parser/AuthenticationResultsParser.php @@ -0,0 +1,79 @@ +parseCheckSegment($segment); + if ($parsed !== null) { + $checks[] = $parsed; + } + } + + return new AuthenticationResults($checks, $authservId !== '' ? $authservId : null, raw: $value); + } + + private function parseCheckSegment(string $segment): ?AuthenticationCheckResult + { + if ($segment === '' || !str_contains($segment, '=')) { + return null; + } + + [$method, $rest] = explode('=', $segment, 2); + $method = strtolower(trim($method)); + if ($method === '') { + return null; + } + + $tokens = preg_split('/\s+/', trim($rest)) ?: []; + $result = strtolower(array_shift($tokens) ?? 'none'); + $properties = $this->parseProperties($tokens); + + return new AuthenticationCheckResult( + $method, + $result, + $properties['header.d'] ?? null, + $properties['header.s'] ?? null, + $properties['smtp.mailfrom'] ?? null, + $properties, + ); + } + + /** + * @param list $tokens + * @return array + */ + private function parseProperties(array $tokens): array + { + $properties = []; + + foreach ($tokens as $token) { + if (!str_contains($token, '=')) { + continue; + } + + [$name, $tokenValue] = explode('=', $token, 2); + $properties[strtolower(trim($name))] = trim($tokenValue, '"'); + } + + return $properties; + } +} diff --git a/src/Email/Parser/BounceParser.php b/src/Email/Parser/BounceParser.php new file mode 100644 index 0000000..8f31dfb --- /dev/null +++ b/src/Email/Parser/BounceParser.php @@ -0,0 +1,233 @@ +parseMany($email); + + return $reports[0] ?? null; + } + + /** + * @return list + */ + public function parseMany(ParsedEmail $email): array + { + $deliveryStatuses = $this->deliveryStatusFromParts($email); + if ($deliveryStatuses !== []) { + $reports = []; + foreach ($deliveryStatuses as $deliveryStatus) { + $status = $deliveryStatus['status'] ?? null; + $diagnostic = $deliveryStatus['diagnostic_code'] ?? null; + + $reports[] = new BounceReport( + $this->classify($status, $diagnostic), + $deliveryStatus['final_recipient'] ?? ($deliveryStatus['original_recipient'] ?? null), + $deliveryStatus['action'] ?? null, + $status, + $diagnostic, + $deliveryStatus['remote_mta'] ?? ($deliveryStatus['reporting_mta'] ?? null), + $email->messageId, + [ + 'source' => 'delivery-status', + 'reporting_mta' => $deliveryStatus['reporting_mta'] ?? null, + 'last_attempt_date' => $deliveryStatus['last_attempt_date'] ?? null, + 'will_retry_until' => $deliveryStatus['will_retry_until'] ?? null, + ], + ); + } + + foreach ($reports as $report) { + $this->dispatchDetectedEvent($report); + } + + return $reports; + } + + if ($email->isBounceCandidate()) { + $text = strtolower(trim($email->textBody ?? '')); + if ($text === '' && trim($email->subjectOrEmpty()) === '') { + return []; + } + + $recipient = $this->extractRecipientFromText($text); + $status = $this->extractEnhancedStatusFromText($text); + $diagnostic = $this->extractDiagnosticFromText($text); + + $report = new BounceReport( + $this->classify($status, $diagnostic ?: $email->subjectOrEmpty()), + $recipient, + $status !== null && str_starts_with($status, '4.') ? 'delayed' : 'failed', + $status, + $diagnostic, + null, + $email->messageId, + ['source' => 'heuristic-text'], + ); + $this->dispatchDetectedEvent($report); + + return [$report]; + } + + return []; + } + + private function classify(?string $status, ?string $diagnostic): BounceType + { + $statusText = strtolower($status ?? ''); + $diagnosticText = strtolower($diagnostic ?? ''); + + $statusSpecific = $this->classifyBySpecificStatus($statusText); + if ($statusSpecific !== null) { + return $statusSpecific; + } + + $diagnosticClassification = $this->classifyByDiagnostic($diagnosticText); + if ($diagnosticClassification !== BounceType::Unknown) { + return $diagnosticClassification; + } + + $statusClassification = $this->classifyByGenericStatus($statusText); + if ($statusClassification !== null) { + return $statusClassification; + } + + return BounceType::Unknown; + } + + private function classifyByDiagnostic(string $diagnostic): BounceType + { + if (str_contains($diagnostic, 'mailbox full')) { + return BounceType::MailboxFull; + } + + if (str_contains($diagnostic, 'user unknown') || str_contains($diagnostic, 'no such user')) { + return BounceType::UserUnknown; + } + + if (str_contains($diagnostic, 'domain not found') || str_contains($diagnostic, 'host not found')) { + return BounceType::DomainNotFound; + } + + if (str_contains($diagnostic, 'spam') || str_contains($diagnostic, 'blocked') || str_contains($diagnostic, 'policy')) { + return BounceType::SpamRejected; + } + + if (str_contains($diagnostic, 'temporarily') || str_contains($diagnostic, 'try again later')) { + return BounceType::Temporary; + } + + return BounceType::Unknown; + } + + private function classifyByGenericStatus(string $status): ?BounceType + { + if (preg_match('/\b5\.[0-9]+\.[0-9]+\b/', $status) === 1) { + return BounceType::Hard; + } + + if (preg_match('/\b4\.[0-9]+\.[0-9]+\b/', $status) === 1) { + return BounceType::Soft; + } + + return null; + } + + private function classifyBySpecificStatus(string $status): ?BounceType + { + if (str_contains($status, '5.1.1')) { + return BounceType::UserUnknown; + } + + if (str_contains($status, '5.2.2') || str_contains($status, '4.2.2')) { + return BounceType::MailboxFull; + } + + if (str_contains($status, '5.1.2')) { + return BounceType::DomainNotFound; + } + + return null; + } + + /** + * @return list + */ + private function deliveryStatusFromParts(ParsedEmail $email): array + { + foreach ($email->parts as $part) { + if ($part->contentType !== 'message/delivery-status') { + continue; + } + + return $this->deliveryStatusParser->parseMany($part->body); + } + + return []; + } + + private function dispatchDetectedEvent(BounceReport $report): void + { + EmailEventBus::dispatch('bounce.detected', [ + 'type' => $report->type->value, + 'recipient' => $report->recipient, + 'status' => $report->status, + 'message_id' => $report->originalMessageId, + ]); + } + + private function extractDiagnosticFromText(string $text): ?string + { + if ($text === '') { + return null; + } + + foreach (['diagnostic-code:', 'reason:', 'error:'] as $label) { + if (preg_match('/' . preg_quote($label, '/') . '\s*(.+)$/mi', $text, $matches) === 1) { + return trim($matches[1]); + } + } + + return null; + } + + private function extractEnhancedStatusFromText(string $text): ?string + { + if (preg_match('/\b([245]\.[0-9]+\.[0-9]+)\b/', $text, $matches) !== 1) { + return null; + } + + return $matches[1]; + } + + private function extractRecipientFromText(string $text): ?string + { + if (preg_match('/[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,63}/i', $text, $matches) !== 1) { + return null; + } + + return strtolower($matches[0]); + } +} diff --git a/src/Email/Parser/CharsetDecoder.php b/src/Email/Parser/CharsetDecoder.php new file mode 100644 index 0000000..a287975 --- /dev/null +++ b/src/Email/Parser/CharsetDecoder.php @@ -0,0 +1,100 @@ +charsetCandidates($normalized); + if ($this->fallbackCharset !== null && trim($this->fallbackCharset) !== '') { + $fallback = strtoupper(trim($this->fallbackCharset)); + if (!in_array($fallback, $candidates, true)) { + $candidates[] = $fallback; + } + } + + foreach ($candidates as $candidate) { + $converted = $this->convertWithMb($value, $candidate); + if ($converted !== null) { + return $converted; + } + + $converted = $this->convertWithIconv($value, $candidate); + if ($converted !== null) { + return $converted; + } + } + + return $value; + } + + /** + * @return list + */ + private function charsetCandidates(string $charset): array + { + return match ($charset) { + 'US-ASCII', 'ASCII' => ['ASCII', 'US-ASCII'], + 'ISO-8859-1', 'LATIN1', 'ISO8859-1' => ['ISO-8859-1', 'LATIN1'], + 'ISO-8859-15', 'LATIN9', 'ISO8859-15' => ['ISO-8859-15', 'LATIN9'], + 'WINDOWS-1252', 'CP1252' => ['WINDOWS-1252', 'CP1252'], + 'CP850', 'IBM850' => ['CP850', 'IBM850'], + 'KOI8-R', 'KOI8R' => ['KOI8-R', 'KOI8R'], + 'GB18030' => ['GB18030', 'GBK'], + 'BIG5', 'BIG-5' => ['BIG5', 'BIG-5'], + 'SHIFT_JIS', 'SHIFT-JIS', 'SJIS' => ['SHIFT_JIS', 'SJIS'], + default => [$charset], + }; + } + + private function convertWithIconv(string $value, string $charset): ?string + { + if (!function_exists('iconv')) { + return null; + } + + // "X-*" vendor charset labels regularly trigger runtime warnings in iconv. + if (preg_match('/^X-/i', $charset) === 1) { + return null; + } + + $previous = set_error_handler(static fn(): bool => true); + + try { + $converted = iconv($charset, 'UTF-8//IGNORE', $value); + } finally { + if ($previous !== null) { + set_error_handler($previous); + } else { + restore_error_handler(); + } + } + + return is_string($converted) ? $converted : null; + } + + private function convertWithMb(string $value, string $charset): ?string + { + if (!function_exists('mb_convert_encoding')) { + return null; + } + + try { + $converted = mb_convert_encoding($value, 'UTF-8', $charset); + + return is_string($converted) ? $converted : null; + } catch (\ValueError) { + return null; + } + } +} diff --git a/src/Email/Parser/DeliveryStatusParser.php b/src/Email/Parser/DeliveryStatusParser.php new file mode 100644 index 0000000..7369ba7 --- /dev/null +++ b/src/Email/Parser/DeliveryStatusParser.php @@ -0,0 +1,181 @@ +parseMany($body); + if ($reports === []) { + return [ + 'final_recipient' => null, + 'original_recipient' => null, + 'action' => null, + 'status' => null, + 'diagnostic_code' => null, + 'remote_mta' => null, + 'reporting_mta' => null, + 'last_attempt_date' => null, + 'will_retry_until' => null, + ]; + } + + return $reports[0]; + } + + /** + * @return list + */ + public function parseMany(string $body): array + { + $normalized = str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $body)); + $blocks = preg_split("/\r\n\r\n+/", trim($normalized)) ?: []; + + $globalFields = []; + $recipientFields = []; + + foreach ($blocks as $block) { + $fields = $this->parseFieldBlock($block); + if ($fields === []) { + continue; + } + + if (array_key_exists('final-recipient', $fields)) { + $recipientFields[] = $fields; + + continue; + } + + $globalFields = array_merge($globalFields, $fields); + } + + if ($recipientFields === []) { + return [[ + 'final_recipient' => null, + 'original_recipient' => null, + 'action' => null, + 'status' => null, + 'diagnostic_code' => null, + 'remote_mta' => null, + 'reporting_mta' => $this->normalizeMta($globalFields['reporting-mta'] ?? null), + 'last_attempt_date' => null, + 'will_retry_until' => null, + ]]; + } + + $reports = []; + foreach ($recipientFields as $recipient) { + $reports[] = [ + 'final_recipient' => $this->normalizeRecipient($recipient['final-recipient']), + 'original_recipient' => $this->normalizeRecipient($recipient['original-recipient'] ?? null), + 'action' => $this->nullableString($recipient['action'] ?? null), + 'status' => $this->nullableString($recipient['status'] ?? null), + 'diagnostic_code' => $this->nullableString($recipient['diagnostic-code'] ?? null), + 'remote_mta' => $this->normalizeMta($recipient['remote-mta'] ?? null), + 'reporting_mta' => $this->normalizeMta($globalFields['reporting-mta'] ?? null), + 'last_attempt_date' => $this->nullableString($recipient['last-attempt-date'] ?? null), + 'will_retry_until' => $this->nullableString($recipient['will-retry-until'] ?? null), + ]; + } + + return $reports; + } + + private function normalizeMta(?string $value): ?string + { + return $this->normalizeSemicolonValue($value); + } + + private function normalizeRecipient(?string $value): ?string + { + return $this->normalizeSemicolonValue($value); + } + + private function normalizeSemicolonValue(?string $value): ?string + { + $value = $this->nullableString($value); + if ($value === null) { + return null; + } + + if (!str_contains($value, ';')) { + return $value; + } + + [, $value] = array_pad(explode(';', $value, 2), 2, ''); + + return $this->nullableString($value); + } + + private function nullableString(?string $value): ?string + { + if ($value === null) { + return null; + } + + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } + + /** + * @return array + */ + private function parseFieldBlock(string $block): array + { + $fields = []; + $currentKey = null; + + $lines = preg_split('/\r\n/', $block) ?: []; + + foreach ($lines as $line) { + if ($line === '') { + continue; + } + + if (($line[0] ?? '') === ' ' || ($line[0] ?? '') === "\t") { + if ($currentKey !== null && array_key_exists($currentKey, $fields)) { + $fields[$currentKey] .= ' ' . trim($line); + } + + continue; + } + + if (!str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $currentKey = strtolower(trim($name)); + $fields[$currentKey] = trim($value); + } + + return $fields; + } +} diff --git a/src/Email/Parser/EmailParser.php b/src/Email/Parser/EmailParser.php new file mode 100644 index 0000000..557d69c --- /dev/null +++ b/src/Email/Parser/EmailParser.php @@ -0,0 +1,15 @@ + $metadata + */ + public function parse(string $rawEmail, array $metadata = []): ParsedEmail; +} diff --git a/src/Email/Parser/HeaderParser.php b/src/Email/Parser/HeaderParser.php new file mode 100644 index 0000000..547e457 --- /dev/null +++ b/src/Email/Parser/HeaderParser.php @@ -0,0 +1,185 @@ +mode, [self::MODE_STRICT, self::MODE_TOLERANT], true)) { + throw new EmailParseException(sprintf('Invalid header parser mode: %s', $this->mode)); + } + } + + public function parse(string $headerBlock, ?string $mode = null): HeaderBag + { + $mode ??= $this->mode; + $lines = preg_split('/\r\n/', $this->normalizeLineEndings($headerBlock)) ?: []; + $unfolded = $this->unfold($lines, $mode); + $headers = []; + + foreach ($unfolded as $line) { + if ($line === '') { + continue; + } + + if (!str_contains($line, ':')) { + if ($mode === self::MODE_STRICT) { + throw new EmailParseException(sprintf('Malformed header line: %s', $line)); + } + + $headers['x-invalid-header'] ??= []; + $headers['x-invalid-header'][] = $line; + + continue; + } + + [$name, $value] = explode(':', $line, 2); + $normalizedName = strtolower(trim($name)); + + if (!array_key_exists($normalizedName, $headers)) { + $headers[$normalizedName] = []; + } + + $headers[$normalizedName][] = $this->decodeHeaderValue(trim($value)); + } + + return new HeaderBag($headers, $unfolded); + } + + public function parseDate(?string $headerValue): ?DateTimeImmutable + { + if ($headerValue === null || trim($headerValue) === '') { + return null; + } + + try { + return new DateTimeImmutable($headerValue); + } catch (\Exception) { + return null; + } + } + + /** + * @return list + */ + public function parseReferences(?string $headerValue): array + { + if ($headerValue === null || trim($headerValue) === '') { + return []; + } + + if (preg_match_all('/<[^>]+>/', $headerValue, $matches) > 0) { + return $matches[0]; + } + + $parts = preg_split('/\s+/', trim($headerValue)) ?: []; + $normalized = []; + + foreach ($parts as $part) { + if ($part === '') { + continue; + } + + $normalized[] = str_starts_with($part, '<') ? $part : sprintf('<%s>', trim($part, '<>')); + } + + return $normalized; + } + + private static function convertToUtf8(string $charset, string $value): string|false + { + set_error_handler( + static fn() => true, + ); + + try { + return iconv($charset, 'UTF-8//IGNORE', $value); + } finally { + restore_error_handler(); + } + } + + private function decodeEncodedWordsFallback(string $value): string + { + return (string) preg_replace_callback( + '/=\?([^?]+)\?([bqBQ])\?([^?]+)\?=/', + static function (array $matches): string { + $charset = strtoupper($matches[1]); + $encoding = strtoupper($matches[2]); + $payload = $matches[3]; + + $decoded = $encoding === 'B' + ? base64_decode($payload, true) + : quoted_printable_decode(str_replace('_', ' ', $payload)); + + if ($decoded === false) { + return $matches[0]; + } + + if ($charset === 'UTF-8' || $charset === 'US-ASCII') { + return $decoded; + } + + $converted = self::convertToUtf8($charset, $decoded); + + return is_string($converted) && $converted !== '' ? $converted : $decoded; + }, + $value, + ); + } + + private function decodeHeaderValue(string $value): string + { + $decoded = iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8'); + + if ($decoded !== false) { + return $decoded; + } + + return $this->decodeEncodedWordsFallback($value); + } + + private function normalizeLineEndings(string $value): string + { + return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $value)); + } + + /** + * @param list $lines + * @return list + */ + private function unfold(array $lines, string $mode): array + { + $unfolded = []; + + foreach ($lines as $line) { + if (($line[0] ?? '') === ' ' || ($line[0] ?? '') === "\t") { + $last = array_key_last($unfolded); + if ($last !== null) { + $unfolded[$last] .= ' ' . ltrim($line); + } elseif ($mode === self::MODE_STRICT) { + throw new EmailParseException(sprintf('Malformed folded header line: %s', $line)); + } else { + $unfolded[] = ltrim($line); + } + + continue; + } + + $unfolded[] = $line; + } + + return $unfolded; + } +} diff --git a/src/Email/Parser/MimeParser.php b/src/Email/Parser/MimeParser.php new file mode 100644 index 0000000..f427996 --- /dev/null +++ b/src/Email/Parser/MimeParser.php @@ -0,0 +1,18 @@ +partParser->parseFromHeadersAndBody($headers, $body); + } +} diff --git a/src/Email/Parser/MimePartParser.php b/src/Email/Parser/MimePartParser.php new file mode 100644 index 0000000..d645b74 --- /dev/null +++ b/src/Email/Parser/MimePartParser.php @@ -0,0 +1,244 @@ +splitRawMessage($rawPart); + $headers = $this->headerParser->parse($rawHeaders); + + return $this->parseFromHeadersAndBody($headers, $rawBody, $partNumber); + } + + public function parseFromHeadersAndBody(HeaderBag $headers, string $body, ?string $partNumber = null): ParsedEmailPart + { + $contentTypeHeader = $headers->first('Content-Type') ?? 'text/plain'; + $transferEncoding = $headers->first('Content-Transfer-Encoding'); + $contentDisposition = $headers->first('Content-Disposition'); + + $contentType = $this->headerValueToken($contentTypeHeader); + $contentTypeParams = $this->headerParameters($contentTypeHeader); + $dispositionParams = $this->headerParameters($contentDisposition); + + $boundary = $contentTypeParams['boundary'] ?? null; + $charset = $contentTypeParams['charset'] ?? null; + $nameFromContentType = $contentTypeParams['name'] ?? null; + $disposition = $contentDisposition !== null ? $this->headerValueToken($contentDisposition) : null; + $filename = $dispositionParams['filename'] ?? $nameFromContentType; + + $contentId = $headers->first('Content-ID'); + if ($contentId !== null) { + $contentId = trim($contentId, '<>'); + } + + $children = []; + $decodedBody = ''; + + if (str_starts_with($contentType, 'multipart/') && $boundary !== null && $boundary !== '') { + foreach ($this->splitMultipartBody($body, $boundary) as $index => $partBody) { + $childNumber = $partNumber === null + ? (string) ($index + 1) + : sprintf('%s.%d', $partNumber, $index + 1); + $children[] = $this->parse($partBody, $childNumber); + } + } else { + $decodedBody = $this->transferDecoder->decode($body, $transferEncoding); + $decodedBody = $this->charsetDecoder->toUtf8($decodedBody, $charset); + } + + $isInline = $disposition === 'inline'; + + return new ParsedEmailPart( + $headers->asMap(), + $contentType, + $charset, + $disposition, + $filename, + $contentId, + $decodedBody, + $isInline, + $partNumber, + $children, + ); + } + + private function decodeParameterValue(string $value, bool $encoded): string + { + $decoded = $value; + + if ($encoded && preg_match('/^[^\']*\'[^\']*\'(.+)$/', $decoded, $matches) === 1) { + $decoded = $matches[1]; + } + + if ($encoded) { + $decoded = rawurldecode($decoded); + } + + return trim($decoded, " \t\n\r\0\x0B\""); + } + + /** + * @return array + */ + private function headerParameters(?string $value): array + { + if ($value === null || trim($value) === '') { + return []; + } + + $segments = $this->splitHeaderParameters($value); + $parameters = []; + $continuations = []; + + foreach ($segments as $index => $segment) { + if ($index === 0) { + continue; + } + + $this->parseParameterSegment($segment, $parameters, $continuations); + } + + foreach ($continuations as $baseName => $parts) { + ksort($parts); + $joined = implode('', $parts); + $parameters[$baseName] = $this->decodeParameterValue($joined, true); + } + + return $parameters; + } + + private function headerValueToken(string $value): string + { + $token = trim(strtolower(explode(';', $value, 2)[0])); + + return $token !== '' ? $token : 'text/plain'; + } + + /** + * @param array $parameters + * @param array> $continuations + */ + private function parseParameterSegment(string $segment, array &$parameters, array &$continuations): void + { + $trimmed = trim($segment); + if ($trimmed === '' || !str_contains($trimmed, '=')) { + return; + } + + [$rawName, $rawValue] = explode('=', $trimmed, 2); + $name = strtolower(trim($rawName)); + $parameterValue = trim($rawValue, " \t\n\r\0\x0B\""); + + if (preg_match('/^(?[a-z0-9_-]+)\*(?\d+)\*?$/i', $name, $matches) === 1) { + $base = strtolower($matches['base']); + $partIndex = (int) $matches['index']; + $continuations[$base] ??= []; + $continuations[$base][$partIndex] = $parameterValue; + + return; + } + + $isEncoded = str_ends_with($name, '*'); + $baseName = $isEncoded ? substr($name, 0, -1) : $name; + $parameters[$baseName] = $this->decodeParameterValue($parameterValue, $isEncoded); + } + + /** + * @return list + */ + private function splitHeaderParameters(string $value): array + { + $segments = []; + $buffer = ''; + $inQuotes = false; + $length = strlen($value); + + for ($index = 0; $index < $length; $index++) { + $character = $value[$index]; + + if ($character === '"' && ($index === 0 || $value[$index - 1] !== '\\')) { + $inQuotes = !$inQuotes; + } + + if ($character === ';' && !$inQuotes) { + $segments[] = $buffer; + $buffer = ''; + + continue; + } + + $buffer .= $character; + } + + $segments[] = $buffer; + + return $segments; + } + + /** + * @return list + */ + private function splitMultipartBody(string $body, string $boundary): array + { + $normalized = str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $body)); + $startDelimiter = '--' . $boundary; + $endDelimiter = $startDelimiter . '--'; + $lines = preg_split('/\r\n/', $normalized) ?: []; + $parts = []; + $buffer = []; + $inPart = false; + + foreach ($lines as $line) { + if ($line === $startDelimiter || $line === $endDelimiter) { + if ($inPart && $buffer !== []) { + $parts[] = implode("\r\n", $buffer); + $buffer = []; + } + + if ($line === $endDelimiter) { + break; + } + + $inPart = true; + + continue; + } + + if (!$inPart) { + continue; + } + + $buffer[] = $line; + } + + if ($buffer !== []) { + $parts[] = implode("\r\n", $buffer); + } + + return $parts; + } + + /** + * @return array{0:string,1:string} + */ + private function splitRawMessage(string $raw): array + { + $normalized = str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $raw)); + $parts = preg_split("/\r\n\r\n/", $normalized, 2) ?: []; + + return [$parts[0] ?? '', $parts[1] ?? '']; + } +} diff --git a/src/Email/Parser/RawEmailParser.php b/src/Email/Parser/RawEmailParser.php new file mode 100644 index 0000000..618882f --- /dev/null +++ b/src/Email/Parser/RawEmailParser.php @@ -0,0 +1,253 @@ + $metadata + */ + public function parse(string $rawEmail, array $metadata = []): ParsedEmail + { + if (strlen($rawEmail) > $this->limits->maxMessageBytes) { + throw new EmailParseException(sprintf( + 'Raw email exceeds max message size limit (%d bytes).', + $this->limits->maxMessageBytes, + )); + } + + [$headerBlock, $body] = $this->splitRawMessage($rawEmail); + $this->assertHeaderLimits($headerBlock); + $headers = $this->headerParser->parse($headerBlock); + $rootPart = $this->mimeParser->parse($headers, $body); + $this->assertMimeLimits($rootPart); + [$textBody, $htmlBody] = $this->collectBodies($rootPart); + $attachments = $this->attachmentExtractor->extract($rootPart); + $this->assertAttachmentLimits($attachments); + $this->assertBodyLimits($rootPart); + $parts = $this->flattenParts($rootPart); + + return new ParsedEmail( + $this->addresses($headers, 'From'), + $this->addresses($headers, 'To'), + $this->addresses($headers, 'Cc'), + $this->addresses($headers, 'Bcc'), + $headers->first('Subject'), + $this->headerParser->parseDate($headers->first('Date')), + $this->normalizeMessageId($headers->first('Message-ID')), + $this->normalizeMessageId($headers->first('In-Reply-To')), + $this->headerParser->parseReferences($headers->first('References')), + $textBody, + $htmlBody, + $attachments, + $parts, + $headers->asMap(), + $this->normalizeLineEndings($rawEmail), + $metadata, + ); + } + + private function addresses(HeaderBag $headers, string $name): EmailAddressList + { + return $this->addressParser->parse($headers->first($name)); + } + + /** + * @param list $attachments + */ + private function assertAttachmentLimits(array $attachments): void + { + if (count($attachments) > $this->limits->maxAttachmentCount) { + throw new EmailParseException(sprintf( + 'Attachment count exceeds limit (%d).', + $this->limits->maxAttachmentCount, + )); + } + + foreach ($attachments as $attachment) { + if ($attachment->sizeBytes <= $this->limits->maxAttachmentBytes) { + continue; + } + + throw new EmailParseException(sprintf( + 'Attachment exceeds max size limit (%d bytes): %s', + $this->limits->maxAttachmentBytes, + $attachment->filename, + )); + } + } + + private function assertBodyLimits(ParsedEmailPart $root): void + { + foreach ($this->flattenParts($root) as $part) { + if ($part->children !== []) { + continue; + } + + if (strlen($part->body) > $this->limits->maxDecodedBodyBytes) { + throw new EmailParseException(sprintf( + 'Decoded MIME body exceeds limit (%d bytes).', + $this->limits->maxDecodedBodyBytes, + )); + } + } + } + + private function assertHeaderLimits(string $headerBlock): void + { + if (strlen($headerBlock) > $this->limits->maxHeaderBytes) { + throw new EmailParseException(sprintf( + 'Header section exceeds limit (%d bytes).', + $this->limits->maxHeaderBytes, + )); + } + + $headerLines = preg_split('/\r\n/', $headerBlock) ?: []; + if (count($headerLines) > $this->limits->maxHeaderCount) { + throw new EmailParseException(sprintf( + 'Header count exceeds limit (%d).', + $this->limits->maxHeaderCount, + )); + } + } + + private function assertMimeLimits(ParsedEmailPart $root): void + { + $parts = 0; + $maxDepth = 0; + $partStack = [$root]; + $depthStack = [1]; + + while ($partStack !== [] && $depthStack !== []) { + $current = array_pop($partStack); + $depth = array_pop($depthStack); + + $parts++; + $maxDepth = max($maxDepth, $depth); + + if ($parts > $this->limits->maxMimeParts) { + throw new EmailParseException(sprintf( + 'MIME part count exceeds limit (%d).', + $this->limits->maxMimeParts, + )); + } + + if ($maxDepth > $this->limits->maxMimeDepth) { + throw new EmailParseException(sprintf( + 'MIME nesting depth exceeds limit (%d).', + $this->limits->maxMimeDepth, + )); + } + + foreach ($current->children as $child) { + $partStack[] = $child; + $depthStack[] = $depth + 1; + } + } + } + + /** + * @return array{0:?string,1:?string} + */ + private function collectBodies(ParsedEmailPart $root): array + { + $textBody = null; + $htmlBody = null; + + foreach ($this->flattenParts($root) as $part) { + if ($part->children !== []) { + continue; + } + + if ($part->disposition === 'attachment') { + continue; + } + + if ($textBody === null && $part->contentType === 'text/plain') { + $textBody = $part->body; + + continue; + } + + if ($htmlBody === null && $part->contentType === 'text/html') { + $htmlBody = $part->body; + } + } + + return [$textBody, $htmlBody]; + } + + /** + * @return list + */ + private function flattenParts(ParsedEmailPart $root): array + { + $parts = []; + $stack = [$root]; + $stackCount = count($stack); + $index = 0; + + while ($index < $stackCount) { + $part = $stack[$index]; + $index++; + + $parts[] = $part; + + foreach ($part->children as $child) { + $stack[] = $child; + $stackCount++; + } + } + + return $parts; + } + + private function normalizeLineEndings(string $value): string + { + return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $value)); + } + + private function normalizeMessageId(?string $messageId): ?string + { + if ($messageId === null || trim($messageId) === '') { + return null; + } + + $trimmed = trim($messageId); + + if (str_starts_with($trimmed, '<') && str_ends_with($trimmed, '>')) { + return $trimmed; + } + + return sprintf('<%s>', trim($trimmed, '<>')); + } + + /** + * @return array{0:string,1:string} + */ + private function splitRawMessage(string $raw): array + { + $normalized = $this->normalizeLineEndings($raw); + $parts = preg_split("/\r\n\r\n/", $normalized, 2) ?: []; + + return [$parts[0] ?? '', $parts[1] ?? '']; + } +} diff --git a/src/Email/Parser/TransferDecoder.php b/src/Email/Parser/TransferDecoder.php new file mode 100644 index 0000000..2bc95cf --- /dev/null +++ b/src/Email/Parser/TransferDecoder.php @@ -0,0 +1,31 @@ + $this->decodeBase64($body), + 'quoted-printable' => quoted_printable_decode($body), + '7bit', '8bit', 'binary', '' => $body, + default => $body, + }; + } + + private function decodeBase64(string $body): string + { + $decoded = base64_decode(preg_replace('/\s+/', '', $body) ?? $body, true); + + if ($decoded === false) { + return $body; + } + + return $decoded; + } +} diff --git a/src/Email/Receiver/EmailReceiver.php b/src/Email/Receiver/EmailReceiver.php new file mode 100644 index 0000000..2c64986 --- /dev/null +++ b/src/Email/Receiver/EmailReceiver.php @@ -0,0 +1,12 @@ +readNext(consume: false); + } + + public function receive(): ?ParsedEmail + { + return $this->receiveParsed(); + } + + /** + * @return list + */ + public function receiveMany(?int $limit = null): array + { + $limit ??= $this->config->maxMessages; + + if ($limit < 1) { + return []; + } + + $received = []; + + while (count($received) < $limit) { + $parsed = $this->receiveParsed(); + if ($parsed === null) { + break; + } + + $received[] = $parsed; + } + + return $received; + } + + public function receiveParsed(): ?ParsedEmail + { + return $this->readNext(consume: true); + } + + private function beginProcessing(string $file, bool $consume): string + { + if (!$consume || $this->config->processingDirectory === null || $this->config->processingDirectory === '') { + return $file; + } + + return $this->moveFileToDirectory($file, $this->config->processingDirectory, ensureUnique: true); + } + + /** + * @return array + */ + private function buildMetadata(string $originalPath, string $processingPath, bool $consume): array + { + return [ + 'source' => 'spool', + 'path' => $originalPath, + 'original_path' => $originalPath, + 'processing_path' => $processingPath, + 'consumed_at' => $consume ? gmdate(DATE_ATOM) : null, + 'size_bytes' => filesize($processingPath) ?: 0, + ]; + } + + private function deleteFile(string $file, bool $strict): void + { + if (!file_exists($file)) { + return; + } + + if (!unlink($file) && $strict) { + throw new RuntimeException(sprintf('Unable to delete file "%s".', $file)); + } + } + + private function ensureDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException(sprintf('Unable to create directory: %s', $directory)); + } + } + + private function finalizeRead(string $file): void + { + if ($this->moveAfterRead !== null && $this->moveAfterRead !== '') { + $this->moveFileToDirectory($file, $this->moveAfterRead); + + return; + } + + if ($this->deleteAfterRead) { + $this->deleteFile($file, strict: true); + } + } + + private function firstSpoolFile(): ?string + { + $directory = rtrim($this->config->directory, '/\\'); + if (!is_dir($directory)) { + return null; + } + + $extension = ltrim($this->config->extension, '.'); + $files = glob(sprintf('%s/*.%s', $directory, $extension)); + if ($files === false || $files === []) { + return null; + } + + $now = time(); + $candidates = []; + + foreach ($files as $file) { + $mtime = filemtime($file); + if ($mtime === false) { + continue; + } + + $age = $now - $mtime; + + if ($this->config->olderThanSeconds !== null && $age < $this->config->olderThanSeconds) { + continue; + } + + if ($this->config->newerThanSeconds !== null && $age > $this->config->newerThanSeconds) { + continue; + } + + $candidates[] = $file; + } + + if ($candidates === []) { + return null; + } + + sort($candidates); + + return $candidates[0]; + } + + private function markFailed(string $file, string $reason): void + { + if ($this->failedDirectory !== null && $this->failedDirectory !== '') { + try { + $target = $this->moveFileToDirectory($file, $this->failedDirectory); + $errorPath = $target . '.error.txt'; + file_put_contents($errorPath, $reason); + } catch (\Throwable) { + // Best effort quarantine. + } + + return; + } + + if ($this->deleteAfterRead) { + $this->deleteFile($file, strict: false); + } + } + + private function moveFileToDirectory(string $file, string $directory, bool $ensureUnique = false): string + { + $this->ensureDirectory($directory); + + $target = rtrim($directory, '/\\') . '/' . basename($file); + if ($ensureUnique) { + $target = $this->uniqueTarget($directory, basename($file)); + } + + if (!rename($file, $target)) { + throw new RuntimeException(sprintf('Unable to move file "%s" to "%s".', $file, $target)); + } + + return $target; + } + + /** + * @param array $metadata + */ + private function parseFile(string $raw, array $metadata): ParsedEmail + { + return $this->parser->parse($raw, $metadata); + } + + private function readFile(string $file, bool $exclusiveLock): string|false + { + if (!$this->config->lockBeforeRead) { + return file_get_contents($file); + } + + $handle = fopen($file, 'rb'); + if (!is_resource($handle)) { + return false; + } + + $lockMode = $exclusiveLock ? LOCK_EX : LOCK_SH; + if (!flock($handle, $lockMode)) { + fclose($handle); + + return false; + } + + $contents = stream_get_contents($handle); + flock($handle, LOCK_UN); + fclose($handle); + + return is_string($contents) ? $contents : false; + } + + private function readNext(bool $consume): ?ParsedEmail + { + $sourceFile = $this->firstSpoolFile(); + if ($sourceFile === null) { + return null; + } + + $processingFile = $sourceFile; + $startedAt = microtime(true); + EmailEventBus::dispatch('email.receive.start', [ + 'source' => 'spool', + 'path' => $sourceFile, + 'consume' => $consume, + ]); + + try { + $processingFile = $this->beginProcessing($sourceFile, $consume); + $raw = $this->readFile($processingFile, $consume); + if ($raw === false) { + $this->markFailed($processingFile, 'Unable to read spool file.'); + EmailEventBus::dispatch('email.receive.finish', [ + 'source' => 'spool', + 'successful' => false, + 'path' => $processingFile, + 'error' => 'Unable to read spool file.', + 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ]); + + return null; + } + + $parsed = $this->parseFile($raw, $this->buildMetadata($sourceFile, $processingFile, $consume)); + } catch (\Throwable $exception) { + $this->markFailed($processingFile, $exception->getMessage()); + EmailEventBus::dispatch('email.parse.failed', [ + 'source' => 'spool', + 'path' => $processingFile, + 'error' => $exception->getMessage(), + ]); + EmailEventBus::dispatch('email.receive.finish', [ + 'source' => 'spool', + 'successful' => false, + 'path' => $processingFile, + 'error' => $exception->getMessage(), + 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ]); + + return null; + } + + if ($consume) { + $this->finalizeRead($processingFile); + } + + EmailEventBus::dispatch('email.receive.finish', [ + 'source' => 'spool', + 'successful' => true, + 'path' => $processingFile, + 'subject' => $parsed->subject, + 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ]); + + return $parsed; + } + + private function uniqueTarget(string $directory, string $basename): string + { + $target = rtrim($directory, '/\\') . '/' . $basename; + if (!file_exists($target)) { + return $target; + } + + $pathInfo = pathinfo($basename); + $name = $pathInfo['filename']; + $extension = isset($pathInfo['extension']) ? ('.' . $pathInfo['extension']) : ''; + + return sprintf('%s/%s-%s%s', rtrim($directory, '/\\'), $name, bin2hex(random_bytes(4)), $extension); + } +} diff --git a/src/Email/Result/EmailDeliveryReport.php b/src/Email/Result/EmailDeliveryReport.php new file mode 100644 index 0000000..f685a47 --- /dev/null +++ b/src/Email/Result/EmailDeliveryReport.php @@ -0,0 +1,24 @@ + $acceptedRecipients + * @param array $rejectedRecipients + * @param list $recipientResults + * @param array $metadata + */ + public function __construct( + public bool $successful, + public array $acceptedRecipients, + public array $rejectedRecipients, + public ?string $messageId = null, + public ?string $error = null, + public array $recipientResults = [], + public array $metadata = [], + ) {} +} diff --git a/src/Email/Result/EmailRecipientResult.php b/src/Email/Result/EmailRecipientResult.php new file mode 100644 index 0000000..2d8450f --- /dev/null +++ b/src/Email/Result/EmailRecipientResult.php @@ -0,0 +1,15 @@ + $acceptedRecipients + * @param array $rejectedRecipients + * @param array $metadata + */ + public function __construct( + public string $transport, + public ?string $messageId, + public array $acceptedRecipients, + public array $rejectedRecipients, + public array $metadata = [], + ) {} + + public function recipientCount(): int + { + return count($this->acceptedRecipients) + count($this->rejectedRecipients); + } + + public function successful(): bool + { + return $this->acceptedRecipients !== [] && $this->rejectedRecipients === []; + } +} diff --git a/src/Email/System/AddressFormatter.php b/src/Email/System/AddressFormatter.php new file mode 100644 index 0000000..2cefdc8 --- /dev/null +++ b/src/Email/System/AddressFormatter.php @@ -0,0 +1,56 @@ +isAscii($value)) { + return $value; + } + + return sprintf('=?UTF-8?B?%s?=', base64_encode($value)); + } + + public function format(EmailAddress $address): string + { + if ($address->name === null || $address->name === '') { + return $address->email; + } + + return sprintf('%s <%s>', $this->encodeDisplayName($address->name), $address->email); + } + + /** + * @param list $addresses + */ + public function formatList(array $addresses): string + { + return implode(', ', array_map($this->format(...), $addresses)); + } + + private function encodeDisplayName(string $value): string + { + $encoded = $this->encodeMimeHeader($value); + + if ($encoded !== $value) { + return $encoded; + } + + if (preg_match('/[,;"]/', $value) === 1) { + return sprintf('"%s"', addcslashes($value, '"\\')); + } + + return $value; + } + + private function isAscii(string $value): bool + { + return preg_match('/^[\x20-\x7E]*$/', $value) === 1; + } +} diff --git a/src/Email/System/AttachmentEncoder.php b/src/Email/System/AttachmentEncoder.php new file mode 100644 index 0000000..4223cc3 --- /dev/null +++ b/src/Email/System/AttachmentEncoder.php @@ -0,0 +1,147 @@ +encodeToStream( + $attachment, + static function (string $chunk) use (&$encodedContent): void { + $encodedContent .= $chunk; + }, + includeHeaders: false, + ); + + return new MimePart($this->buildHeaders($attachment), trim($encodedContent)); + } + + /** + * @param callable(string):void $write + */ + public function encodeToStream(EmailAttachment $attachment, callable $write, bool $includeHeaders = true): void + { + if ($includeHeaders) { + $write(implode("\r\n", $this->buildHeaders($attachment)) . "\r\n\r\n"); + } + + ['stream' => $stream, 'owned' => $ownedStream] = $this->openReadableStream($attachment); + + try { + $this->streamingBase64Encoder->encode( + $stream, + static function (string $chunk) use ($write): void { + if ($chunk !== '') { + $write($chunk); + } + }, + $attachment->maxSizeBytes(), + ); + } catch (RuntimeException $exception) { + throw new AttachmentException(sprintf( + 'Unable to encode attachment "%s": %s', + $attachment->name, + $exception->getMessage(), + ), previous: $exception); + } finally { + if ($ownedStream) { + fclose($stream); + } + } + } + + /** + * @return list + */ + private function buildHeaders(EmailAttachment $attachment): array + { + $filename = $this->filenameEncoder->encode($attachment->name); + $headers = [ + sprintf( + 'Content-Type: %s; name="%s"; name*=UTF-8\'\'%s', + $attachment->mimeType, + $filename['fallback'], + $filename['star'], + ), + sprintf( + 'Content-Disposition: %s; filename="%s"; filename*=UTF-8\'\'%s', + $attachment->disposition, + $filename['fallback'], + $filename['star'], + ), + 'Content-Transfer-Encoding: ' . ContentTransferEncoding::Base64->value, + ]; + + if ($attachment->isInline() && $attachment->contentId !== null && $attachment->contentId !== '') { + $headers[] = sprintf('Content-ID: <%s>', trim($attachment->contentId, '<>')); + } + + return $headers; + } + + /** + * @return array{stream:resource,owned:bool} + */ + private function openContentBackedStream(string $content): array + { + $stream = fopen('php://temp', 'w+b'); + if (!is_resource($stream)) { + throw new RuntimeException('Unable to open temporary stream for attachment encoding.'); + } + + if (fwrite($stream, $content) === false) { + fclose($stream); + + throw new RuntimeException('Unable to write attachment content to temporary stream.'); + } + + rewind($stream); + + return ['stream' => $stream, 'owned' => true]; + } + + /** + * @return array{stream:resource,owned:bool} + */ + private function openReadableStream(EmailAttachment $attachment): array + { + if (is_resource($attachment->stream)) { + $meta = stream_get_meta_data($attachment->stream); + + if ($meta['seekable'] === true) { + rewind($attachment->stream); + } + + return ['stream' => $attachment->stream, 'owned' => false]; + } + + if ($attachment->path !== null) { + $stream = fopen($attachment->path, 'rb'); + if (!is_resource($stream)) { + throw new AttachmentException(sprintf('Unable to open attachment file: %s', $attachment->path)); + } + + return ['stream' => $stream, 'owned' => true]; + } + + if ($attachment->content !== null) { + return $this->openContentBackedStream($attachment->content); + } + + throw new AttachmentException(sprintf('Attachment source unavailable: %s', $attachment->name)); + } +} diff --git a/src/Email/System/EmailBuilder.php b/src/Email/System/EmailBuilder.php deleted file mode 100644 index 2d921f6..0000000 --- a/src/Email/System/EmailBuilder.php +++ /dev/null @@ -1,195 +0,0 @@ -boundaryAlternative = bin2hex(random_bytes(16)); - } - - public function setCommonHeaders(array $to, string $subject, array $cc = [], $bcc = [], $replyTo = '') - { - $this->headers = [ - 'Date: ' . date('r'), - 'From: =?UTF-8?B?' . base64_encode($this->from['name']) . '?= <' . $this->from['email'] . '>', - 'To: ' . implode(',', $to), - 'Reply-To: ' . $replyTo, - 'Subject: ' . $subject - ]; - if (!empty($cc)) { - $this->headers[] = "Cc: " . implode(',', $cc); - } - if (!empty($bcc)) { - $this->headers[] = "Bcc: " . implode(',', $bcc); - } - $this->headers['XM'] = 'X-Mailer: PHP/' . phpversion(); - return $this; - } - - public function setIdHeaders(string $messageId, string $inReplyTo = '', array $references = []) - { - $domain = substr(strrchr($this->from['email'], "@"), 1); - $this->headers[] = "Message-ID: <$messageId>"; - - if (!empty($inReplyTo)) { - $this->headers[] = "In-Reply-To: <$inReplyTo@$domain>"; - } - if (!empty($references)) { - $this->headers[] = 'References: ' . implode( - ' ', - array_map(function ($ref) use ($domain) { - return "<$ref@$domain>"; - }, $references) - ); - } - return $this; - } - - // General headers method with direct parameters - public function setGeneralHeaders( - string $language = '', - int $priority = null, - string $mailer = '' - ) { - if (!empty($language)) { - $this->headers[] = "Content-Language: $language"; - } - if (!is_null($priority)) { - if (!in_array($priority, [1, 2, 3])) { - throw new \InvalidArgumentException("Invalid X-Priority value. It must be 1, 2, or 3."); - } - $this->headers[] = "X-Priority: $priority"; - } - if (!empty($mailer)) { - $this->headers['XM'] = "X-Mailer: $mailer"; - } - return $this; - } - - // List headers method with direct parameters - public function setListHeaders( - string $listId = '', - string $unsubscribe = '', - string $subscribe = '', - string $archive = '' - ) { - if (!empty($listId)) { - $this->headers[] = "List-Id: <$listId>"; - } - if (!empty($unsubscribe)) { - $this->headers[] = "List-Unsubscribe: <$unsubscribe>"; - } - if (!empty($subscribe)) { - $this->headers[] = "List-Subscribe: <$subscribe>"; - } - if (!empty($archive)) { - $this->headers[] = "List-Archive: <$archive>"; - } - return $this; - } - - // Misc headers method with direct parameters - public function setMiscHeaders( - bool $confirmedOptIn = null, - string $spamStatus = '', - string $organization = '', - string $dispositionNotificationTo = '' - ) { - if ($confirmedOptIn !== null) { - $this->headers[] = "X-Confirmed-OptIn: " . ($confirmedOptIn ? 'Yes' : 'No'); - } - if (!empty($spamStatus)) { - $this->headers[] = "X-Spam-Status: $spamStatus"; - } - if (!empty($organization)) { - $this->headers[] = "Organization: $organization"; - } - if (!empty($dispositionNotificationTo)) { - $this->headers[] = "Disposition-Notification-To: $dispositionNotificationTo"; - } - return $this; - } - - public function setBody($htmlContent, $plainText = '', $attachments = []) - { - if (empty($plainText) && !empty($htmlContent)) { - $plainText = strip_tags($htmlContent); - } - $this->headers[] = 'MIME-Version: 1.0'; - $message = $this->buildAlternativeBody($plainText, $htmlContent); - $this->headers['CT'] = "Content-Type: multipart/alternative; boundary=\"$this->boundaryAlternative\""; - if (!empty($attachments)) { - $this->boundaryMixed = sha1(uniqid(time(), true)); - $this->headers['CT'] = "Content-Type: multipart/mixed; boundary=\"$this->boundaryMixed\""; - $message = $this->wrapWithMixedBoundary($message, $attachments); - } - $this->headers[] = 'Content-Length: ' . strlen($message); - - return $message; - } - - public function getHeaders() - { - return implode("\r\n", $this->headers); - } - - // Private helper methods for body building - private function buildAlternativeBody($plainText, $htmlContent) - { - $message = $this->buildPlainTextPart($plainText); - if (!empty($htmlContent)) { - $message .= $this->buildHtmlPart($htmlContent); - } - $message .= "--$this->boundaryAlternative--\r\n"; - return $message; - } - - private function wrapWithMixedBoundary($message, $attachments) - { - return "--$this->boundaryMixed\r\n" - . "Content-Type: multipart/alternative; boundary=\"$this->boundaryAlternative\"\r\n\r\n" - . $message - . $this->buildAttachmentsPart($attachments) - . "--$this->boundaryMixed--\r\n"; - } - - private function buildPlainTextPart($plainText) - { - return "--$this->boundaryAlternative\r\n" - . "Content-Type: text/plain; charset=UTF-8\r\n" - . "Content-Transfer-Encoding: 7bit\r\n\r\n" - . "$plainText\r\n\r\n"; - } - - private function buildHtmlPart($htmlContent) - { - return "--$this->boundaryAlternative\r\n" - . "Content-Type: text/html; charset=UTF-8\r\n" - . "Content-Transfer-Encoding: quoted-printable\r\n\r\n" - . quoted_printable_encode($htmlContent) . "\r\n\r\n"; - } - - private function buildAttachmentsPart($attachments) - { - $attachmentsPart = ""; - foreach ($attachments as $attachment) { - $filePath = $attachment['path']; - $fileName = rawurlencode($attachment['name']); - $fileType = mime_content_type($filePath) ?: 'application/octet-stream'; - $fileContent = chunk_split(base64_encode(file_get_contents($filePath))); - - $attachmentsPart .= "--$this->boundaryMixed\r\n" - . "Content-Type: $fileType; name*=\"UTF-8''" . $fileName . "\"\r\n" - . "Content-Disposition: attachment; filename*=\"UTF-8''" . $fileName . "\"\r\n" - . "Content-Transfer-Encoding: base64\r\n\r\n" - . "$fileContent\r\n\r\n"; - } - return $attachmentsPart; - } -} diff --git a/src/Email/System/EmailHeaderBuilder.php b/src/Email/System/EmailHeaderBuilder.php new file mode 100644 index 0000000..941a5dc --- /dev/null +++ b/src/Email/System/EmailHeaderBuilder.php @@ -0,0 +1,264 @@ +baseHeaders($message, $mimeMessage, $includeSubject); + $headerLines = $this->addGeneralHeaders($message, $headerLines); + $headerLines = $this->addListHeaders($message, $headerLines); + $headerLines = $this->addMiscHeaders($message, $headerLines); + $headerLines = $this->addMessageThreadHeaders($message, $headerLines); + $headerLines = $this->addCustomHeaders($message, $headerLines); + + return implode("\r\n", array_map($this->headerFolder->fold(...), $headerLines)); + } + + public function resolveMessageId(EmailMessage $message): ?string + { + $from = $message->envelope()->from; + + if ($from === null) { + return null; + } + + return $this->normalizeMessageId($message->headersData()->messageId, $from); + } + + /** + * @param list $headerLines + * @return list + */ + private function addCustomHeaders(EmailMessage $message, array $headerLines): array + { + foreach ($message->headersData()->customHeaders as $name => $value) { + if (is_array($value)) { + foreach ($value as $lineValue) { + $headerLines[] = sprintf('%s: %s', $name, $lineValue); + } + + continue; + } + + $headerLines[] = sprintf('%s: %s', $name, $value); + } + + return $headerLines; + } + + /** + * @param list $headerLines + * @return list + */ + private function addGeneralHeaders(EmailMessage $message, array $headerLines): array + { + $envelope = $message->envelope(); + $headers = $message->headersData(); + + if (count($envelope->cc) > 0) { + $headerLines[] = 'Cc: ' . $this->addressFormatter->formatList($envelope->cc); + } + + if ($headers->priority !== null) { + $headerLines[] = 'X-Priority: ' . $headers->priority->value; + } + + if ($headers->language !== '') { + HeaderValueGuard::assertNoCrlf($headers->language, 'Content-Language'); + $headerLines[] = 'Content-Language: ' . $headers->language; + } + + $headerLines[] = 'X-Mailer: ' . ($headers->mailer !== '' ? $headers->mailer : 'TalkingBytes'); + + return $headerLines; + } + + /** + * @param list $headerLines + * @return list + */ + private function addListHeaders(EmailMessage $message, array $headerLines): array + { + $headers = $message->headersData(); + $listValues = [ + 'List-Id' => $headers->listId, + 'List-Unsubscribe' => $headers->listUnsubscribe, + 'List-Unsubscribe-Post' => $headers->listUnsubscribePost, + 'List-Subscribe' => $headers->listSubscribe, + 'List-Archive' => $headers->listArchive, + ]; + + foreach ($listValues as $headerName => $value) { + if ($value === null || $value === '') { + continue; + } + + if ($headerName === 'List-Unsubscribe-Post') { + $headerLines[] = sprintf('%s: %s', $headerName, $value); + + continue; + } + + $headerLines[] = sprintf('%s: <%s>', $headerName, trim($value, '<>')); + } + + return $headerLines; + } + + /** + * @param list $headerLines + * @return list + */ + private function addMessageThreadHeaders(EmailMessage $message, array $headerLines): array + { + $envelope = $message->envelope(); + $headers = $message->headersData(); + $from = $envelope->from; + + if ($from === null) { + throw new InvalidArgumentException('Cannot build message thread headers without a From address.'); + } + + $messageId = $this->normalizeMessageId($headers->messageId, $from); + if ($messageId !== null) { + $headerLines[] = 'Message-ID: ' . $messageId; + } + + $inReplyTo = $this->normalizeMessageId($headers->inReplyTo, $from, false); + if ($inReplyTo !== null) { + $headerLines[] = 'In-Reply-To: ' . $inReplyTo; + } + + $references = []; + foreach ($headers->references as $reference) { + $normalized = $this->normalizeMessageId($reference, $from, false); + if ($normalized !== null) { + $references[] = $normalized; + } + } + + if (count($references) > 0) { + $headerLines[] = 'References: ' . implode(' ', $references); + } + + return $headerLines; + } + + /** + * @param list $headerLines + * @return list + */ + private function addMiscHeaders(EmailMessage $message, array $headerLines): array + { + $headers = $message->headersData(); + + if ($headers->confirmedOptIn !== null) { + $headerLines[] = 'X-Confirmed-OptIn: ' . ($headers->confirmedOptIn ? 'Yes' : 'No'); + } + + if ($headers->spamStatus !== null && $headers->spamStatus !== '') { + $headerLines[] = 'X-Spam-Status: ' . $headers->spamStatus; + } + + if ($headers->organization !== null && $headers->organization !== '') { + $headerLines[] = 'Organization: ' . $headers->organization; + } + + if ($headers->dispositionNotificationTo !== null) { + $headerLines[] = 'Disposition-Notification-To: ' + . $this->addressFormatter->format($headers->dispositionNotificationTo); + } + + return $headerLines; + } + + /** + * @return list + */ + private function baseHeaders(EmailMessage $message, MimeMessage $mimeMessage, bool $includeSubject): array + { + $envelope = $message->envelope(); + $headers = $message->headersData(); + + if ($envelope->from === null) { + throw new InvalidArgumentException('Cannot build headers without a From address.'); + } + + $headerLines = [ + 'Date: ' . new DateTimeImmutable('now', new DateTimeZone('UTC'))->format('r'), + 'From: ' . $this->addressFormatter->format($envelope->from), + 'To: ' . $this->formatToHeaderValue($envelope->to), + 'MIME-Version: 1.0', + 'Content-Type: ' . $mimeMessage->contentType, + ]; + + if ($mimeMessage->contentTransferEncoding !== null) { + $headerLines[] = 'Content-Transfer-Encoding: ' . $mimeMessage->contentTransferEncoding->value; + } + + if ($headers->sender !== null) { + $headerLines[] = 'Sender: ' . $this->addressFormatter->format($headers->sender); + } + + if ($headers->replyTo !== null) { + $headerLines[] = 'Reply-To: ' . $this->addressFormatter->format($headers->replyTo); + } + + if ($includeSubject) { + $headerLines[] = 'Subject: ' . $this->addressFormatter->encodeMimeHeader($headers->subject); + } + + return $headerLines; + } + + /** + * @param list $toAddresses + */ + private function formatToHeaderValue(array $toAddresses): string + { + if (count($toAddresses) === 0) { + return 'undisclosed-recipients:;'; + } + + return $this->addressFormatter->formatList($toAddresses); + } + + private function normalizeMessageId(?string $messageId, EmailAddress $from, bool $generateIfMissing = true): ?string + { + $value = trim((string) $messageId); + if ($value === '') { + if (!$generateIfMissing) { + return null; + } + + $domain = substr(strrchr($from->email, '@') ?: '@localhost', 1); + + return sprintf('<%s@%s>', bin2hex(random_bytes(16)), $domain); + } + + if (preg_match('/^<[^\s<>@]+@[^\s<>@]+>$/', $value) === 1) { + return $value; + } + + if (preg_match('/^[^\s<>@]+@[^\s<>@]+$/', $value) === 1) { + return sprintf('<%s>', $value); + } + + throw new InvalidArgumentException(sprintf('Invalid Message-ID value: %s', $value)); + } +} diff --git a/src/Email/System/FilenameEncoder.php b/src/Email/System/FilenameEncoder.php new file mode 100644 index 0000000..5449d87 --- /dev/null +++ b/src/Email/System/FilenameEncoder.php @@ -0,0 +1,23 @@ + $fallback, + 'star' => rawurlencode($sanitized), + ]; + } +} diff --git a/src/Email/System/GenericSender.php b/src/Email/System/GenericSender.php deleted file mode 100644 index c4a3b36..0000000 --- a/src/Email/System/GenericSender.php +++ /dev/null @@ -1,17 +0,0 @@ - false, 'error' => null]; - - public function send(array $to, string $subject, string $message, string $headers) - { - mail(implode(',', $to), $subject, $message, $headers) - ? $this->status['sent'] = true - : $this->status['error'] = "Mail function failed."; - - return $this->status; - } -} diff --git a/src/Email/System/HeaderFolder.php b/src/Email/System/HeaderFolder.php new file mode 100644 index 0000000..639d769 --- /dev/null +++ b/src/Email/System/HeaderFolder.php @@ -0,0 +1,110 @@ +foldGeneric($headerLine, $limit); + } + + $name = substr($headerLine, 0, $separatorPos); + $value = ltrim(substr($headerLine, $separatorPos + 1)); + + if (strcasecmp($name, 'DKIM-Signature') === 0) { + return $this->foldDkim($name, $value, $limit); + } + + return $this->foldStructured($name, $value, $limit); + } + + private function foldDkim(string $name, string $value, int $limit): string + { + $prefix = $name . ': '; + $lines = []; + $current = $prefix; + $segments = preg_split('/;\s*/', trim($value)) ?: []; + + foreach ($segments as $index => $segment) { + if ($segment === '') { + continue; + } + + $piece = ($index > 0 ? '; ' : '') . $segment; + if (strlen($current . $piece) <= $limit) { + $current .= $piece; + + continue; + } + + $lines[] = rtrim($current, '; '); + $current = ' ' . ltrim($segment); + } + + $lines[] = $current; + + if (!str_contains($lines[0], ':')) { + $lines[0] = $prefix . ltrim($lines[0]); + } + + return implode(";\r\n", $lines); + } + + private function foldGeneric(string $value, int $limit): string + { + $parts = []; + $remaining = $value; + + while (strlen($remaining) > $limit) { + $chunk = substr($remaining, 0, $limit); + $breakPos = strrpos($chunk, ' '); + + if ($breakPos === false || $breakPos < 1) { + $breakPos = $limit; + } + + $parts[] = substr($remaining, 0, $breakPos); + $remaining = ltrim(substr($remaining, $breakPos)); + } + + $parts[] = $remaining; + + return implode("\r\n ", $parts); + } + + private function foldStructured(string $name, string $value, int $limit): string + { + $prefix = $name . ': '; + $lines = []; + $current = $prefix; + $tokens = preg_split('/([,\s]+)/', $value, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; + + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + + if (strlen($current . $token) <= $limit) { + $current .= $token; + + continue; + } + + $lines[] = rtrim($current); + $current = ' ' . ltrim($token); + } + + $lines[] = rtrim($current); + + return implode("\r\n", $lines); + } +} diff --git a/src/Email/System/HeaderValueGuard.php b/src/Email/System/HeaderValueGuard.php new file mode 100644 index 0000000..ad4c1d8 --- /dev/null +++ b/src/Email/System/HeaderValueGuard.php @@ -0,0 +1,28 @@ +isAscii($trimmed)) { + return strtolower($trimmed); + } + + if (!function_exists('idn_to_ascii')) { + throw new InvalidArgumentException('IDN conversion requires the intl extension.'); + } + + $converted = idn_to_ascii($trimmed, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); + if (!is_string($converted) || $converted === '') { + throw new InvalidArgumentException(sprintf('Unable to convert IDN domain to ASCII: %s', $domain)); + } + + return strtolower($converted); + } + + private function isAscii(string $value): bool + { + return preg_match('/^[\x00-\x7F]+$/', $value) === 1; + } +} diff --git a/src/Email/System/MimeBoundary.php b/src/Email/System/MimeBoundary.php new file mode 100644 index 0000000..fb658e8 --- /dev/null +++ b/src/Email/System/MimeBoundary.php @@ -0,0 +1,13 @@ +extractBodies($message); + [$inlineAttachments, $regularAttachments] = $this->splitAttachments($message->attachments()); + $bodyPart = $this->buildBodyPart($textBody, $htmlBody, $hasText, $hasHtml); + + if ($inlineAttachments !== []) { + $bodyPart = $this->wrapMultipart($bodyPart, $inlineAttachments, 'related'); + } + + if ($regularAttachments !== []) { + return $this->wrapMultipart($bodyPart, $regularAttachments, 'mixed'); + } + + if ($bodyPart->contentTransferEncoding !== null) { + return new MimeMessage($bodyPart->contentType, $bodyPart->body, $bodyPart->contentTransferEncoding); + } + + return new MimeMessage($bodyPart->contentType, $bodyPart->body); + } + + /** + * @param callable(string):void $write + */ + public function buildToStream(EmailMessage $message, callable $write): MimeMessage + { + [$textBody, $htmlBody, $hasText, $hasHtml] = $this->extractBodies($message); + [$inlineAttachments, $regularAttachments] = $this->splitAttachments($message->attachments()); + $bodyPart = $this->buildBodyPart($textBody, $htmlBody, $hasText, $hasHtml); + + if ($inlineAttachments === [] && $regularAttachments === []) { + $write($bodyPart->body); + + if ($bodyPart->contentTransferEncoding !== null) { + return new MimeMessage($bodyPart->contentType, '', $bodyPart->contentTransferEncoding); + } + + return new MimeMessage($bodyPart->contentType, ''); + } + + $streamState = $this->initialStreamState($bodyPart); + + if ($inlineAttachments !== []) { + $streamState = $this->wrapStreamState($streamState, $inlineAttachments, 'related'); + } + + if ($regularAttachments !== []) { + $streamState = $this->wrapStreamState($streamState, $regularAttachments, 'mixed'); + } + + $streamState['writer']($write); + + return new MimeMessage($streamState['contentType'], '', $streamState['encoding']); + } + + private function buildBodyPart(string $textBody, string $htmlBody, bool $hasText, bool $hasHtml): MimeMessage + { + if ($hasText && !$hasHtml) { + return new MimeMessage( + 'text/plain; charset=UTF-8', + quoted_printable_encode($textBody), + ContentTransferEncoding::QuotedPrintable, + ); + } + + if (!$hasText && $hasHtml) { + return new MimeMessage( + 'text/html; charset=UTF-8', + quoted_printable_encode($htmlBody), + ContentTransferEncoding::QuotedPrintable, + ); + } + + $alternativeBoundary = MimeBoundary::generate(); + $parts = [ + $this->createTextPart($textBody), + $this->createHtmlPart($htmlBody), + ]; + + return new MimeMessage( + sprintf('multipart/alternative; boundary="%s"', $alternativeBoundary), + $this->renderMultipart($alternativeBoundary, $parts), + ); + } + + private function createHtmlPart(string $htmlBody): MimePart + { + return new MimePart( + [ + 'Content-Type: text/html; charset=UTF-8', + 'Content-Transfer-Encoding: ' . ContentTransferEncoding::QuotedPrintable->value, + ], + quoted_printable_encode($htmlBody), + ); + } + + private function createTextPart(string $textBody): MimePart + { + return new MimePart( + [ + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: ' . ContentTransferEncoding::QuotedPrintable->value, + ], + quoted_printable_encode($textBody), + ); + } + + /** + * @return array{0:string,1:string,2:bool,3:bool} + */ + private function extractBodies(EmailMessage $message): array + { + $textBody = $message->textBody(); + $htmlBody = $message->htmlBody(); + if ($textBody === '' && $htmlBody !== '') { + $textBody = strip_tags($htmlBody); + } + + return [$textBody, $htmlBody, $textBody !== '', $htmlBody !== '']; + } + + /** + * @return array{contentType:string,encoding:?ContentTransferEncoding,writer:callable(callable(string):void):void} + */ + private function initialStreamState(MimeMessage $bodyPart): array + { + return [ + 'contentType' => $bodyPart->contentType, + 'encoding' => $bodyPart->contentTransferEncoding, + 'writer' => static function (callable $streamWrite) use ($bodyPart): void { + $streamWrite($bodyPart->body); + }, + ]; + } + + /** + * @param list $parts + */ + private function renderMultipart(string $boundary, array $parts): string + { + $body = ''; + foreach ($parts as $part) { + $body .= sprintf('--%s\r\n%s\r\n', $boundary, $part->render()); + } + + return $body . sprintf('--%s--\r\n', $boundary); + } + + /** + * @param list $attachments + * @return array{0:list,1:list} + */ + private function splitAttachments(array $attachments): array + { + $inline = []; + $regular = []; + + foreach ($attachments as $attachment) { + if ($attachment->isInline()) { + $inline[] = $attachment; + + continue; + } + + $regular[] = $attachment; + } + + return [$inline, $regular]; + } + + /** + * @param list $attachments + */ + private function wrapMultipart(MimeMessage $rootPart, array $attachments, string $multipartType): MimeMessage + { + $boundary = MimeBoundary::generate(); + $body = sprintf( + "--%s\r\n%s\r\n\r\n%s\r\n", + $boundary, + 'Content-Type: ' . $rootPart->contentType, + $rootPart->body, + ); + + foreach ($attachments as $attachment) { + $body .= sprintf('--%s\r\n%s\r\n', $boundary, $this->attachmentEncoder->encode($attachment)->render()); + } + + $body .= sprintf('--%s--\r\n', $boundary); + + return new MimeMessage( + sprintf('multipart/%s; boundary="%s"', $multipartType, $boundary), + $body, + ); + } + + /** + * @param array{contentType:string,encoding:?ContentTransferEncoding,writer:callable(callable(string):void):void} $state + * @param list $attachments + * @return array{contentType:string,encoding:?ContentTransferEncoding,writer:callable(callable(string):void):void} + */ + private function wrapStreamState(array $state, array $attachments, string $multipartType): array + { + $boundary = MimeBoundary::generate(); + $previousContentType = $state['contentType']; + $previousEncoding = $state['encoding']; + $previousWriter = $state['writer']; + + return [ + 'contentType' => sprintf('multipart/%s; boundary="%s"', $multipartType, $boundary), + 'encoding' => null, + 'writer' => function (callable $streamWrite) use ( + $boundary, + $previousContentType, + $previousEncoding, + $previousWriter, + $attachments, + ): void { + $this->writeMultipartBoundary($streamWrite, $boundary); + $this->writePartHeaders($streamWrite, $previousContentType, $previousEncoding); + $previousWriter($streamWrite); + $streamWrite("\r\n"); + + foreach ($attachments as $attachment) { + $this->writeMultipartBoundary($streamWrite, $boundary); + $this->attachmentEncoder->encodeToStream($attachment, $streamWrite, includeHeaders: true); + $streamWrite("\r\n"); + } + + $this->writeMultipartClosingBoundary($streamWrite, $boundary); + }, + ]; + } + + /** + * @param callable(string):void $write + */ + private function writeMultipartBoundary(callable $write, string $boundary): void + { + $write(sprintf("--%s\r\n", $boundary)); + } + + /** + * @param callable(string):void $write + */ + private function writeMultipartClosingBoundary(callable $write, string $boundary): void + { + $write(sprintf("--%s--\r\n", $boundary)); + } + + /** + * @param callable(string):void $write + */ + private function writePartHeaders( + callable $write, + string $contentType, + ?ContentTransferEncoding $encoding, + ): void { + $write('Content-Type: ' . $contentType . "\r\n"); + if ($encoding !== null) { + $write('Content-Transfer-Encoding: ' . $encoding->value . "\r\n"); + } + + $write("\r\n"); + } +} diff --git a/src/Email/System/MimePart.php b/src/Email/System/MimePart.php new file mode 100644 index 0000000..dc2d65d --- /dev/null +++ b/src/Email/System/MimePart.php @@ -0,0 +1,21 @@ + $headers + */ + public function __construct( + private array $headers, + private string $body, + ) {} + + public function render(): string + { + return implode("\r\n", $this->headers) . "\r\n\r\n" . $this->body; + } +} diff --git a/src/Email/System/RawEmailBuilder.php b/src/Email/System/RawEmailBuilder.php new file mode 100644 index 0000000..0e0ff58 --- /dev/null +++ b/src/Email/System/RawEmailBuilder.php @@ -0,0 +1,137 @@ +mimeMessageBuilder->build($message); + $headers = $this->normalizeLineEndings($this->headerBuilder->build($message, $mimeMessage, $includeSubject)); + $body = $this->normalizeLineEndings($mimeMessage->body); + $raw = $headers . "\r\n\r\n" . $body; + + return new RawEmailMessage($headers, $body, $raw, strlen($raw)); + } + + /** + * @param callable(string):void $write + */ + public function buildToStream( + EmailMessage $message, + callable $write, + bool $includeSubject = true, + ?int $maxBytes = null, + ): int { + $bodyStream = fopen('php://temp', 'w+b'); + if (!is_resource($bodyStream)) { + throw new \RuntimeException('Unable to open temporary stream for raw email body.'); + } + + $bodySizeBytes = 0; + + try { + $mimeMessage = $this->mimeMessageBuilder->buildToStream( + $message, + function (string $chunk) use (&$bodySizeBytes, $bodyStream): void { + $normalizedChunk = $this->normalizeLineEndings($chunk); + if ($normalizedChunk === '') { + return; + } + + $written = fwrite($bodyStream, $normalizedChunk); + if ($written === false || $written !== strlen($normalizedChunk)) { + throw new \RuntimeException('Unable to write temporary raw email body chunk.'); + } + + $bodySizeBytes += $written; + }, + ); + $headers = $this->normalizeLineEndings($this->headerBuilder->build($message, $mimeMessage, $includeSubject)); + } catch (\Throwable $exception) { + fclose($bodyStream); + + throw $exception; + } + + $sizeBytes = 0; + $this->writeWithLimit($headers, $write, $sizeBytes, $maxBytes); + $this->writeWithLimit("\r\n\r\n", $write, $sizeBytes, $maxBytes); + + rewind($bodyStream); + while (!feof($bodyStream)) { + $chunk = fread($bodyStream, 8192); + if ($chunk === false || $chunk === '') { + continue; + } + + $this->writeWithLimit($chunk, $write, $sizeBytes, $maxBytes); + } + + fclose($bodyStream); + + return $sizeBytes; + } + + /** + * @return array{sizeBytes:int,containsNonAscii:bool,messageId:?string} + */ + public function inspect(EmailMessage $message, bool $includeSubject = true): array + { + $sizeBytes = 0; + $containsNonAscii = false; + + $this->buildToStream( + $message, + static function (string $chunk) use (&$sizeBytes, &$containsNonAscii): void { + $sizeBytes += strlen($chunk); + + if (!$containsNonAscii && preg_match('/[^\x00-\x7F]/', $chunk) === 1) { + $containsNonAscii = true; + } + }, + $includeSubject, + ); + + return [ + 'sizeBytes' => $sizeBytes, + 'containsNonAscii' => $containsNonAscii, + 'messageId' => $this->headerBuilder->resolveMessageId($message), + ]; + } + + private function normalizeLineEndings(string $value): string + { + return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $value)); + } + + /** + * @param callable(string):void $write + */ + private function writeWithLimit(string $chunk, callable $write, int &$sizeBytes, ?int $maxBytes): void + { + if ($chunk === '') { + return; + } + + $nextSize = $sizeBytes + strlen($chunk); + if ($maxBytes !== null && $nextSize > $maxBytes) { + throw new \RuntimeException(sprintf( + 'Raw email size exceeds configured limit (%d bytes).', + $maxBytes, + )); + } + + $write($chunk); + $sizeBytes = $nextSize; + } +} diff --git a/src/Email/System/RawEmailMessage.php b/src/Email/System/RawEmailMessage.php new file mode 100644 index 0000000..0826c49 --- /dev/null +++ b/src/Email/System/RawEmailMessage.php @@ -0,0 +1,15 @@ + false, 'error' => null]; - - public function __construct(private array $from, private array $smtpConfig) - { - } - - public function send(array $to, string $message, string $headers) - { - return $this->sendViaSMTP($to, $message, $headers); - } - - private function sendViaSMTP($to, $message, $headers) - { - $connection = $this->openConnection(); - if (!$connection) { - return false; - } - - if (!$this->initializeConnection($connection)) { - return false; - } - - if (!$this->authenticate($connection)) { - return false; - } - - if (!$this->sendEmail($connection, $to, $headers, $message)) { - return false; - } - - $this->closeConnection($connection); - $this->status['sent'] = true; - return $this->status; - } - - private function openConnection() - { - $smtpHost = $this->smtpConfig['host']; - $smtpPort = $this->smtpConfig['port']; - $connection = @fsockopen($smtpHost, $smtpPort, $errno, $errstr, 30); - - if (!$connection) { - $this->status['error'] = "Failed to connect to SMTP server: $errstr ($errno)"; - return false; - } - - return $connection; - } - - private function initializeConnection($connection) - { - $smtpSecure = $this->smtpConfig['secure'] ?? 'none'; - $smtpHost = $this->smtpConfig['host']; - - if (!$this->getServerResponse($connection, 'initializeConnection')) { - return false; - } - - switch ($smtpSecure) { - case 'tls': - fwrite($connection, "EHLO $smtpHost\r\n"); - if (!$this->getServerResponse($connection, 'EHLO (before STARTTLS)')) { - return false; - } - - fwrite($connection, "STARTTLS\r\n"); - if (!$this->getServerResponse($connection, 'STARTTLS')) { - return false; - } - - if (!stream_socket_enable_crypto($connection, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - $this->status['error'] = "TLS encryption failed."; - return false; - } - - fwrite($connection, "EHLO $smtpHost\r\n"); - if (!$this->getServerResponse($connection, 'EHLO (after STARTTLS)')) { - return false; - } - break; - - case 'ssl': - if (!stream_socket_enable_crypto($connection, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT)) { - $this->status['error'] = "SSL encryption failed."; - return false; - } - - fwrite($connection, "EHLO $smtpHost\r\n"); - if (!$this->getServerResponse($connection, 'EHLO (after SSL)')) { - return false; - } - break; - - default: - fwrite($connection, "EHLO $smtpHost\r\n"); - if (!$this->getServerResponse($connection, 'EHLO (no encryption)')) { - return false; - } - break; - } - - return true; - } - - private function authenticate($connection) - { - $smtpUser = $this->smtpConfig['username']; - $smtpPass = $this->smtpConfig['password']; - - fwrite($connection, "AUTH LOGIN\r\n"); - if (!$this->getServerResponse($connection, 'AUTH LOGIN')) { - return false; - } - - fwrite($connection, base64_encode($smtpUser) . "\r\n"); - if (!$this->getServerResponse($connection, 'username')) { - return false; - } - - fwrite($connection, base64_encode($smtpPass) . "\r\n"); - if (!$this->getServerResponse($connection, 'password')) { - return false; - } - - return true; - } - - private function sendEmail($connection, $to, $headers, $message) - { - // Send MAIL FROM with email - fwrite($connection, "MAIL FROM: <{$this->from['email']}>\r\n"); - if (!$this->getServerResponse($connection, 'MAIL FROM')) { - return false; - } - - // Send RCPT TO for each recipient - foreach ((array)$to as $recipient) { - fwrite($connection, "RCPT TO: <$recipient>\r\n"); - if (!$this->getServerResponse($connection, 'RCPT TO')) { - return false; - } - } - - // Initiate DATA command - fwrite($connection, "DATA\r\n"); - if (!$this->getServerResponse($connection, 'DATA')) { - return false; - } - - // Send headers and message body - fwrite($connection, "$headers\r\n$message\r\n.\r\n"); - if (!$this->getServerResponse($connection, 'message body')) { - return false; - } - - return true; - } - - private function closeConnection($connection) - { - fwrite($connection, "QUIT\r\n"); - fclose($connection); - } - - private function getServerResponse($connection, $stage = 'unknown') - { - $response = fgets($connection, 512); - if (!str_starts_with($response, '250') && !str_starts_with($response, '354')) { - $this->status['error'] = "SMTP Error at stage '$stage': $response"; - return false; - } - return true; - } -} diff --git a/src/Email/System/SmtpCapabilities.php b/src/Email/System/SmtpCapabilities.php new file mode 100644 index 0000000..e80877c --- /dev/null +++ b/src/Email/System/SmtpCapabilities.php @@ -0,0 +1,37 @@ + $values + * @param list $authMechanisms + */ + public function __construct( + public array $values = [], + public array $authMechanisms = [], + ) {} + + public function has(string $name): bool + { + return array_key_exists(strtoupper($name), $this->values); + } + + public function sizeLimit(): ?int + { + $value = $this->values['SIZE'] ?? null; + + if ($value === null || $value === '') { + return null; + } + + if (!ctype_digit($value)) { + return null; + } + + return (int) $value; + } +} diff --git a/src/Email/System/SmtpCapabilityParser.php b/src/Email/System/SmtpCapabilityParser.php new file mode 100644 index 0000000..c6d78ad --- /dev/null +++ b/src/Email/System/SmtpCapabilityParser.php @@ -0,0 +1,80 @@ + $responseLines + */ + public function parse(array $responseLines): SmtpCapabilities + { + $values = []; + $authMechanisms = []; + + foreach ($responseLines as $line) { + $parsed = $this->parseLine($line); + if ($parsed === null) { + continue; + } + + [$name, $tail] = $parsed; + + $values[$name] = $tail; + + if ($name === 'AUTH') { + $authMechanisms = $this->parseAuthMechanisms($tail); + } + } + + return new SmtpCapabilities($values, $authMechanisms); + } + + /** + * @return list + */ + private function parseAuthMechanisms(string $tail): array + { + if ($tail === '') { + return []; + } + + return preg_split('/\s+/', strtoupper($tail)) ?: []; + } + + /** + * @return array{0:string,1:string}|null + */ + private function parseLine(string $line): ?array + { + if (preg_match('/^\d{3}[\s-](.+)$/', trim($line), $matches) !== 1) { + return null; + } + + $capability = trim($matches[1]); + if ($capability === '' || str_starts_with(strtoupper($capability), 'HELLO ')) { + return null; + } + + [$name, $tail] = $this->splitCapability($capability); + if (preg_match('/^[A-Z0-9-]+$/', $name) !== 1) { + return null; + } + + return [$name, $tail]; + } + + /** + * @return array{0:string,1:string} + */ + private function splitCapability(string $line): array + { + $parts = preg_split('/\s+/', $line, 2) ?: []; + $name = strtoupper($parts[0] ?? ''); + $tail = $parts[1] ?? ''; + + return [$name, strtoupper(trim($tail))]; + } +} diff --git a/src/Email/System/SmtpEnvelopePlanner.php b/src/Email/System/SmtpEnvelopePlanner.php new file mode 100644 index 0000000..4a2706c --- /dev/null +++ b/src/Email/System/SmtpEnvelopePlanner.php @@ -0,0 +1,134 @@ +messageRequiresSmtpUtf8($message); + + if ($this->config->utf8Policy === SmtpUtf8Policy::Reject && $requiresSmtpUtf8) { + throw new RuntimeException('SMTPUTF8 addresses are not allowed by current policy.'); + } + + if ($this->config->utf8Policy === SmtpUtf8Policy::Require && !$capabilities->has('SMTPUTF8')) { + throw new RuntimeException('SMTPUTF8 is required by configuration but not supported by server.'); + } + + if ($requiresSmtpUtf8 && !$capabilities->has('SMTPUTF8')) { + throw new RuntimeException('Message requires SMTPUTF8, but server does not advertise SMTPUTF8.'); + } + + return $requiresSmtpUtf8; + } + + public function buildMailFromCommand( + string $senderEmail, + EmailHeaders $headers, + SmtpCapabilities $capabilities, + int $messageSizeBytes, + ?int $sizeLimit, + bool $messageContainsNonAscii, + bool $requiresSmtpUtf8, + ): string { + $mailFromArgs = []; + + if ($capabilities->has('DSN')) { + $mailFromArgs[] = 'RET=' . ($headers->dsnReturnFull ? 'FULL' : 'HDRS'); + $mailFromArgs[] = 'ENVID=' . ($headers->dsnEnvelopeId ?? bin2hex(random_bytes(8))); + } + + if ($sizeLimit !== null) { + $mailFromArgs[] = 'SIZE=' . $messageSizeBytes; + } + + if ($requiresSmtpUtf8 && $capabilities->has('SMTPUTF8')) { + $mailFromArgs[] = 'SMTPUTF8'; + } + + if ($this->shouldUseEightBitMime($messageContainsNonAscii, $capabilities)) { + $mailFromArgs[] = 'BODY=8BITMIME'; + } + + $mailFrom = sprintf('MAIL FROM:<%s>', $senderEmail); + if ($mailFromArgs !== []) { + $mailFrom .= ' ' . implode(' ', $mailFromArgs); + } + + return $mailFrom; + } + + public function buildRcptCommand(string $recipientEmail, EmailHeaders $headers, SmtpCapabilities $capabilities): string + { + $rcptCommand = sprintf('RCPT TO:<%s>', $recipientEmail); + + if ($capabilities->has('DSN')) { + $notify = $this->dsnNotifyDirective($headers); + if ($notify !== null) { + $rcptCommand .= ' NOTIFY=' . $notify; + } + } + + return $rcptCommand; + } + + private function containsNonAscii(string $value): bool + { + return preg_match('/[^\x00-\x7F]/', $value) === 1; + } + + private function dsnNotifyDirective(EmailHeaders $headers): ?string + { + $notifyFlags = []; + + if ($headers->dsnNotifySuccess) { + $notifyFlags[] = 'SUCCESS'; + } + + if ($headers->dsnNotifyFailure) { + $notifyFlags[] = 'FAILURE'; + } + + if ($headers->dsnNotifyDelay) { + $notifyFlags[] = 'DELAY'; + } + + if ($notifyFlags === []) { + return null; + } + + return implode(',', $notifyFlags); + } + + private function messageRequiresSmtpUtf8(EmailMessage $message): bool + { + $from = $message->envelope()->from; + if ($from !== null && $this->containsNonAscii($from->email)) { + return true; + } + + return array_any($message->envelope()->recipients(), fn($recipient) => $this->containsNonAscii($recipient->email)); + } + + private function shouldUseEightBitMime(bool $messageContainsNonAscii, SmtpCapabilities $capabilities): bool + { + if (!$this->config->allowEightBitMime || !$capabilities->has('8BITMIME')) { + return false; + } + + return $messageContainsNonAscii; + } +} diff --git a/src/Email/System/StreamingBase64Encoder.php b/src/Email/System/StreamingBase64Encoder.php new file mode 100644 index 0000000..ddb5ca1 --- /dev/null +++ b/src/Email/System/StreamingBase64Encoder.php @@ -0,0 +1,48 @@ + $maxInputBytes) { + throw new \RuntimeException(sprintf( + 'Attachment stream exceeds max size (%d bytes).', + $maxInputBytes, + )); + } + + $data = $carry . $chunk; + $completeLength = intdiv(strlen($data), 57) * 57; + + if ($completeLength > 0) { + $write(chunk_split(base64_encode(substr($data, 0, $completeLength)), 76, "\r\n")); + } + + $carry = substr($data, $completeLength); + } + + if ($carry !== '') { + $write(chunk_split(base64_encode($carry), 76, "\r\n")); + } + + return $bytesRead; + } +} diff --git a/src/Email/Template/ArrayVariableRenderer.php b/src/Email/Template/ArrayVariableRenderer.php new file mode 100644 index 0000000..ffa8124 --- /dev/null +++ b/src/Email/Template/ArrayVariableRenderer.php @@ -0,0 +1,22 @@ + $variables + */ + public function render(string $template, array $variables): string + { + $replace = []; + + foreach ($variables as $key => $value) { + $replace['{{' . $key . '}}'] = $value === null ? '' : (string) $value; + } + + return strtr($template, $replace); + } +} diff --git a/src/Email/Template/TemplateRendererInterface.php b/src/Email/Template/TemplateRendererInterface.php new file mode 100644 index 0000000..efda7a4 --- /dev/null +++ b/src/Email/Template/TemplateRendererInterface.php @@ -0,0 +1,13 @@ + $variables + */ + public function render(string $template, array $variables): string; +} diff --git a/src/Email/Testing/AssertableEmailTransport.php b/src/Email/Testing/AssertableEmailTransport.php new file mode 100644 index 0000000..dad8278 --- /dev/null +++ b/src/Email/Testing/AssertableEmailTransport.php @@ -0,0 +1,85 @@ +assertSentWhere(static fn(EmailMessage $message): bool => array_any($message->attachments(), fn($attachment) => $attachment->name === $filename && !$attachment->isInline()), sprintf('Expected an email with attachment "%s".', $filename)); + } + + public function assertHasInlineAttachment(string $contentId): void + { + $this->assertSentWhere(static fn(EmailMessage $message): bool => array_any($message->attachments(), fn($attachment) => $attachment->isInline() && trim((string) $attachment->contentId, '<>') === trim($contentId, '<>')), sprintf('Expected an email with inline attachment "%s".', $contentId)); + } + + public function assertNothingSent(): void + { + if ($this->fakeTransport->sentMessages() !== []) { + throw new RuntimeException('Expected no email to be sent, but at least one message was sent.'); + } + } + + public function assertSent(): void + { + if ($this->fakeTransport->sentMessages() === []) { + throw new RuntimeException('Expected at least one email to be sent.'); + } + } + + public function assertSentCount(int $count): void + { + $actual = count($this->fakeTransport->sentMessages()); + + if ($actual !== $count) { + throw new RuntimeException(sprintf('Expected %d sent email(s), got %d.', $count, $actual)); + } + } + + public function assertSentFrom(string $email): void + { + $this->assertSentWhere( + static fn(EmailMessage $message): bool => $message->envelope()->from?->email === $email, + sprintf('Expected an email sent from "%s".', $email), + ); + } + + public function assertSentSubject(string $subject): void + { + $this->assertSentWhere( + static fn(EmailMessage $message): bool => $message->headersData()->subject === $subject, + sprintf('Expected an email with subject "%s".', $subject), + ); + } + + public function assertSentTo(string $email): void + { + $this->assertSentWhere(static fn(EmailMessage $message): bool => array_any($message->envelope()->recipients(), fn($recipient) => $recipient->email === $email), sprintf('Expected an email sent to "%s".', $email)); + } + + public function assertSentWhere(callable $predicate, string $message = 'Expected a sent email matching predicate.'): void + { + foreach ($this->fakeTransport->sentMessages() as $sentMessage) { + if ($predicate($sentMessage) === true) { + return; + } + } + + throw new RuntimeException($message); + } + + public function lastMessage(): ?EmailMessage + { + $messages = $this->fakeTransport->sentMessages(); + + return $messages === [] ? null : $messages[array_key_last($messages)]; + } +} diff --git a/src/Email/Testing/FakeEmailTransport.php b/src/Email/Testing/FakeEmailTransport.php new file mode 100644 index 0000000..7d0dbcc --- /dev/null +++ b/src/Email/Testing/FakeEmailTransport.php @@ -0,0 +1,55 @@ + + */ + private array $queuedResults = []; + + /** + * @var list + */ + private array $sentMessages = []; + + public function pushResult(CommunicationResult $result): self + { + $this->queuedResults[] = $result; + + return $this; + } + + public function send(EmailMessage $message): CommunicationResult + { + $message->assertReadyToSend(); + $this->sentMessages[] = $message; + + if ($this->queuedResults === []) { + return CommunicationResult::success(metadata: ['transport' => 'fake-email']); + } + + return array_shift($this->queuedResults); + } + + /** + * @return list + */ + public function sentMessages(): array + { + return $this->sentMessages; + } + + public function wasSent(Closure $predicate): bool + { + return array_any($this->sentMessages, fn($message) => $predicate($message) === true); + } +} diff --git a/src/Email/Testing/SpyEmailTransport.php b/src/Email/Testing/SpyEmailTransport.php new file mode 100644 index 0000000..7d80659 --- /dev/null +++ b/src/Email/Testing/SpyEmailTransport.php @@ -0,0 +1,34 @@ + + */ + private array $sentMessages = []; + + public function __construct(private readonly EmailTransport $innerTransport) {} + + public function send(EmailMessage $message): CommunicationResult + { + $this->sentMessages[] = $message; + + return $this->innerTransport->send($message); + } + + /** + * @return list + */ + public function sentMessages(): array + { + return $this->sentMessages; + } +} diff --git a/src/Email/Transport/AbstractRawEmailTransport.php b/src/Email/Transport/AbstractRawEmailTransport.php new file mode 100644 index 0000000..310d134 --- /dev/null +++ b/src/Email/Transport/AbstractRawEmailTransport.php @@ -0,0 +1,16 @@ + $fallbackTransports + */ + public function __construct( + private EmailTransport $primaryTransport, + private array $fallbackTransports = [], + ) {} + + public function send(EmailMessage $message): CommunicationResult + { + $attemptedTransports = []; + + $primaryResult = $this->primaryTransport->send($message); + $attemptedTransports[] = $this->primaryTransport::class; + + if ($primaryResult->successful) { + return $primaryResult; + } + + foreach ($this->fallbackTransports as $transport) { + $attemptedTransports[] = $transport::class; + $result = $transport->send($message); + + if ($result->successful) { + $metadata = $result->metadata; + $metadata['fallback_used'] = true; + $metadata['attempted_transports'] = $attemptedTransports; + + return new CommunicationResult( + true, + $result->statusCode, + null, + $result->response, + $metadata, + ); + } + + $primaryResult = $result; + } + + $metadata = $primaryResult->metadata; + $metadata['attempted_transports'] = $attemptedTransports; + + return CommunicationResult::failure( + $primaryResult->error ?? 'All configured transports failed.', + statusCode: $primaryResult->statusCode, + response: $primaryResult->response, + metadata: $metadata, + ); + } +} diff --git a/src/Email/Transport/DkimSigningTransport.php b/src/Email/Transport/DkimSigningTransport.php new file mode 100644 index 0000000..a0dc303 --- /dev/null +++ b/src/Email/Transport/DkimSigningTransport.php @@ -0,0 +1,30 @@ +rawEmailBuilder->build($message, includeSubject: true); + $dkimHeader = $this->signer->buildSignatureHeader($raw->headers, $raw->body, $this->config); + $dkimValue = trim(substr($dkimHeader, strlen('DKIM-Signature:'))); + + return $this->innerTransport->send($message->header('DKIM-Signature', $dkimValue)); + } +} diff --git a/src/Email/Transport/EmailTransport.php b/src/Email/Transport/EmailTransport.php new file mode 100644 index 0000000..66ad2eb --- /dev/null +++ b/src/Email/Transport/EmailTransport.php @@ -0,0 +1,13 @@ + $recipients + * @param array $metadata + */ + public static function failure( + string $transport, + ?string $messageId, + string $error, + array $recipients, + array $metadata = [], + ): CommunicationResult { + $result = new EmailSendResult( + $transport, + self::normalizeMessageId($messageId), + [], + array_fill_keys($recipients, $error), + $metadata + ['transport' => $transport], + ); + + return CommunicationResult::failure($error, response: $result, metadata: $result->metadata); + } + + /** + * @param list $acceptedRecipients + * @param array $metadata + */ + public static function success( + string $transport, + ?string $messageId, + array $acceptedRecipients, + array $metadata = [], + ): CommunicationResult { + $result = new EmailSendResult($transport, self::normalizeMessageId($messageId), $acceptedRecipients, [], $metadata + ['transport' => $transport]); + + return CommunicationResult::success(response: $result, metadata: $result->metadata); + } + + private static function normalizeMessageId(?string $messageId): string + { + $trimmed = trim((string) $messageId); + if ($trimmed !== '') { + return $trimmed; + } + + return sprintf('<%s@talkingbytes.local>', bin2hex(random_bytes(16))); + } +} diff --git a/src/Email/Transport/FallbackEmailTransport.php b/src/Email/Transport/FallbackEmailTransport.php new file mode 100644 index 0000000..b5e7bf4 --- /dev/null +++ b/src/Email/Transport/FallbackEmailTransport.php @@ -0,0 +1,26 @@ + $fallbackTransports + */ + public function __construct(EmailTransport $primaryTransport, array $fallbackTransports = []) + { + $this->composite = new CompositeEmailTransport($primaryTransport, $fallbackTransports); + } + + public function send(EmailMessage $message): CommunicationResult + { + return $this->composite->send($message); + } +} diff --git a/src/Email/Transport/LogEmailTransport.php b/src/Email/Transport/LogEmailTransport.php new file mode 100644 index 0000000..fdd7710 --- /dev/null +++ b/src/Email/Transport/LogEmailTransport.php @@ -0,0 +1,109 @@ +assertReadyToSend(); + + $rawEmail = $this->rawEmailBuilder->build($message, includeSubject: true); + if ( + $this->config->maxMessageBytes !== null + && $rawEmail->sizeBytes > $this->config->maxMessageBytes + ) { + return CommunicationResult::failure(sprintf( + 'Email size %d bytes exceeds configured log max message size %d bytes.', + $rawEmail->sizeBytes, + $this->config->maxMessageBytes, + )); + } + + $messageId = $this->extractMessageId($rawEmail->headers) ?? $this->headerBuilder->resolveMessageId($message); + $path = ''; + $written = false; + + $recipients = array_map(static fn($address): string => $address->email, $message->envelope()->recipients()); + + try { + $path = $this->resolvePath(); + $written = $this->writeLog($path, $rawEmail->raw); + } catch (RuntimeException $exception) { + return EmailTransportResultFactory::failure( + 'log-email', + $messageId, + $exception->getMessage(), + $recipients, + ['log_path' => $path], + ); + } + + if (!$written) { + return EmailTransportResultFactory::failure( + 'log-email', + $messageId, + sprintf('Unable to write email log file: %s', $path), + $recipients, + ['log_path' => $path], + ); + } + + return EmailTransportResultFactory::success('log-email', $messageId, $recipients, [ + 'log_path' => $path, + 'size_bytes' => $rawEmail->sizeBytes, + ]); + } + + private function extractMessageId(string $headers): ?string + { + if (preg_match('/^Message-ID:\s*(.+)$/mi', $headers, $matches) !== 1) { + return null; + } + + return trim($matches[1]); + } + + private function resolvePath(): string + { + if (file_exists($this->config->directory) && !is_dir($this->config->directory)) { + throw new RuntimeException(sprintf('Log path is not a directory: %s', $this->config->directory)); + } + + if (!is_dir($this->config->directory) && !mkdir($this->config->directory, 0775, true) && !is_dir($this->config->directory)) { + throw new RuntimeException(sprintf('Unable to create log directory: %s', $this->config->directory)); + } + + if (!is_writable($this->config->directory)) { + throw new RuntimeException(sprintf('Log directory is not writable: %s', $this->config->directory)); + } + + $suffix = $this->config->dailyFiles + ? new DateTimeImmutable()->format('Y-m-d') + : 'emails'; + + return rtrim($this->config->directory, '/\\') . '/' . $this->config->filenamePrefix . '-' . $suffix . '.log'; + } + + private function writeLog(string $path, string $rawEmail): bool + { + $payload = sprintf("----- %s -----\n%s\n\n", new DateTimeImmutable()->format(DATE_ATOM), $rawEmail); + + return file_put_contents($path, $payload, FILE_APPEND | LOCK_EX) !== false; + } +} diff --git a/src/Email/Transport/LoggingEmailTransport.php b/src/Email/Transport/LoggingEmailTransport.php new file mode 100644 index 0000000..41d6dbc --- /dev/null +++ b/src/Email/Transport/LoggingEmailTransport.php @@ -0,0 +1,50 @@ +):void $logger + */ + public function __construct( + private EmailTransport $innerTransport, + private mixed $logger, + ) {} + + public function send(EmailMessage $message): CommunicationResult + { + ($this->logger)('email.send.start', [ + 'to_count' => count($message->envelope()->to), + 'cc_count' => count($message->envelope()->cc), + 'bcc_count' => count($message->envelope()->bcc), + 'subject' => $message->headersData()->subject, + ]); + + try { + $result = $this->innerTransport->send($message); + } catch (Throwable $throwable) { + ($this->logger)('email.send.finish', [ + 'successful' => false, + 'error' => $throwable->getMessage(), + 'metadata' => [], + ]); + + throw $throwable; + } + + ($this->logger)('email.send.finish', [ + 'successful' => $result->successful, + 'error' => $result->error, + 'metadata' => $result->metadata, + ]); + + return $result; + } +} diff --git a/src/Email/Transport/MailFunctionTransport.php b/src/Email/Transport/MailFunctionTransport.php new file mode 100644 index 0000000..06d3a01 --- /dev/null +++ b/src/Email/Transport/MailFunctionTransport.php @@ -0,0 +1,94 @@ +maxMessageBytes !== null && $this->maxMessageBytes < 1) { + throw new \InvalidArgumentException('mail() max message bytes must be greater than zero when provided.'); + } + } + + public function send(EmailMessage $message): CommunicationResult + { + $message->assertReadyToSend(); + + $rawEmail = $this->rawEmailBuilder->build($message, includeSubject: false); + if ($this->maxMessageBytes !== null && $rawEmail->sizeBytes > $this->maxMessageBytes) { + return CommunicationResult::failure(sprintf( + 'Email size %d bytes exceeds configured mail() max message size %d bytes.', + $rawEmail->sizeBytes, + $this->maxMessageBytes, + )); + } + + $messageId = $this->extractMessageId($rawEmail->headers); + $subject = $this->addressFormatter->encodeMimeHeader($message->headersData()->subject); + + $recipients = array_map( + static fn($address): string => $address->email, + $message->envelope()->recipients(), + ); + + $sent = mail( + implode(',', $recipients), + $subject, + $rawEmail->body, + $rawEmail->headers, + $this->envelopeSenderParameter($message), + ); + + $result = new EmailSendResult( + 'mail-function', + $messageId, + $sent ? $recipients : [], + $sent ? [] : array_fill_keys($recipients, 'mail() transport failed.'), + [ + 'transport' => 'mail-function', + 'size_bytes' => $rawEmail->sizeBytes, + ], + ); + + if (!$sent) { + return CommunicationResult::failure( + 'mail() transport failed.', + response: $result, + metadata: $result->metadata, + ); + } + + return CommunicationResult::success(response: $result, metadata: $result->metadata); + } + + private function envelopeSenderParameter(EmailMessage $message): string + { + $sender = $message->envelope()->envelopeSender(); + if ($sender === null) { + return ''; + } + + return sprintf('-f %s', escapeshellarg($sender->email)); + } + + private function extractMessageId(string $headers): ?string + { + if (preg_match('/^Message-ID:\s*(.+)$/mi', $headers, $matches) !== 1) { + return null; + } + + return trim($matches[1]); + } +} diff --git a/src/Email/Transport/NullEmailTransport.php b/src/Email/Transport/NullEmailTransport.php new file mode 100644 index 0000000..b5c5dad --- /dev/null +++ b/src/Email/Transport/NullEmailTransport.php @@ -0,0 +1,34 @@ +assertReadyToSend(); + + $recipients = array_map(static fn($address): string => $address->email, $message->envelope()->recipients()); + $result = new EmailSendResult( + 'null-email', + $this->headerBuilder->resolveMessageId($message), + $recipients, + [], + [ + 'transport' => 'null-email', + 'recipient_count' => count($recipients), + ], + ); + + return CommunicationResult::success(response: $result, metadata: $result->metadata); + } +} diff --git a/src/Email/Transport/RateLimitedEmailTransport.php b/src/Email/Transport/RateLimitedEmailTransport.php new file mode 100644 index 0000000..06c12c6 --- /dev/null +++ b/src/Email/Transport/RateLimitedEmailTransport.php @@ -0,0 +1,24 @@ +rateLimiter->assertCanProceed(); + + return $this->innerTransport->send($message); + } +} diff --git a/src/Email/Transport/RetryEmailTransport.php b/src/Email/Transport/RetryEmailTransport.php new file mode 100644 index 0000000..b902fa5 --- /dev/null +++ b/src/Email/Transport/RetryEmailTransport.php @@ -0,0 +1,23 @@ +retryPolicy, fn(): CommunicationResult => $this->innerTransport->send($message)); + } +} diff --git a/src/Email/Transport/SendmailTransport.php b/src/Email/Transport/SendmailTransport.php new file mode 100644 index 0000000..76b79f2 --- /dev/null +++ b/src/Email/Transport/SendmailTransport.php @@ -0,0 +1,218 @@ +assertReadyToSend(); + + if (!is_executable($this->config->path)) { + return CommunicationResult::failure(sprintf('Sendmail binary is not executable: %s', $this->config->path)); + } + + $recipients = array_map(static fn($address): string => $address->email, $message->envelope()->recipients()); + $messageId = $this->headerBuilder->resolveMessageId($message); + + try { + $sizeBytes = $this->executeSendmail($message); + } catch (RuntimeException $exception) { + return EmailTransportResultFactory::failure( + 'sendmail', + $messageId, + $exception->getMessage(), + $recipients, + ); + } + + return EmailTransportResultFactory::success('sendmail', $messageId, $recipients, ['size_bytes' => $sizeBytes]); + } + + /** + * @return list + */ + private function buildCommand(EmailMessage $message): array + { + $command = [$this->config->path, ...$this->config->extraArguments]; + + $sender = $message->envelope()->envelopeSender(); + if ($sender !== null) { + $command[] = sprintf('-f%s', $sender->email); + } + + return $command; + } + + /** + * @param array $pipes + */ + private function closePipes(array $pipes): void + { + foreach ($pipes as $pipe) { + if (!is_resource($pipe)) { + continue; + } + + fclose($pipe); + } + } + + /** + * @param resource $process + */ + private function closeProcess($process, bool $alreadyClosed): void + { + if ($alreadyClosed || !is_resource($process)) { + return; + } + + proc_terminate($process); + proc_close($process); + } + + /** + * @return array + */ + private function descriptorSpec(): array + { + return [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + } + + private function executeSendmail(EmailMessage $message): int + { + $process = proc_open($this->buildCommand($message), $this->descriptorSpec(), $pipes); + + if (!is_resource($process)) { + throw new RuntimeException('Unable to open sendmail process.'); + } + + $processClosed = false; + $sizeBytes = 0; + + try { + $sizeBytes = $this->writeToStdin($pipes[0], $message); + fclose($pipes[0]); + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + [$exitCode, $stdout, $stderr] = $this->readProcessOutputUntilExit( + $process, + $pipes[1], + $pipes[2], + $this->config->timeoutSeconds, + ); + $processClosed = true; + } finally { + $this->closePipes($pipes); + $this->closeProcess($process, $processClosed); + } + + if ($exitCode !== 0) { + $detail = trim(($stderr ?: $stdout) ?: 'unknown error'); + + throw new RuntimeException(sprintf('Sendmail exited with code %d: %s', $exitCode, $detail)); + } + + return $sizeBytes; + } + + /** + * @param resource $process + * @param resource $stdout + * @param resource $stderr + * @return array{0:int,1:string,2:string} + */ + private function readProcessOutputUntilExit($process, $stdout, $stderr, int $timeoutSeconds): array + { + $start = microtime(true); + $stdoutBuffer = ''; + $stderrBuffer = ''; + $terminated = false; + + while (true) { + $status = proc_get_status($process); + $stdoutBuffer .= stream_get_contents($stdout) ?: ''; + $stderrBuffer .= stream_get_contents($stderr) ?: ''; + + if ($status['running'] !== true) { + break; + } + + if ((microtime(true) - $start) > $timeoutSeconds) { + $terminated = true; + proc_terminate($process); + usleep(100000); + + break; + } + + usleep(10000); + } + + $stdoutBuffer .= stream_get_contents($stdout) ?: ''; + $stderrBuffer .= stream_get_contents($stderr) ?: ''; + + $exitCode = proc_close($process); + + if ($terminated) { + throw new RuntimeException(sprintf('Sendmail process timed out after %d seconds.', $timeoutSeconds)); + } + + return [$exitCode, $stdoutBuffer, $stderrBuffer]; + } + + /** + * @param resource $stdin + */ + private function writeChunk($stdin, string $chunk): void + { + $length = strlen($chunk); + $written = 0; + + while ($written < $length) { + $current = fwrite($stdin, substr($chunk, $written)); + + if ($current === false || $current === 0) { + throw new RuntimeException('Unable to write email payload to sendmail process.'); + } + + $written += $current; + } + } + + /** + * @param resource $stdin + */ + private function writeToStdin($stdin, EmailMessage $message): int + { + return $this->rawEmailBuilder->buildToStream( + $message, + function (string $chunk) use ($stdin): void { + $this->writeChunk($stdin, $chunk); + }, + includeSubject: true, + maxBytes: $this->config->maxMessageBytes, + ); + } +} diff --git a/src/Email/Transport/SmtpTransport.php b/src/Email/Transport/SmtpTransport.php new file mode 100644 index 0000000..16ef2e3 --- /dev/null +++ b/src/Email/Transport/SmtpTransport.php @@ -0,0 +1,770 @@ +assertReadyToSend(); + + $inspection = $this->rawEmailBuilder->inspect($message, includeSubject: true); + if ( + $this->config->maxMessageBytes !== null + && $inspection['sizeBytes'] > $this->config->maxMessageBytes + ) { + return CommunicationResult::failure(sprintf( + 'Email size %d bytes exceeds configured SMTP max message size %d bytes.', + $inspection['sizeBytes'], + $this->config->maxMessageBytes, + )); + } + + $messageId = $inspection['messageId']; + $connection = null; + $capabilities = new SmtpCapabilities(); + $start = microtime(true); + $serverGreeting = null; + $authMechanism = null; + $sessionStarted = false; + $report = null; + $transcript = []; + + try { + $connection = $this->openConnection(); + [, $serverGreeting] = $this->expect($connection, [220], 'server greeting', $transcript); + $sessionStarted = true; + + $capabilities = $this->initializeSession($connection, $transcript); + $authMechanism = $this->authenticate($connection, $capabilities, $transcript); + $report = $this->sendEmailData( + $connection, + $message, + $inspection['sizeBytes'], + $inspection['containsNonAscii'], + $capabilities, + $messageId, + $transcript, + ); + $this->write($connection, "QUIT\r\n", $transcript); + + $metadata = $this->buildRuntimeMetadata( + $messageId, + $capabilities, + $start, + $serverGreeting, + $authMechanism, + $report->metadata, + $transcript, + ); + + if (!$report->successful) { + return CommunicationResult::failure( + $report->error ?? 'SMTP delivery failed.', + response: $report, + metadata: $metadata, + ); + } + + return CommunicationResult::success(response: $report, metadata: $metadata); + } catch (RuntimeException $exception) { + $metadata = $this->buildRuntimeMetadata( + $messageId, + $capabilities, + $start, + $serverGreeting, + $authMechanism, + ['error_stage' => 'smtp-send'], + $transcript, + ); + + return CommunicationResult::failure( + $exception->getMessage(), + response: new EmailDeliveryReport( + false, + [], + [], + $messageId, + $exception->getMessage(), + [], + $metadata, + ), + metadata: $metadata, + ); + } finally { + if (is_resource($connection)) { + if ($report === null) { + $this->gracefulClose($connection, $sessionStarted, $transcript); + } + + fclose($connection); + } + } + } + + private function assertSizeWithinLimit(SmtpCapabilities $capabilities, int $messageSizeBytes): ?int + { + $sizeLimit = $capabilities->sizeLimit(); + if ($sizeLimit !== null && $messageSizeBytes > $sizeLimit) { + throw new RuntimeException( + sprintf('Email size %d bytes exceeds SMTP SIZE limit %d bytes.', $messageSizeBytes, $sizeLimit), + ); + } + + return $sizeLimit; + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function authenticate($connection, SmtpCapabilities $capabilities, array &$transcript): ?string + { + if ($this->config->credentials === null) { + return null; + } + + $mechanism = $this->resolveAuthMechanism($capabilities); + + if ($mechanism === SmtpAuthMechanism::Plain) { + $payload = sprintf( + "\0%s\0%s", + $this->config->credentials->username, + $this->config->credentials->password, + ); + + $this->write($connection, 'AUTH PLAIN ' . base64_encode($payload) . "\r\n", $transcript, sensitive: true); + $this->expect($connection, [235], 'AUTH PLAIN success', $transcript); + + return SmtpAuthMechanism::Plain->value; + } + + $this->write($connection, "AUTH LOGIN\r\n", $transcript); + $this->expect($connection, [334], 'AUTH LOGIN challenge', $transcript); + + $this->write($connection, base64_encode($this->config->credentials->username) . "\r\n", $transcript, sensitive: true); + $this->expect($connection, [334], 'AUTH username challenge', $transcript); + + $this->write($connection, base64_encode($this->config->credentials->password) . "\r\n", $transcript, sensitive: true); + $this->expect($connection, [235], 'AUTH success', $transcript); + + return SmtpAuthMechanism::Login->value; + } + + /** + * @param array $metadata + * @param list $transcript + * @return array + */ + private function buildRuntimeMetadata( + ?string $messageId, + SmtpCapabilities $capabilities, + float $startedAt, + ?string $serverGreeting, + ?string $authMechanism, + array $metadata = [], + array $transcript = [], + ): array { + $base = [ + 'transport' => 'smtp', + 'smtp_host' => $this->config->host, + 'smtp_port' => $this->config->port, + 'security' => $this->config->security->value, + 'ehlo_capabilities' => array_keys($capabilities->values), + 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'message_id' => $messageId, + 'auth_mechanism' => $authMechanism, + 'server_greeting' => $serverGreeting !== null ? trim($serverGreeting) : null, + ]; + + if ($this->config->captureTranscript) { + $base['smtp_transcript'] = $transcript; + } + + return array_merge($base, $metadata); + } + + /** + * @param resource $connection + * @param list $expectedCodes + * @param list $transcript + * @return array{0:int,1:string,2:list} + */ + private function expect($connection, array $expectedCodes, string $stage, array &$transcript = []): array + { + [$code, $response, $lines] = $this->readResponse($connection, $transcript); + + if (!in_array($code, $expectedCodes, true)) { + throw new RuntimeException( + sprintf( + 'SMTP error at %s. Expected %s, got %d: %s', + $stage, + implode(', ', $expectedCodes), + $code, + trim($response), + ), + ); + } + + return [$code, $response, $lines]; + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function gracefulClose($connection, bool $sessionStarted, array &$transcript): void + { + if (!$sessionStarted) { + return; + } + + $this->tryWriteAndDrain($connection, "RSET\r\n", $transcript); + $this->tryWriteAndDrain($connection, "QUIT\r\n", $transcript); + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function initializeSession($connection, array &$transcript): SmtpCapabilities + { + $this->write($connection, sprintf("EHLO %s\r\n", $this->config->localDomain), $transcript); + [, , $ehloLines] = $this->expect($connection, [250], 'EHLO', $transcript); + $capabilities = $this->capabilityParser->parse($ehloLines); + + if (!$this->shouldAttemptStartTls()) { + return $capabilities; + } + + $hasStartTls = $capabilities->has('STARTTLS'); + if (!$hasStartTls && $this->requiresStartTls()) { + throw new RuntimeException('SMTP server does not advertise STARTTLS, but STARTTLS is required.'); + } + + if (!$hasStartTls) { + return $capabilities; + } + + $this->write($connection, "STARTTLS\r\n", $transcript); + $this->expect($connection, [220], 'STARTTLS', $transcript); + + if (!stream_socket_enable_crypto($connection, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new RuntimeException('STARTTLS negotiation failed.'); + } + + $this->write($connection, sprintf("EHLO %s\r\n", $this->config->localDomain), $transcript); + [, , $secureEhloLines] = $this->expect($connection, [250], 'EHLO after STARTTLS', $transcript); + + return $this->capabilityParser->parse($secureEhloLines); + } + + /** + * @return resource + */ + private function openConnection() + { + $host = $this->config->security === SmtpSecurity::Ssl + ? sprintf('ssl://%s', $this->config->host) + : $this->config->host; + + $errno = 0; + $errstr = ''; + + $handler = static function (int $severity, string $message): bool { + throw new RuntimeException($message, $severity); + }; + + set_error_handler($handler); + + try { + $connection = fsockopen($host, $this->config->port, $errno, $errstr, $this->config->timeoutSeconds); + } finally { + restore_error_handler(); + } + + if (!is_resource($connection)) { + throw new RuntimeException(sprintf('Failed to connect to SMTP server: %s (%d)', $errstr, $errno)); + } + + stream_set_timeout($connection, $this->config->timeoutSeconds); + + return $connection; + } + + /** + * @param resource $connection + * @param list $transcript + * @return array{0:int,1:string,2:list} + */ + private function readResponse($connection, array &$transcript = []): array + { + $response = ''; + $lines = []; + $code = 0; + + while (true) { + $line = fgets($connection, 1024); + if ($line === false) { + $metadata = stream_get_meta_data($connection); + if ($metadata['timed_out'] === true) { + throw new RuntimeException('SMTP server response timed out.'); + } + + throw new RuntimeException('Failed to read SMTP server response.'); + } + + $response .= $line; + $this->recordTranscriptResponse($transcript, $line); + $lines[] = rtrim($line, "\r\n"); + + if (preg_match('/^(\d{3})([\s-])/', $line, $matches) !== 1) { + continue; + } + + $code = (int) $matches[1]; + if ($matches[2] === ' ') { + break; + } + } + + return [$code, $response, $lines]; + } + + /** + * @param list $transcript + */ + private function recordTranscriptCommand(array &$transcript, string $command, bool $sensitive, bool $dataPayload): void + { + if (!$this->config->captureTranscript) { + return; + } + + if ($dataPayload) { + $transcript[] = sprintf('C: [DATA %d bytes]', strlen($command)); + + return; + } + + $line = trim(str_replace(["\r", "\n"], '', $command)); + if ($line === '') { + return; + } + + if ($sensitive || str_starts_with($line, 'AUTH ')) { + $transcript[] = 'C: [REDACTED]'; + + return; + } + + $transcript[] = 'C: ' . $line; + } + + /** + * @param list $transcript + */ + private function recordTranscriptResponse(array &$transcript, string $responseLine): void + { + if (!$this->config->captureTranscript) { + return; + } + + $trimmed = trim($responseLine); + if ($trimmed === '') { + return; + } + + $transcript[] = 'S: ' . $trimmed; + } + + private function requiresStartTls(): bool + { + return $this->config->security === SmtpSecurity::StartTlsRequired; + } + + private function resolveAuthMechanism(SmtpCapabilities $capabilities): SmtpAuthMechanism + { + if ($this->config->authMechanism !== SmtpAuthMechanism::Auto) { + if ($capabilities->authMechanisms !== [] && !in_array(strtoupper($this->config->authMechanism->value), $capabilities->authMechanisms, true)) { + throw new RuntimeException(sprintf( + 'SMTP server does not advertise AUTH %s.', + strtoupper($this->config->authMechanism->value), + )); + } + + return $this->config->authMechanism; + } + + if (in_array('PLAIN', $capabilities->authMechanisms, true)) { + return SmtpAuthMechanism::Plain; + } + + if (in_array('LOGIN', $capabilities->authMechanisms, true) || $capabilities->authMechanisms === []) { + return SmtpAuthMechanism::Login; + } + + throw new RuntimeException('No supported SMTP AUTH mechanism found (expected PLAIN or LOGIN).'); + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function sendEmailData( + $connection, + EmailMessage $message, + int $messageSizeBytes, + bool $messageContainsNonAscii, + SmtpCapabilities $capabilities, + ?string $messageId, + array &$transcript, + ): EmailDeliveryReport { + $envelopeSender = $message->envelope()->envelopeSender(); + if ($envelopeSender === null) { + throw new RuntimeException('Envelope sender is required for SMTP transport.'); + } + + $sizeLimit = $this->assertSizeWithinLimit($capabilities, $messageSizeBytes); + $headers = $message->headersData(); + $planner = $this->envelopePlanner ?? new SmtpEnvelopePlanner($this->config); + $requiresUtf8 = $planner->assertSmtpUtf8Policy($message, $capabilities); + + if ($capabilities->has('PIPELINING')) { + ['accepted' => $acceptedRecipients, 'rejected' => $rejectedRecipients, 'results' => $recipientResults] = $this->sendMailFromAndRecipientsPipelined( + $connection, + $message, + $headers, + $capabilities, + $messageSizeBytes, + $sizeLimit, + $messageContainsNonAscii, + $requiresUtf8, + $planner, + $transcript, + ); + } else { + $this->sendMailFrom( + $connection, + $envelopeSender->email, + $headers, + $capabilities, + $messageSizeBytes, + $sizeLimit, + $messageContainsNonAscii, + $requiresUtf8, + $planner, + $transcript, + ); + ['accepted' => $acceptedRecipients, 'rejected' => $rejectedRecipients, 'results' => $recipientResults] = $this->sendRecipients( + $connection, + $message, + $headers, + $capabilities, + $planner, + $transcript, + ); + } + + if ($acceptedRecipients === []) { + return new EmailDeliveryReport( + false, + [], + $rejectedRecipients, + $messageId, + 'SMTP rejected all recipients.', + $recipientResults, + [ + 'transport' => 'smtp', + 'smtp_host' => $this->config->host, + 'smtp_port' => $this->config->port, + 'security' => $this->config->security->value, + 'ehlo_capabilities' => array_keys($capabilities->values), + 'message_size_bytes' => $messageSizeBytes, + ], + ); + } + + $this->write($connection, "DATA\r\n", $transcript); + $this->expect($connection, [354], 'DATA', $transcript); + + $this->writeDataPayloadFromMessage($connection, $message, $messageSizeBytes, $transcript); + [, $messageResponse] = $this->expect($connection, [250], 'message body', $transcript); + + return new EmailDeliveryReport( + $rejectedRecipients === [], + $acceptedRecipients, + $rejectedRecipients, + $messageId, + $rejectedRecipients === [] ? null : 'One or more recipients were rejected.', + $recipientResults, + [ + 'transport' => 'smtp', + 'smtp_host' => $this->config->host, + 'smtp_port' => $this->config->port, + 'security' => $this->config->security->value, + 'ehlo_capabilities' => array_keys($capabilities->values), + 'message_size_bytes' => $messageSizeBytes, + 'accepted_count' => count($acceptedRecipients), + 'rejected_count' => count($rejectedRecipients), + 'partial_success' => $rejectedRecipients !== [], + 'final_response' => trim($messageResponse), + ], + ); + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function sendMailFrom( + $connection, + string $senderEmail, + EmailHeaders $headers, + SmtpCapabilities $capabilities, + int $messageSizeBytes, + ?int $sizeLimit, + bool $messageContainsNonAscii, + bool $requiresSmtpUtf8, + SmtpEnvelopePlanner $planner, + array &$transcript, + ): void { + $mailFrom = $planner->buildMailFromCommand( + $senderEmail, + $headers, + $capabilities, + $messageSizeBytes, + $sizeLimit, + $messageContainsNonAscii, + $requiresSmtpUtf8, + ); + + $this->write($connection, $mailFrom . "\r\n", $transcript); + $this->expect($connection, [250], 'MAIL FROM', $transcript); + } + + /** + * @param resource $connection + * @param list $transcript + * @return array{accepted:list,rejected:array,results:list} + */ + private function sendMailFromAndRecipientsPipelined( + $connection, + EmailMessage $message, + EmailHeaders $headers, + SmtpCapabilities $capabilities, + int $messageSizeBytes, + ?int $sizeLimit, + bool $messageContainsNonAscii, + bool $requiresSmtpUtf8, + SmtpEnvelopePlanner $planner, + array &$transcript, + ): array { + $sender = $message->envelope()->envelopeSender(); + if ($sender === null) { + throw new RuntimeException('Envelope sender is required for SMTP transport.'); + } + + $mailFromCommand = $planner->buildMailFromCommand( + $sender->email, + $headers, + $capabilities, + $messageSizeBytes, + $sizeLimit, + $messageContainsNonAscii, + $requiresSmtpUtf8, + ); + + $commands = []; + foreach ($message->envelope()->recipients() as $recipient) { + $commands[] = ['email' => $recipient->email, 'command' => $planner->buildRcptCommand($recipient->email, $headers, $capabilities)]; + } + + $this->write($connection, $mailFromCommand . "\r\n", $transcript); + foreach ($commands as $recipientCommand) { + $this->write($connection, $recipientCommand['command'] . "\r\n", $transcript); + } + + [$mailFromCode, $mailFromResponse] = $this->readResponse($connection, $transcript); + if (!in_array($mailFromCode, [250], true)) { + throw new RuntimeException(sprintf('SMTP error at MAIL FROM. Expected 250, got %d: %s', $mailFromCode, trim($mailFromResponse))); + } + + $acceptedRecipients = []; + $rejectedRecipients = []; + $recipientResults = []; + + foreach ($commands as $recipientCommand) { + [$code, $response] = $this->readResponse($connection, $transcript); + $email = $recipientCommand['email']; + + if (in_array($code, [250, 251], true)) { + $acceptedRecipients[] = $email; + $recipientResults[] = new EmailRecipientResult($email, true, $code, trim($response)); + + continue; + } + + $rejectedRecipients[$email] = trim($response); + $recipientResults[] = new EmailRecipientResult($email, false, $code > 0 ? $code : null, trim($response)); + } + + return ['accepted' => $acceptedRecipients, 'rejected' => $rejectedRecipients, 'results' => $recipientResults]; + } + + /** + * @param resource $connection + * @param list $transcript + * @return array{accepted:list,rejected:array,results:list} + */ + private function sendRecipients( + $connection, + EmailMessage $message, + EmailHeaders $headers, + SmtpCapabilities $capabilities, + SmtpEnvelopePlanner $planner, + array &$transcript, + ): array { + $acceptedRecipients = []; + $rejectedRecipients = []; + $recipientResults = []; + + foreach ($message->envelope()->recipients() as $recipient) { + $rcptCommand = $planner->buildRcptCommand($recipient->email, $headers, $capabilities); + + $this->write($connection, $rcptCommand . "\r\n", $transcript); + [$code, $response] = $this->readResponse($connection, $transcript); + + if (in_array($code, [250, 251], true)) { + $acceptedRecipients[] = $recipient->email; + $recipientResults[] = new EmailRecipientResult( + $recipient->email, + true, + $code, + trim($response), + ); + + continue; + } + + $rejectedRecipients[$recipient->email] = trim($response); + $recipientResults[] = new EmailRecipientResult( + $recipient->email, + false, + $code > 0 ? $code : null, + trim($response), + ); + } + + return ['accepted' => $acceptedRecipients, 'rejected' => $rejectedRecipients, 'results' => $recipientResults]; + } + + private function shouldAttemptStartTls(): bool + { + return in_array( + $this->config->security, + [SmtpSecurity::StartTlsRequired, SmtpSecurity::StartTlsOptional], + true, + ); + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function tryWriteAndDrain($connection, string $command, array &$transcript): void + { + try { + $this->write($connection, $command, $transcript); + $this->readResponse($connection, $transcript); + } catch (\Throwable) { + // Best-effort close path; do not override root SMTP error. + } + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function write($connection, string $data, array &$transcript = [], bool $sensitive = false, bool $dataPayload = false): void + { + $this->recordTranscriptCommand($transcript, $data, $sensitive, $dataPayload); + + $dataLength = strlen($data); + $bytesWritten = 0; + + while ($bytesWritten < $dataLength) { + $chunk = substr($data, $bytesWritten); + $written = fwrite($connection, $chunk); + + if ($written === false || $written === 0) { + throw new RuntimeException('Failed to write to SMTP server socket.'); + } + + $bytesWritten += $written; + } + } + + /** + * @param resource $connection + * @param list $transcript + */ + private function writeDataPayloadFromMessage($connection, EmailMessage $message, int $messageSizeBytes, array &$transcript): void + { + if ($this->config->captureTranscript) { + $transcript[] = sprintf('C: [DATA %d bytes]', $messageSizeBytes); + } + + $lineBuffer = ''; + $this->rawEmailBuilder->buildToStream( + $message, + function (string $chunk) use (&$lineBuffer, $connection): void { + $lineBuffer .= str_replace(["\r\n", "\r"], "\n", $chunk); + + while (($newlinePosition = strpos($lineBuffer, "\n")) !== false) { + $line = substr($lineBuffer, 0, $newlinePosition); + $lineBuffer = substr($lineBuffer, $newlinePosition + 1); + + if ($line !== '' && $line[0] === '.') { + $line = '.' . $line; + } + + $this->write($connection, $line . "\r\n"); + } + }, + includeSubject: true, + ); + + if ($lineBuffer !== '') { + if ($lineBuffer[0] === '.') { + $lineBuffer = '.' . $lineBuffer; + } + + $this->write($connection, $lineBuffer . "\r\n"); + } + + $this->write($connection, ".\r\n"); + } +} diff --git a/src/Email/Transport/SpoolEmailTransport.php b/src/Email/Transport/SpoolEmailTransport.php new file mode 100644 index 0000000..b966fcb --- /dev/null +++ b/src/Email/Transport/SpoolEmailTransport.php @@ -0,0 +1,183 @@ +assertReadyToSend(); + + $messageId = $this->headerBuilder->resolveMessageId($message); + $recipients = array_map(static fn($address): string => $address->email, $message->envelope()->recipients()); + + try { + $spoolResult = $this->spool($message); + } catch (RuntimeException $exception) { + return EmailTransportResultFactory::failure( + 'spool-email', + $messageId, + $exception->getMessage(), + $recipients, + ); + } + + return EmailTransportResultFactory::success('spool-email', $messageId, $recipients, [ + 'spool_path' => $spoolResult['path'], + 'size_bytes' => $spoolResult['sizeBytes'], + 'metadata_write_failed' => $spoolResult['metadataError'] !== null, + 'metadata_write_error' => $spoolResult['metadataError'], + ]); + } + + private function ensureWritableDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException(sprintf('Unable to create spool directory: %s', $directory)); + } + + if (!is_writable($directory)) { + throw new RuntimeException(sprintf('Spool directory is not writable: %s', $directory)); + } + } + + private function finalizeEmailFile(string $tempPath, string $finalPath): void + { + if (rename($tempPath, $finalPath)) { + return; + } + + $this->removeFileIfExists($tempPath); + + throw new RuntimeException(sprintf('Unable to finalize spooled email: %s', $finalPath)); + } + + private function removeFileIfExists(string $path): void + { + if (is_file($path)) { + unlink($path); + } + } + + /** + * @return array{path:string,metadataError:?string,sizeBytes:int} + */ + private function spool(EmailMessage $message): array + { + $directory = rtrim($this->config->directory, '/\\'); + $this->ensureWritableDirectory($directory); + + $id = sprintf('%s_%s', new DateTimeImmutable()->format('Ymd_His'), bin2hex(random_bytes(6))); + $finalPath = sprintf('%s/%s.eml', $directory, $id); + $tempPath = sprintf('%s/.%s.tmp', $directory, $id); + + try { + $sizeBytes = $this->writeTempFile($tempPath, $message); + } catch (RuntimeException $exception) { + $this->removeFileIfExists($tempPath); + + throw $exception; + } + + $this->finalizeEmailFile($tempPath, $finalPath); + + $metadataError = null; + if ($this->config->writeMetadata) { + try { + $this->writeMetadata($directory, $id, $message, $sizeBytes); + } catch (RuntimeException $exception) { + $metadataError = $exception->getMessage(); + } + } + + return ['path' => $finalPath, 'metadataError' => $metadataError, 'sizeBytes' => $sizeBytes]; + } + + private function writeMetadata(string $directory, string $id, EmailMessage $message, int $sizeBytes): void + { + $metadataPath = sprintf('%s/%s.json', $directory, $id); + $metadata = [ + 'created_at' => new DateTimeImmutable()->format(DATE_ATOM), + 'subject' => $message->headersData()->subject, + 'recipients' => array_map(static fn($address): string => $address->email, $message->envelope()->recipients()), + 'size_bytes' => $sizeBytes, + 'metadata' => $message->metadata(), + ]; + + $encoded = json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (!is_string($encoded)) { + throw new RuntimeException(sprintf('Unable to encode spool metadata: %s', $metadataPath)); + } + + if (file_put_contents($metadataPath, $encoded, LOCK_EX) === false) { + throw new RuntimeException(sprintf('Unable to write spool metadata file: %s', $metadataPath)); + } + } + + /** + * @param resource $stream + */ + private function writeStreamChunk($stream, string $chunk): void + { + $length = strlen($chunk); + $written = 0; + + while ($written < $length) { + $current = fwrite($stream, substr($chunk, $written)); + if ($current === false || $current === 0) { + throw new RuntimeException('Unable to write spool temp file chunk.'); + } + + $written += $current; + } + } + + private function writeTempFile(string $tempPath, EmailMessage $message): int + { + $stream = fopen($tempPath, 'w+b'); + if (!is_resource($stream)) { + throw new RuntimeException(sprintf('Unable to open spool temp file for writing: %s', $tempPath)); + } + + if (!flock($stream, LOCK_EX)) { + fclose($stream); + + throw new RuntimeException(sprintf('Unable to lock spool temp file: %s', $tempPath)); + } + + try { + return $this->rawEmailBuilder->buildToStream( + $message, + function (string $chunk) use ($stream): void { + $this->writeStreamChunk($stream, $chunk); + }, + includeSubject: true, + maxBytes: $this->config->maxMessageBytes, + ); + } finally { + flock($stream, LOCK_UN); + fclose($stream); + } + } +} diff --git a/src/Email/ValueObject/AttachmentContentResolver.php b/src/Email/ValueObject/AttachmentContentResolver.php new file mode 100644 index 0000000..a126984 --- /dev/null +++ b/src/Email/ValueObject/AttachmentContentResolver.php @@ -0,0 +1,15 @@ + $properties + */ + public function __construct( + public string $method, + public string $result, + public ?string $domain = null, + public ?string $selector = null, + public ?string $scope = null, + public array $properties = [], + ) {} + + public function passed(): bool + { + return strtolower($this->result) === 'pass'; + } +} diff --git a/src/Email/ValueObject/AuthenticationResults.php b/src/Email/ValueObject/AuthenticationResults.php new file mode 100644 index 0000000..79276d3 --- /dev/null +++ b/src/Email/ValueObject/AuthenticationResults.php @@ -0,0 +1,62 @@ + $checks + * @param list $warnings + */ + public function __construct( + public array $checks, + public ?string $authservId = null, + public array $warnings = [], + public ?string $raw = null, + ) {} + + public function check(string $method): ?AuthenticationCheckResult + { + $normalized = strtolower($method); + + foreach ($this->checks as $check) { + if (strtolower($check->method) === $normalized) { + return $check; + } + } + + return null; + } + + public function isAuthenticated(): bool + { + return $this->passedDkim() || $this->passedSpf() || $this->passedDmarc(); + } + + public function passedArc(): bool + { + return $this->check('arc')?->passed() ?? false; + } + + public function passedDkim(): bool + { + return $this->check('dkim')?->passed() ?? false; + } + + public function passedDmarc(): bool + { + return $this->check('dmarc')?->passed() ?? false; + } + + public function passedSpf(): bool + { + return $this->check('spf')?->passed() ?? false; + } + + public function resultFor(string $method): ?AuthenticationCheckResult + { + return $this->check($method); + } +} diff --git a/src/Email/ValueObject/BounceReport.php b/src/Email/ValueObject/BounceReport.php new file mode 100644 index 0000000..c72a0e2 --- /dev/null +++ b/src/Email/ValueObject/BounceReport.php @@ -0,0 +1,24 @@ + $metadata + */ + public function __construct( + public BounceType $type, + public ?string $recipient, + public ?string $action, + public ?string $status, + public ?string $diagnosticCode, + public ?string $remoteMta, + public ?string $originalMessageId, + public array $metadata = [], + ) {} +} diff --git a/src/Email/ValueObject/DkimVerificationResult.php b/src/Email/ValueObject/DkimVerificationResult.php new file mode 100644 index 0000000..8027fce --- /dev/null +++ b/src/Email/ValueObject/DkimVerificationResult.php @@ -0,0 +1,19 @@ + $metadata + */ + public function __construct( + public bool $valid, + public ?string $domain = null, + public ?string $selector = null, + public ?string $reason = null, + public array $metadata = [], + ) {} +} diff --git a/src/Email/ValueObject/EmailAddress.php b/src/Email/ValueObject/EmailAddress.php new file mode 100644 index 0000000..98bc0b3 --- /dev/null +++ b/src/Email/ValueObject/EmailAddress.php @@ -0,0 +1,88 @@ +email = $this->normalizeAndValidateEmail($email); + + if ($this->name !== null && $this->name !== '') { + HeaderValueGuard::assertNoCrlf($this->name, 'Display name'); + } + } + + public static function fromMailbox(string $mailbox): self + { + HeaderValueGuard::assertNoCrlf($mailbox, 'Mailbox'); + + $pattern = '/^\s*(?:(?[^<]+?)\s*)?<(?[^>]+)>\s*$/'; + if (preg_match($pattern, $mailbox, $matches) === 1) { + $displayName = trim($matches['name']); + $displayName = trim($displayName, "\"'"); + $email = trim($matches['email']); + + return new self($email, $displayName !== '' ? $displayName : null); + } + + return new self(trim($mailbox)); + } + + private function assertValidLocalPart(string $localPart, string $originalEmail): void + { + if (str_starts_with($localPart, '.') || str_ends_with($localPart, '.') || str_contains($localPart, '..')) { + throw new InvalidArgumentException(sprintf('Invalid email address: %s', $originalEmail)); + } + + $isAscii = preg_match('/^[\x00-\x7F]+$/', $localPart) === 1; + if ($isAscii) { + if (preg_match(self::LOCAL_PART_ASCII_PATTERN, $localPart) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid email address: %s', $originalEmail)); + } + + return; + } + + if (preg_match('/[\x00-\x1F\x7F\s<>]/u', $localPart) === 1) { + throw new InvalidArgumentException(sprintf('Invalid email address: %s', $originalEmail)); + } + } + + private function normalizeAndValidateEmail(string $email): string + { + $parts = explode('@', $email); + if (count($parts) !== 2) { + throw new InvalidArgumentException(sprintf('Invalid email address: %s', $email)); + } + + [$localPart, $domain] = $parts; + if ($localPart === '' || $domain === '') { + throw new InvalidArgumentException(sprintf('Invalid email address: %s', $email)); + } + + $asciiDomain = new IdnConverter()->toAsciiDomain($domain); + $this->assertValidLocalPart($localPart, $email); + + if (preg_match(self::DOMAIN_ASCII_PATTERN, $asciiDomain) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid email address: %s', $email)); + } + + return sprintf('%s@%s', $localPart, $asciiDomain); + } +} diff --git a/src/Email/ValueObject/EmailAddressList.php b/src/Email/ValueObject/EmailAddressList.php new file mode 100644 index 0000000..ee5a02c --- /dev/null +++ b/src/Email/ValueObject/EmailAddressList.php @@ -0,0 +1,46 @@ + + */ +final readonly class EmailAddressList implements Countable, IteratorAggregate +{ + /** + * @param list $addresses + */ + public function __construct(private array $addresses = []) {} + + /** + * @return list + */ + public function all(): array + { + return $this->addresses; + } + + public function count(): int + { + return count($this->addresses); + } + + public function first(): ?InboundEmailAddress + { + return $this->addresses[0] ?? null; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + yield from $this->addresses; + } +} diff --git a/src/Email/ValueObject/EmailAttachment.php b/src/Email/ValueObject/EmailAttachment.php new file mode 100644 index 0000000..4ce9683 --- /dev/null +++ b/src/Email/ValueObject/EmailAttachment.php @@ -0,0 +1,259 @@ + $maxSizeBytes) { + throw new AttachmentException( + sprintf('Attachment data exceeds max size (%d bytes): %s', $maxSizeBytes, $name), + ); + } + + return new self( + $name, + $mimeType, + $size, + $maxSizeBytes, + $disposition, + $contentId, + null, + $content, + ); + } + + public static function fromPath( + string $path, + ?string $name = null, + int $maxSizeBytes = self::DEFAULT_MAX_SIZE_BYTES, + string $disposition = 'attachment', + ?string $contentId = null, + ): self { + self::assertDisposition($disposition); + self::assertMaxSize($maxSizeBytes); + + if (!is_file($path)) { + throw new AttachmentException(sprintf('Attachment file not found: %s', $path)); + } + + if (!is_readable($path)) { + throw new AttachmentException(sprintf('Attachment file is not readable: %s', $path)); + } + + $fileSize = filesize($path); + if ($fileSize === false) { + throw new AttachmentException(sprintf('Unable to determine attachment size: %s', $path)); + } + + if ($fileSize > $maxSizeBytes) { + throw new AttachmentException( + sprintf('Attachment exceeds max size (%d bytes): %s', $maxSizeBytes, $path), + ); + } + + $mimeType = mime_content_type($path); + $resolvedName = $name ?? basename($path); + self::assertName($resolvedName); + $resolvedMimeType = is_string($mimeType) && $mimeType !== '' ? $mimeType : 'application/octet-stream'; + self::assertMimeType($resolvedMimeType); + self::assertContentId($contentId); + + return new self( + $resolvedName, + $resolvedMimeType, + $fileSize, + $maxSizeBytes, + $disposition, + $contentId, + $path, + ); + } + + /** + * @param resource $stream + */ + public static function fromStream( + mixed $stream, + string $name, + string $mimeType = 'application/octet-stream', + string $disposition = 'attachment', + ?string $contentId = null, + int $maxSizeBytes = self::DEFAULT_MAX_SIZE_BYTES, + ): self { + self::assertCommonFields($name, $mimeType, $disposition, $contentId, $maxSizeBytes); + + if (!is_resource($stream)) { + throw new InvalidArgumentException('Attachment stream must be a valid resource.'); + } + + $meta = stream_get_meta_data($stream); + $sizeBytes = 0; + + $uri = $meta['uri'] ?? null; + if (is_string($uri) && is_file($uri)) { + $size = filesize($uri); + if ($size !== false) { + $sizeBytes = $size; + } + } + + if ($sizeBytes > $maxSizeBytes) { + throw new AttachmentException( + sprintf('Attachment stream exceeds max size (%d bytes): %s', $maxSizeBytes, $name), + ); + } + + return new self($name, $mimeType, $sizeBytes, $maxSizeBytes, $disposition, $contentId, null, null, $stream); + } + + public function isInline(): bool + { + return $this->disposition === 'inline'; + } + + public function maxSizeBytes(): int + { + return $this->maxSizeBytes; + } + + public function readContent(): string + { + if ($this->content !== null) { + $this->assertReadSizeWithinLimit(strlen($this->content)); + + return $this->content; + } + + if ($this->path !== null) { + return $this->readFileContent($this->path); + } + + if (is_resource($this->stream)) { + $meta = stream_get_meta_data($this->stream); + + if ($meta['seekable'] === true) { + rewind($this->stream); + } + + $data = stream_get_contents($this->stream); + if ($data === false) { + throw new AttachmentException(sprintf('Unable to read attachment stream: %s', $this->name)); + } + + $this->assertReadSizeWithinLimit(strlen($data)); + + return $data; + } + + throw new AttachmentException(sprintf('Attachment source unavailable: %s', $this->name)); + } + + private static function assertCommonFields( + string $name, + string $mimeType, + string $disposition, + ?string $contentId, + int $maxSizeBytes, + ): void { + self::assertDisposition($disposition); + self::assertName($name); + self::assertMimeType($mimeType); + self::assertContentId($contentId); + self::assertMaxSize($maxSizeBytes); + } + + private static function assertContentId(?string $contentId): void + { + if ($contentId === null) { + return; + } + + if ($contentId === '' || str_contains($contentId, "\r") || str_contains($contentId, "\n") || str_contains($contentId, "\0")) { + throw new InvalidArgumentException('Attachment contentId must not be empty or contain control characters.'); + } + } + + private static function assertDisposition(string $disposition): void + { + if (!in_array($disposition, ['attachment', 'inline'], true)) { + throw new InvalidArgumentException(sprintf('Invalid attachment disposition: %s', $disposition)); + } + } + + private static function assertMaxSize(int $maxSizeBytes): void + { + if ($maxSizeBytes < 1) { + throw new InvalidArgumentException('Attachment max size must be greater than zero.'); + } + } + + private static function assertMimeType(string $mimeType): void + { + if (preg_match('/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/', $mimeType) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid attachment MIME type: %s', $mimeType)); + } + } + + private static function assertName(string $name): void + { + if ($name === '' || str_contains($name, "\r") || str_contains($name, "\n") || str_contains($name, "\0")) { + throw new InvalidArgumentException('Attachment name must not be empty or contain control characters.'); + } + } + + private function assertReadSizeWithinLimit(int $size): void + { + if ($size <= $this->maxSizeBytes) { + return; + } + + throw new AttachmentException( + sprintf('Attachment exceeds max size (%d bytes): %s', $this->maxSizeBytes, $this->name), + ); + } + + private function readFileContent(string $path): string + { + $fileContent = file_get_contents($path); + if ($fileContent === false) { + throw new AttachmentException(sprintf('Unable to read attachment file: %s', $path)); + } + + $this->assertReadSizeWithinLimit(strlen($fileContent)); + + return $fileContent; + } +} diff --git a/src/Email/ValueObject/EmailEnvelope.php b/src/Email/ValueObject/EmailEnvelope.php new file mode 100644 index 0000000..ded5a4d --- /dev/null +++ b/src/Email/ValueObject/EmailEnvelope.php @@ -0,0 +1,95 @@ + $to + * @param list $cc + * @param list $bcc + */ + public function __construct( + public ?EmailAddress $from = null, + public ?EmailAddress $returnPath = null, + public array $to = [], + public array $cc = [], + public array $bcc = [], + ) {} + + public function assertCanSend(): void + { + if ($this->from === null) { + throw new MissingMessageDataException('Envelope sender is required before sending.'); + } + + if (count($this->recipients()) === 0) { + throw new MissingMessageDataException('At least one envelope recipient is required before sending.'); + } + } + + public function envelopeSender(): ?EmailAddress + { + return $this->returnPath ?? $this->from; + } + + /** + * @return list + */ + public function recipients(): array + { + $emails = []; + $recipients = []; + + foreach (array_merge($this->to, $this->cc, $this->bcc) as $address) { + $emailKey = strtolower($address->email); + + if (isset($emails[$emailKey])) { + continue; + } + + $emails[$emailKey] = true; + $recipients[] = $address; + } + + return $recipients; + } + + /** + * @param list $bcc + */ + public function withBcc(array $bcc): self + { + return new self($this->from, $this->returnPath, $this->to, $this->cc, $bcc); + } + + /** + * @param list $cc + */ + public function withCc(array $cc): self + { + return new self($this->from, $this->returnPath, $this->to, $cc, $this->bcc); + } + + public function withFrom(EmailAddress $from): self + { + return new self($from, $this->returnPath, $this->to, $this->cc, $this->bcc); + } + + public function withReturnPath(?EmailAddress $returnPath): self + { + return new self($this->from, $returnPath, $this->to, $this->cc, $this->bcc); + } + + /** + * @param list $to + */ + public function withTo(array $to): self + { + return new self($this->from, $this->returnPath, $to, $this->cc, $this->bcc); + } +} diff --git a/src/Email/ValueObject/EmailHeaders.php b/src/Email/ValueObject/EmailHeaders.php new file mode 100644 index 0000000..d2b6f1f --- /dev/null +++ b/src/Email/ValueObject/EmailHeaders.php @@ -0,0 +1,526 @@ + $references + * @param array> $customHeaders + */ + public function __construct( + public string $subject = '', + public ?EmailAddress $replyTo = null, + public ?EmailAddress $sender = null, + public string $language = '', + public ?Priority $priority = null, + public string $mailer = '', + public ?string $listId = null, + public ?string $listUnsubscribe = null, + public ?string $listUnsubscribePost = null, + public ?string $listSubscribe = null, + public ?string $listArchive = null, + public ?bool $confirmedOptIn = null, + public ?string $spamStatus = null, + public ?string $organization = null, + public ?EmailAddress $dispositionNotificationTo = null, + public ?string $messageId = null, + public ?string $inReplyTo = null, + public array $references = [], + public array $customHeaders = [], + public bool $dsnNotifySuccess = false, + public bool $dsnNotifyFailure = false, + public bool $dsnNotifyDelay = false, + public bool $dsnReturnFull = true, + public ?string $dsnEnvelopeId = null, + ) { + $this->validateCommonHeaderValues(); + $this->validateDsnEnvelopeId(); + $this->validateReferences(); + $this->validateCustomHeaders(); + } + + public function assertCanSend(): void + { + if (trim($this->subject) === '') { + throw new MissingMessageDataException('Email subject is required before sending.'); + } + } + + /** + * @param string|list $value + */ + public function withCustomHeader(string $name, string|array $value): self + { + HeaderValueGuard::assertHeaderName($name); + + $headers = $this->customHeaders; + $headers[$name] = $this->normalizeHeaderValue($value); + + return $this->copy(['customHeaders' => $headers]); + } + + /** + * @param array> $headers + */ + public function withCustomHeaders(array $headers, bool $replace = false): self + { + $current = $replace ? [] : $this->customHeaders; + + foreach ($headers as $name => $value) { + HeaderValueGuard::assertHeaderName($name); + $current[$name] = $this->normalizeHeaderValue($value); + } + + return $this->copy(['customHeaders' => $current]); + } + + public function withDeliveryNotification( + bool $success = false, + bool $failure = true, + bool $delay = true, + bool $returnFull = true, + ?string $envelopeId = null, + ): self { + return $this->copy([ + 'dsnNotifySuccess' => $success, + 'dsnNotifyFailure' => $failure, + 'dsnNotifyDelay' => $delay, + 'dsnReturnFull' => $returnFull, + 'dsnEnvelopeId' => $envelopeId, + ]); + } + + public function withDsnEnvelopeId(?string $envelopeId): self + { + return $this->copy(['dsnEnvelopeId' => $envelopeId]); + } + + public function withGeneralHeaders(string $language = '', ?Priority $priority = null, string $mailer = ''): self + { + return $this->copy(['language' => $language, 'priority' => $priority, 'mailer' => $mailer]); + } + + public function withListHeaders( + ?string $listId = null, + ?string $unsubscribe = null, + ?string $subscribe = null, + ?string $archive = null, + ?string $unsubscribePost = null, + ): self { + return $this->copy([ + 'listId' => $listId, + 'listUnsubscribe' => $unsubscribe, + 'listSubscribe' => $subscribe, + 'listArchive' => $archive, + 'listUnsubscribePost' => $unsubscribePost, + ]); + } + + /** + * @param list $references + */ + public function withMessageDetails(?string $messageId = null, ?string $inReplyTo = null, array $references = []): self + { + return $this->copy(['messageId' => $messageId, 'inReplyTo' => $inReplyTo, 'references' => $references]); + } + + public function withMiscHeaders( + ?bool $confirmedOptIn = null, + ?string $spamStatus = null, + ?string $organization = null, + ?EmailAddress $dispositionNotificationTo = null, + ): self { + return $this->copy([ + 'confirmedOptIn' => $confirmedOptIn, + 'spamStatus' => $spamStatus, + 'organization' => $organization, + 'dispositionNotificationTo' => $dispositionNotificationTo, + ]); + } + + public function withOneClickUnsubscribe(string $url): self + { + return $this->copy([ + 'listUnsubscribe' => sprintf('<%s>', trim($url, '<>')), + 'listUnsubscribePost' => 'List-Unsubscribe=One-Click', + ]); + } + + public function withoutCustomHeader(string $name): self + { + $headers = $this->customHeaders; + unset($headers[$name]); + + return $this->copy(['customHeaders' => $headers]); + } + + public function withoutListHeaders(): self + { + return $this->copy([ + 'listId' => null, + 'listUnsubscribe' => null, + 'listUnsubscribePost' => null, + 'listSubscribe' => null, + 'listArchive' => null, + ]); + } + + public function withoutReplyTo(): self + { + return $this->copy(['replyTo' => null]); + } + + public function withoutSender(): self + { + return $this->copy(['sender' => null]); + } + + public function withReadReceiptTo(?EmailAddress $dispositionNotificationTo): self + { + return $this->copy(['dispositionNotificationTo' => $dispositionNotificationTo]); + } + + public function withReplyTo(?EmailAddress $replyTo): self + { + return $this->copy(['replyTo' => $replyTo]); + } + + public function withSender(?EmailAddress $sender): self + { + return $this->copy(['sender' => $sender]); + } + + public function withSubject(string $subject): self + { + return $this->copy(['subject' => $subject]); + } + + /** + * @param array $overrides + */ + private function copy(array $overrides): self + { + $listId = $this->overrideNullableString($overrides, 'listId', $this->listId); + $listUnsubscribe = $this->overrideNullableString($overrides, 'listUnsubscribe', $this->listUnsubscribe); + $listUnsubscribePost = $this->overrideNullableString($overrides, 'listUnsubscribePost', $this->listUnsubscribePost); + $listSubscribe = $this->overrideNullableString($overrides, 'listSubscribe', $this->listSubscribe); + $listArchive = $this->overrideNullableString($overrides, 'listArchive', $this->listArchive); + $spamStatus = $this->overrideNullableString($overrides, 'spamStatus', $this->spamStatus); + $organization = $this->overrideNullableString($overrides, 'organization', $this->organization); + $messageId = $this->overrideNullableString($overrides, 'messageId', $this->messageId); + $inReplyTo = $this->overrideNullableString($overrides, 'inReplyTo', $this->inReplyTo); + $dsnEnvelopeId = $this->overrideNullableString($overrides, 'dsnEnvelopeId', $this->dsnEnvelopeId); + + return new self( + $this->overrideString($overrides, 'subject', $this->subject), + $this->overrideEmailAddress($overrides, 'replyTo', $this->replyTo), + $this->overrideEmailAddress($overrides, 'sender', $this->sender), + $this->overrideString($overrides, 'language', $this->language), + $this->overridePriority($overrides, 'priority', $this->priority), + $this->overrideString($overrides, 'mailer', $this->mailer), + $listId, + $listUnsubscribe, + $listUnsubscribePost, + $listSubscribe, + $listArchive, + $this->overrideNullableBool($overrides, 'confirmedOptIn', $this->confirmedOptIn), + $spamStatus, + $organization, + $this->overrideEmailAddress($overrides, 'dispositionNotificationTo', $this->dispositionNotificationTo), + $messageId, + $inReplyTo, + $this->overrideReferences($overrides, 'references', $this->references), + $this->overrideCustomHeaders($overrides, 'customHeaders', $this->customHeaders), + $this->overrideBool($overrides, 'dsnNotifySuccess', $this->dsnNotifySuccess), + $this->overrideBool($overrides, 'dsnNotifyFailure', $this->dsnNotifyFailure), + $this->overrideBool($overrides, 'dsnNotifyDelay', $this->dsnNotifyDelay), + $this->overrideBool($overrides, 'dsnReturnFull', $this->dsnReturnFull), + $dsnEnvelopeId, + ); + } + + /** + * @return string|list + */ + private function normalizeCustomHeaderValue(mixed $value, string $key): string|array + { + if (is_string($value)) { + return $value; + } + + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('Override "%s" values must be strings or list of strings.', $key)); + } + + return $this->normalizeStringList($value, $key); + } + + /** + * @param string|list $value + * @return string|list + */ + private function normalizeHeaderValue(string|array $value): string|array + { + if (!is_array($value)) { + return $value; + } + + $normalized = []; + + foreach ($value as $item) { + $normalized[] = (string) $item; + } + + return $normalized; + } + + /** + * @param array $values + * @return list + */ + private function normalizeStringList(array $values, string $key): array + { + $normalized = []; + + foreach ($values as $value) { + if (!is_string($value)) { + throw new InvalidArgumentException(sprintf('Override "%s" list values must be strings.', $key)); + } + + $normalized[] = $value; + } + + return $normalized; + } + + /** + * @param array $overrides + */ + private function overrideBool(array $overrides, string $key, bool $current): bool + { + /** @var bool $value */ + $value = $this->overrideValue( + $overrides, + $key, + $current, + static fn(mixed $value): bool => is_bool($value), + 'a bool', + ); + + return $value; + } + + /** + * @param array $overrides + * @param array> $current + * @return array> + */ + private function overrideCustomHeaders(array $overrides, string $key, array $current): array + { + $value = $this->overrideValue($overrides, $key, $current, static fn(mixed $value): bool => is_array($value), 'an array'); + if (!is_array($value)) { + return $current; + } + + $normalized = []; + foreach ($value as $headerName => $headerValue) { + if (!is_string($headerName)) { + throw new InvalidArgumentException(sprintf('Override "%s" keys must be strings.', $key)); + } + + $normalized[$headerName] = $this->normalizeCustomHeaderValue($headerValue, $key); + } + + return $normalized; + } + + /** + * @param array $overrides + */ + private function overrideEmailAddress(array $overrides, string $key, ?EmailAddress $current): ?EmailAddress + { + /** @var ?EmailAddress $value */ + $value = $this->overrideValue( + $overrides, + $key, + $current, + static fn(mixed $value): bool => $value === null || $value instanceof EmailAddress, + 'an EmailAddress or null', + ); + + return $value; + } + + /** + * @param array $overrides + */ + private function overrideNullableBool(array $overrides, string $key, ?bool $current): ?bool + { + /** @var ?bool $value */ + $value = $this->overrideValue( + $overrides, + $key, + $current, + static fn(mixed $value): bool => $value === null || is_bool($value), + 'a bool or null', + ); + + return $value; + } + + /** + * @param array $overrides + */ + private function overrideNullableString(array $overrides, string $key, ?string $current): ?string + { + /** @var ?string $value */ + $value = $this->overrideValue( + $overrides, + $key, + $current, + static fn(mixed $value): bool => $value === null || is_string($value), + 'a string or null', + ); + + return $value; + } + + /** + * @param array $overrides + */ + private function overridePriority(array $overrides, string $key, ?Priority $current): ?Priority + { + /** @var ?Priority $value */ + $value = $this->overrideValue( + $overrides, + $key, + $current, + static fn(mixed $value): bool => $value === null || $value instanceof Priority, + 'a Priority or null', + ); + + return $value; + } + + /** + * @param array $overrides + * @param list $current + * @return list + */ + private function overrideReferences(array $overrides, string $key, array $current): array + { + $value = $this->overrideValue($overrides, $key, $current, static fn(mixed $value): bool => is_array($value), 'an array'); + if (!is_array($value)) { + return $current; + } + + return $this->normalizeStringList($value, $key); + } + + /** + * @param array $overrides + */ + private function overrideString(array $overrides, string $key, string $current): string + { + /** @var string $value */ + $value = $this->overrideValue( + $overrides, + $key, + $current, + static fn(mixed $value): bool => is_string($value), + 'a string', + ); + + return $value; + } + + /** + * @param array $overrides + */ + private function overrideValue( + array $overrides, + string $key, + mixed $current, + callable $validator, + string $expected, + ): mixed { + if (!array_key_exists($key, $overrides)) { + return $current; + } + + $value = $overrides[$key]; + if (!$validator($value)) { + throw new InvalidArgumentException(sprintf('Override "%s" must be %s.', $key, $expected)); + } + + return $value; + } + + private function validateCommonHeaderValues(): void + { + foreach ([ + 'Subject' => $this->subject, + 'Content-Language' => $this->language, + 'X-Mailer' => $this->mailer, + 'List-Id' => $this->listId, + 'List-Unsubscribe' => $this->listUnsubscribe, + 'List-Unsubscribe-Post' => $this->listUnsubscribePost, + 'List-Subscribe' => $this->listSubscribe, + 'List-Archive' => $this->listArchive, + 'X-Spam-Status' => $this->spamStatus, + 'Organization' => $this->organization, + 'Message-ID' => $this->messageId, + 'In-Reply-To' => $this->inReplyTo, + 'DSN-ENVID' => $this->dsnEnvelopeId, + ] as $name => $value) { + if ($value === null || $value === '') { + continue; + } + + HeaderValueGuard::assertNoCrlf($value, $name); + } + } + + private function validateCustomHeaders(): void + { + foreach ($this->customHeaders as $name => $value) { + HeaderValueGuard::assertHeaderName($name); + + if (is_array($value)) { + foreach ($value as $singleValue) { + HeaderValueGuard::assertNoCrlf($singleValue, $name); + } + + continue; + } + + HeaderValueGuard::assertNoCrlf($value, $name); + } + } + + private function validateDsnEnvelopeId(): void + { + if ($this->dsnEnvelopeId === null || $this->dsnEnvelopeId === '') { + return; + } + + if (preg_match('/^[A-Za-z0-9._-]{1,200}$/', $this->dsnEnvelopeId) !== 1) { + throw new InvalidArgumentException('DSN envelope ID must use only letters, numbers, dot, underscore, and hyphen.'); + } + } + + private function validateReferences(): void + { + foreach ($this->references as $reference) { + HeaderValueGuard::assertNoCrlf($reference, 'References'); + } + } +} diff --git a/src/Email/ValueObject/HeaderBag.php b/src/Email/ValueObject/HeaderBag.php new file mode 100644 index 0000000..9905613 --- /dev/null +++ b/src/Email/ValueObject/HeaderBag.php @@ -0,0 +1,53 @@ +> $headers + * @param list $rawLines + */ + public function __construct( + private array $headers, + private array $rawLines = [], + ) {} + + /** + * @return list + */ + public function all(string $name): array + { + return $this->headers[strtolower($name)] ?? []; + } + + /** + * @return array> + */ + public function asMap(): array + { + return $this->headers; + } + + public function first(string $name): ?string + { + $values = $this->all($name); + + return $values[0] ?? null; + } + + public function has(string $name): bool + { + return array_key_exists(strtolower($name), $this->headers); + } + + /** + * @return list + */ + public function rawLines(): array + { + return $this->rawLines; + } +} diff --git a/src/Email/ValueObject/ImapPartAttachmentContentResolver.php b/src/Email/ValueObject/ImapPartAttachmentContentResolver.php new file mode 100644 index 0000000..8780fbe --- /dev/null +++ b/src/Email/ValueObject/ImapPartAttachmentContentResolver.php @@ -0,0 +1,44 @@ +rawPartFetcher; + $raw = $fetcher(); + if ($raw === '') { + throw new RuntimeException(sprintf('IMAP part %s returned empty content.', $this->partNumber)); + } + + return $this->transferDecoder->decode($raw, $this->transferEncoding); + } + + public function streamTo(mixed $stream): void + { + if (!is_resource($stream)) { + throw new RuntimeException('Target stream is not a valid resource.'); + } + + $bytes = fwrite($stream, $this->contents()); + if ($bytes === false) { + throw new RuntimeException('Failed to write attachment contents to target stream.'); + } + } +} diff --git a/src/Email/ValueObject/InMemoryAttachmentContentResolver.php b/src/Email/ValueObject/InMemoryAttachmentContentResolver.php new file mode 100644 index 0000000..d44b5ac --- /dev/null +++ b/src/Email/ValueObject/InMemoryAttachmentContentResolver.php @@ -0,0 +1,40 @@ +contents; + } + + public function streamTo(mixed $stream): void + { + if (!is_resource($stream)) { + throw new RuntimeException('Attachment stream target must be a valid resource.'); + } + + $written = 0; + $length = strlen($this->contents); + + while ($written < $length) { + $chunk = substr($this->contents, $written); + $bytes = fwrite($stream, $chunk); + + if ($bytes === false || $bytes === 0) { + throw new RuntimeException('Failed to stream attachment content.'); + } + + $written += $bytes; + } + } +} diff --git a/src/Email/ValueObject/InboundEmailAddress.php b/src/Email/ValueObject/InboundEmailAddress.php new file mode 100644 index 0000000..8cbbbbc --- /dev/null +++ b/src/Email/ValueObject/InboundEmailAddress.php @@ -0,0 +1,19 @@ +email !== null && filter_var($this->email, FILTER_VALIDATE_EMAIL) !== false; + } +} diff --git a/src/Email/ValueObject/ParsedEmail.php b/src/Email/ValueObject/ParsedEmail.php new file mode 100644 index 0000000..cc78a3f --- /dev/null +++ b/src/Email/ValueObject/ParsedEmail.php @@ -0,0 +1,94 @@ + $references + * @param list $attachments + * @param list $parts + * @param array> $headers + * @param array $metadata + */ + public function __construct( + public EmailAddressList $from, + public EmailAddressList $to, + public EmailAddressList $cc, + public EmailAddressList $bcc, + public ?string $subject, + public ?DateTimeImmutable $date, + public ?string $messageId, + public ?string $inReplyTo, + public array $references, + public ?string $textBody, + public ?string $htmlBody, + public array $attachments, + public array $parts, + public array $headers, + public string $raw, + public array $metadata = [], + ) {} + + public function attachmentCount(): int + { + return count($this->attachments); + } + + public function firstAttachment(): ?ReceivedAttachment + { + return $this->attachments[0] ?? null; + } + + public function fromEmail(): ?string + { + return $this->from->first()?->email; + } + + public function hasAttachments(): bool + { + return $this->attachmentCount() > 0; + } + + public function header(string $name): ?string + { + $values = $this->headers[strtolower($name)] ?? []; + + return $values[0] ?? null; + } + + /** + * @return list + */ + public function headers(string $name): array + { + return $this->headers[strtolower($name)] ?? []; + } + + public function isBounceCandidate(): bool + { + $contentType = strtolower($this->header('Content-Type') ?? ''); + if (str_contains($contentType, 'multipart/report') || str_contains($contentType, 'message/delivery-status')) { + return true; + } + + $subject = strtolower($this->subjectOrEmpty()); + $bounceHints = ['delivery status notification', 'mail delivery failed', 'undelivered']; + + return array_any($bounceHints, fn($hint) => str_contains($subject, (string) $hint)); + } + + public function isReply(): bool + { + return $this->inReplyTo !== null || $this->references !== []; + } + + public function subjectOrEmpty(): string + { + return $this->subject ?? ''; + } +} diff --git a/src/Email/ValueObject/ParsedEmailPart.php b/src/Email/ValueObject/ParsedEmailPart.php new file mode 100644 index 0000000..56bed56 --- /dev/null +++ b/src/Email/ValueObject/ParsedEmailPart.php @@ -0,0 +1,25 @@ +> $headers + * @param list $children + */ + public function __construct( + public array $headers, + public string $contentType, + public ?string $charset, + public ?string $disposition, + public ?string $filename, + public ?string $contentId, + public string $body, + public bool $inline, + public ?string $partNumber = null, + public array $children = [], + ) {} +} diff --git a/src/Email/ValueObject/ReceivedAttachment.php b/src/Email/ValueObject/ReceivedAttachment.php new file mode 100644 index 0000000..ee55f68 --- /dev/null +++ b/src/Email/ValueObject/ReceivedAttachment.php @@ -0,0 +1,82 @@ +contents()); + } + + public function contents(): string + { + return $this->contentResolver->contents(); + } + + public function extension(): ?string + { + $extension = strtolower(pathinfo($this->filename, PATHINFO_EXTENSION)); + + return $extension !== '' ? $extension : null; + } + + public function isImage(): bool + { + return str_starts_with(strtolower($this->mimeType), 'image/'); + } + + public function isInline(): bool + { + return $this->inline; + } + + public function safeFilename(string $fallback = 'attachment.bin'): string + { + $candidate = trim($this->filename); + if ($candidate === '') { + return $fallback; + } + + $safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($candidate)) ?? ''; + $safe = trim($safe, '._'); + if ($safe === '') { + return $fallback; + } + + return $safe; + } + + public function saveTo(string $path): void + { + if (file_put_contents($path, $this->contents(), LOCK_EX) === false) { + throw new RuntimeException(sprintf('Failed to write attachment to path: %s', $path)); + } + } + + /** + * @param resource $stream + */ + public function streamTo(mixed $stream): void + { + $this->contentResolver->streamTo($stream); + } +} diff --git a/src/Grpc/GrpcCallError.php b/src/Grpc/GrpcCallError.php new file mode 100644 index 0000000..fe008e1 --- /dev/null +++ b/src/Grpc/GrpcCallError.php @@ -0,0 +1,19 @@ + $metadata + */ + public function __construct( + public string $method, + public GrpcStatus $status, + public string $message, + public int $durationMs, + public array $metadata = [], + ) {} +} diff --git a/src/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php new file mode 100644 index 0000000..a6bc1bb --- /dev/null +++ b/src/Grpc/GrpcClient.php @@ -0,0 +1,308 @@ + $middlewares + */ + private function __construct( + private GrpcTransport $transport, + private array $middlewares = [], + private ?NativeGrpcStreamingInvoker $streamingInvoker = null, + ) {} + + /** + * @param callable(GrpcRequest): GrpcResponse $caller + */ + public static function using(callable $caller): self + { + return new self(new GrpcTransport($caller)); + } + + public static function usingNative(NativeGrpcInvoker $invoker): self + { + return self::using( + static function (GrpcRequest $request) use ($invoker): GrpcResponse { + $native = $invoker->invoke( + method: $request->method, + message: $request->message, + headers: $request->headers, + deadlineSeconds: $request->deadlineSeconds, + ); + + return new GrpcResponse( + status: GrpcStatus::fromCode($native->statusCode), + message: $native->message, + headers: $native->headers, + trailers: $native->trailers, + metadata: $native->metadata, + ); + }, + ); + } + + public static function usingNativeStreaming( + NativeGrpcInvoker $invoker, + NativeGrpcStreamingInvoker $streamingInvoker, + ): self { + return new self(self::usingNative($invoker)->transport, streamingInvoker: $streamingInvoker); + } + + /** + * @param iterable $messages + * @param array $metadata + */ + public function bidiStream( + string $method, + iterable $messages, + callable $onMessage, + GrpcMetadata $headers = new GrpcMetadata(), + ?float $deadlineSeconds = null, + array $metadata = [], + ): CommunicationResult { + return $this->runOutboundStream('bidi', $method, $messages, $headers, $deadlineSeconds, $metadata, $onMessage); + } + + /** + * @param iterable $messages + * @param array $metadata + */ + public function clientStream( + string $method, + iterable $messages, + GrpcMetadata $headers = new GrpcMetadata(), + ?float $deadlineSeconds = null, + array $metadata = [], + ): CommunicationResult { + return $this->runOutboundStream('client', $method, $messages, $headers, $deadlineSeconds, $metadata); + } + + public function send(GrpcRequest $request): CommunicationResult + { + $pipeline = new MiddlewarePipeline($this->transport, $this->middlewares); + + return $pipeline->send(new CommunicationRequest('grpc', $request)); + } + + /** + * @param callable(mixed):void $onMessage + */ + public function serverStream(GrpcRequest $request, callable $onMessage): CommunicationResult + { + return $this->runStream( + streamType: 'server', + method: $request->method, + execute: fn(NativeGrpcStreamingInvoker $invoker): NativeGrpcResult => $invoker->serverStream( + method: $request->method, + message: $request->message, + headers: $request->headers, + onMessage: $onMessage, + deadlineSeconds: $request->deadlineSeconds, + ), + ); + } + + public function supportsStreaming(): bool + { + return $this->streamingInvoker !== null; + } + + public function withGrpcRetry(?GrpcRetryPolicy $policy = null): self + { + return $this->withRetryPolicy($policy ?? GrpcRetryPolicy::standard()); + } + + public function withMiddleware(MiddlewareInterface $middleware): self + { + $middlewares = $this->middlewares; + $middlewares[] = $middleware; + + return new self($this->transport, $middlewares, $this->streamingInvoker); + } + + /** + * @param list $middlewares + */ + public function withMiddlewares(array $middlewares): self + { + return new self($this->transport, $middlewares, $this->streamingInvoker); + } + + public function withRetryPolicy(RetryPolicy $policy): self + { + return $this->withMiddleware(new RetryMiddleware($policy)); + } + + /** + * @param iterable $messages + * @param array $metadata + * @param null|callable(mixed):void $onMessage + */ + private function runOutboundStream( + string $streamType, + string $method, + iterable $messages, + GrpcMetadata $headers, + ?float $deadlineSeconds, + array $metadata, + ?callable $onMessage = null, + ): CommunicationResult { + $request = new GrpcStreamRequest($method, $messages, $headers, $deadlineSeconds, $metadata); + + return $this->runStream( + streamType: $streamType, + method: $request->method, + execute: fn(NativeGrpcStreamingInvoker $invoker): NativeGrpcResult => match ($streamType) { + 'bidi' => $invoker->bidiStream( + method: $request->method, + messages: $request->messages, + headers: $request->headers, + onMessage: $onMessage ?? static function (mixed $message): void {}, + deadlineSeconds: $request->deadlineSeconds, + ), + 'client' => $invoker->clientStream( + method: $request->method, + messages: $request->messages, + headers: $request->headers, + deadlineSeconds: $request->deadlineSeconds, + ), + default => throw new \InvalidArgumentException(sprintf('Unsupported stream type "%s".', $streamType)), + }, + ); + } + + /** + * @param callable(NativeGrpcStreamingInvoker):NativeGrpcResult $execute + */ + private function runStream(string $streamType, string $method, callable $execute): CommunicationResult + { + if ($this->streamingInvoker === null) { + return CommunicationResult::failure( + sprintf('gRPC %s streaming is unavailable for this client.', $streamType), + ); + } + + $startedAt = microtime(true); + CommunicationEventBus::dispatch('grpc.stream.start', [ + 'transport' => 'grpc', + 'type' => $streamType, + 'method' => $method, + ]); + + try { + $native = $execute($this->streamingInvoker); + } catch (Throwable $exception) { + $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + CommunicationEventBus::dispatch('grpc.stream.failed', [ + 'transport' => 'grpc', + 'type' => $streamType, + 'method' => $method, + 'duration_ms' => $durationMs, + 'error' => $exception->getMessage(), + ]); + + $error = new GrpcCallError( + method: $method, + status: GrpcStatus::Unknown, + message: sprintf('gRPC %s stream failed: %s', $streamType, $exception->getMessage()), + durationMs: $durationMs, + metadata: [ + 'exception' => $exception::class, + 'stream_type' => $streamType, + ], + ); + + return CommunicationResult::failure( + $error->message, + null, + $error, + [ + 'transport' => 'grpc', + 'stream_type' => $streamType, + 'method' => $method, + 'duration_ms' => $durationMs, + 'grpc_error' => $error, + ], + ); + } + + $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + $response = new GrpcResponse( + status: GrpcStatus::fromCode($native->statusCode), + message: $native->message, + headers: $native->headers, + trailers: $native->trailers, + metadata: $native->metadata, + ); + + if (!$response->isOk()) { + CommunicationEventBus::dispatch('grpc.stream.failed', [ + 'transport' => 'grpc', + 'type' => $streamType, + 'method' => $method, + 'status_code' => $response->status->value, + 'status_name' => $response->status->name, + 'duration_ms' => $durationMs, + ]); + + $error = new GrpcCallError( + method: $method, + status: $response->status, + message: sprintf('gRPC %s stream failed with status %d (%s).', $streamType, $response->status->value, $response->status->name), + durationMs: $durationMs, + metadata: ['stream_type' => $streamType], + ); + + return CommunicationResult::failure( + $error->message, + $response->status->value, + $response, + [ + 'transport' => 'grpc', + 'stream_type' => $streamType, + 'method' => $method, + 'duration_ms' => $durationMs, + 'grpc_status' => $response->status->value, + 'grpc_status_name' => $response->status->name, + 'grpc_error' => $error, + ], + ); + } + + CommunicationEventBus::dispatch('grpc.stream.finish', [ + 'transport' => 'grpc', + 'type' => $streamType, + 'method' => $method, + 'status_code' => $response->status->value, + 'duration_ms' => $durationMs, + ]); + + return CommunicationResult::success( + $response->status->value, + $response, + [ + 'transport' => 'grpc', + 'stream_type' => $streamType, + 'method' => $method, + 'duration_ms' => $durationMs, + ], + ); + } +} diff --git a/src/Grpc/GrpcDeadline.php b/src/Grpc/GrpcDeadline.php new file mode 100644 index 0000000..1a9714e --- /dev/null +++ b/src/Grpc/GrpcDeadline.php @@ -0,0 +1,19 @@ +> */ + public array $headers; + + /** + * @param array> $headers + */ + public function __construct(array $headers = []) + { + $normalized = []; + foreach ($headers as $name => $values) { + $normalizedName = self::normalizeHeaderName($name); + + foreach ($values as $value) { + self::assertHeaderValue($value, $normalizedName); + } + + $normalized[$normalizedName] = $values; + } + + $this->headers = $normalized; + } + + /** + * @return list + */ + public function binaryValues(string $name): array + { + $normalizedName = self::normalizeHeaderName($name); + if (!self::isBinaryHeader($normalizedName)) { + throw new InvalidArgumentException(sprintf( + 'Metadata key "%s" is not a binary key; use values() instead.', + $normalizedName, + )); + } + + /** @var list $values */ + $values = $this->headers[$normalizedName] ?? []; + + return $values; + } + + public function first(string $name): ?string + { + $values = $this->values($name); + + return $values[0] ?? null; + } + + public function firstBinary(string $name): ?string + { + $values = $this->binaryValues($name); + + return $values[0] ?? null; + } + + public function has(string $name): bool + { + return array_key_exists(self::normalizeHeaderName($name), $this->headers); + } + + /** + * @return list + */ + public function values(string $name): array + { + $normalizedName = self::normalizeHeaderName($name); + if (self::isBinaryHeader($normalizedName)) { + throw new InvalidArgumentException(sprintf( + 'Binary gRPC metadata key "%s" requires binaryValues() accessor.', + $normalizedName, + )); + } + + /** @var list $values */ + $values = $this->headers[$normalizedName] ?? []; + + return $values; + } + + /** + * @param list $values + */ + public function with(string $name, array $values): self + { + $normalizedName = self::normalizeHeaderName($name); + if (self::isBinaryHeader($normalizedName)) { + throw new InvalidArgumentException(sprintf( + 'Binary gRPC metadata key "%s" requires withBinary() or withBinaryValue().', + $normalizedName, + )); + } + + foreach ($values as $value) { + self::assertHeaderValue($value, $normalizedName); + } + + $headers = $this->headers; + $headers[$normalizedName] = $values; + + return new self($headers); + } + + /** + * @param list $values + */ + public function withBinary(string $name, array $values): self + { + $normalizedName = self::normalizeHeaderName($name); + if (!self::isBinaryHeader($normalizedName)) { + throw new InvalidArgumentException(sprintf( + 'Metadata key "%s" is not a binary key; use with() instead.', + $normalizedName, + )); + } + + $headers = $this->headers; + $headers[$normalizedName] = $values; + + return new self($headers); + } + + public function withBinaryValue(string $name, string $value): self + { + $normalizedName = self::normalizeHeaderName($name); + if (!self::isBinaryHeader($normalizedName)) { + throw new InvalidArgumentException(sprintf( + 'Metadata key "%s" is not a binary key; use withValue() instead.', + $normalizedName, + )); + } + + return $this->appendValue($normalizedName, $value); + } + + public function withValue(string $name, string $value): self + { + $normalizedName = self::normalizeHeaderName($name); + if (self::isBinaryHeader($normalizedName)) { + throw new InvalidArgumentException(sprintf( + 'Binary gRPC metadata key "%s" requires withBinaryValue().', + $normalizedName, + )); + } + + self::assertHeaderValue($value, $normalizedName); + + return $this->appendValue($normalizedName, $value); + } + + private static function assertHeaderValue(string $value, string $name): void + { + if (self::isBinaryHeader($name)) { + return; + } + + if (str_contains($value, "\r") || str_contains($value, "\n") || str_contains($value, "\0")) { + throw new InvalidArgumentException('gRPC metadata value must not contain control characters.'); + } + } + + private static function isBinaryHeader(string $name): bool + { + return str_ends_with($name, '-bin'); + } + + private static function normalizeHeaderName(string $name): string + { + $normalized = strtolower($name); + + if ($normalized === '') { + throw new InvalidArgumentException('gRPC metadata name must not be empty.'); + } + + if (preg_match('/^[a-z0-9_.-]+$/', $normalized) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid gRPC metadata name "%s".', $name)); + } + + return $normalized; + } + + private function appendValue(string $normalizedName, string $value): self + { + $headers = $this->headers; + /** @var list $values */ + $values = $headers[$normalizedName] ?? []; + $values[] = $value; + $headers[$normalizedName] = $values; + + return new self($headers); + } +} diff --git a/src/Grpc/GrpcMethodGuard.php b/src/Grpc/GrpcMethodGuard.php new file mode 100644 index 0000000..69199dd --- /dev/null +++ b/src/Grpc/GrpcMethodGuard.php @@ -0,0 +1,30 @@ + $metadata + */ + public function __construct( + public string $method, + public mixed $message, + public GrpcMetadata $headers = new GrpcMetadata(), + public ?float $deadlineSeconds = null, + public array $metadata = [], + ) { + self::assertMethod($method); + self::assertDeadline($deadlineSeconds); + } + + public function deadlineMicros(): ?int + { + if ($this->deadlineSeconds === null) { + return null; + } + + return GrpcDeadline::secondsToMicros($this->deadlineSeconds); + } + + public function withDeadlineSeconds(?float $deadlineSeconds): self + { + self::assertDeadline($deadlineSeconds); + + return new self( + $this->method, + $this->message, + $this->headers, + $deadlineSeconds, + $this->metadata, + ); + } + + public function withHeaders(GrpcMetadata $headers): self + { + return new self( + $this->method, + $this->message, + $headers, + $this->deadlineSeconds, + $this->metadata, + ); + } + + private static function assertDeadline(?float $deadlineSeconds): void + { + if ($deadlineSeconds !== null && $deadlineSeconds <= 0.0) { + throw new InvalidArgumentException('gRPC deadline must be greater than zero.'); + } + } + + private static function assertMethod(string $method): void + { + GrpcMethodGuard::assertValid($method); + } +} diff --git a/src/Grpc/GrpcResponse.php b/src/Grpc/GrpcResponse.php new file mode 100644 index 0000000..7f9ac2b --- /dev/null +++ b/src/Grpc/GrpcResponse.php @@ -0,0 +1,24 @@ + $metadata + */ + public function __construct( + public GrpcStatus $status, + public mixed $message, + public GrpcMetadata $headers = new GrpcMetadata(), + public GrpcMetadata $trailers = new GrpcMetadata(), + public array $metadata = [], + ) {} + + public function isOk(): bool + { + return $this->status === GrpcStatus::Ok; + } +} diff --git a/src/Grpc/GrpcStatus.php b/src/Grpc/GrpcStatus.php new file mode 100644 index 0000000..fbab495 --- /dev/null +++ b/src/Grpc/GrpcStatus.php @@ -0,0 +1,47 @@ + $metadata + * @param iterable $messages + */ + public function __construct( + public string $method, + public iterable $messages, + public GrpcMetadata $headers = new GrpcMetadata(), + public ?float $deadlineSeconds = null, + public array $metadata = [], + ) { + GrpcMethodGuard::assertValid($this->method); + + if ($this->deadlineSeconds !== null && $this->deadlineSeconds <= 0.0) { + throw new InvalidArgumentException('gRPC stream deadline must be greater than zero.'); + } + } + + public function deadlineMicros(): ?int + { + if ($this->deadlineSeconds === null) { + return null; + } + + return GrpcDeadline::secondsToMicros($this->deadlineSeconds); + } +} diff --git a/src/Grpc/GrpcTransport.php b/src/Grpc/GrpcTransport.php new file mode 100644 index 0000000..e8d292c --- /dev/null +++ b/src/Grpc/GrpcTransport.php @@ -0,0 +1,127 @@ +caller = Closure::fromCallable($caller); + } + + public function send(CommunicationRequest $request): CommunicationResult + { + if (!$request->payload instanceof GrpcRequest) { + return CommunicationResult::failure('GrpcTransport expects GrpcRequest payload.'); + } + + $grpcRequest = $request->payload; + $startedAt = microtime(true); + CommunicationEventBus::dispatch('grpc.request.start', [ + 'transport' => 'grpc', + 'method' => $grpcRequest->method, + 'deadline_seconds' => $grpcRequest->deadlineSeconds, + 'metadata_header_count' => count($grpcRequest->headers->headers), + ]); + + try { + $response = ($this->caller)($grpcRequest); + } catch (Throwable $exception) { + $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + CommunicationEventBus::dispatch('grpc.request.failed', [ + 'transport' => 'grpc', + 'method' => $grpcRequest->method, + 'duration_ms' => $durationMs, + 'error' => $exception->getMessage(), + ]); + + $error = new GrpcCallError( + method: $grpcRequest->method, + status: GrpcStatus::Unknown, + message: $exception->getMessage(), + durationMs: $durationMs, + metadata: [ + 'exception' => $exception::class, + ], + ); + + return CommunicationResult::failure( + sprintf('gRPC call failed: %s', $exception->getMessage()), + null, + $error, + [ + 'transport' => 'grpc', + 'method' => $grpcRequest->method, + 'duration_ms' => $durationMs, + 'grpc_error' => $error, + ], + ); + } + + $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + if (!$response->isOk()) { + CommunicationEventBus::dispatch('grpc.request.failed', [ + 'transport' => 'grpc', + 'method' => $grpcRequest->method, + 'status_code' => $response->status->value, + 'status_name' => $response->status->name, + 'duration_ms' => $durationMs, + ]); + + $error = new GrpcCallError( + method: $grpcRequest->method, + status: $response->status, + message: sprintf('gRPC call failed with status %d (%s).', $response->status->value, $response->status->name), + durationMs: $durationMs, + ); + + return CommunicationResult::failure( + $error->message, + $response->status->value, + $response, + [ + 'transport' => 'grpc', + 'method' => $grpcRequest->method, + 'duration_ms' => $durationMs, + 'grpc_status' => $response->status->value, + 'grpc_status_name' => $response->status->name, + 'grpc_error' => $error, + ], + ); + } + + CommunicationEventBus::dispatch('grpc.request.finish', [ + 'transport' => 'grpc', + 'method' => $grpcRequest->method, + 'status_code' => $response->status->value, + 'duration_ms' => $durationMs, + ]); + + return CommunicationResult::success( + $response->status->value, + $response, + [ + 'transport' => 'grpc', + 'method' => $grpcRequest->method, + 'duration_ms' => $durationMs, + ], + ); + } +} diff --git a/src/Grpc/Native/GeneratedStubGrpcInvoker.php b/src/Grpc/Native/GeneratedStubGrpcInvoker.php new file mode 100644 index 0000000..2ceff7c --- /dev/null +++ b/src/Grpc/Native/GeneratedStubGrpcInvoker.php @@ -0,0 +1,426 @@ + $methodMap Maps gRPC method path to stub method name. + */ + public function __construct( + private object $stubClient, + private array $methodMap = [], + ) {} + + public function bidiStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $call = $this->invokeStreamOpen($method, $headers, $deadlineSeconds); + $this->writeMessages($call, $messages); + $this->finishClientWrites($call); + $this->drainInboundStream($call, $onMessage); + + return $this->finalizeCall($call); + } + + public function clientStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $call = $this->invokeStreamOpen($method, $headers, $deadlineSeconds); + $this->writeMessages($call, $messages); + $this->finishClientWrites($call); + + return $this->finalizeCall($call); + } + + public function invoke( + string $method, + mixed $message, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $call = $this->invokeStubMethod( + $this->resolveMethodName($method), + [ + $message, + $this->toNativeMetadata($headers), + $this->toNativeOptions($deadlineSeconds), + ], + ); + + return $this->finalizeCall($call); + } + + public function serverStream( + string $method, + mixed $message, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $call = $this->invokeStubMethod( + $this->resolveMethodName($method), + [ + $message, + $this->toNativeMetadata($headers), + $this->toNativeOptions($deadlineSeconds), + ], + ); + + $this->drainInboundStream($call, $onMessage); + + return $this->finalizeCall($call); + } + + private function drainInboundStream(mixed $call, callable $onMessage): void + { + if (!is_object($call)) { + throw new \RuntimeException('gRPC stream call result must be an object.'); + } + + if (method_exists($call, 'responses')) { + $responses = $call->responses(); + if (!is_iterable($responses)) { + throw new \RuntimeException('gRPC server stream responses() must return iterable.'); + } + + foreach ($responses as $response) { + $onMessage($response); + } + + return; + } + + if (method_exists($call, 'read')) { + while (true) { + $response = $call->read(); + if ($response === null) { + break; + } + + $onMessage($response); + } + + return; + } + + throw new \RuntimeException('Unsupported gRPC stream call object: expected responses() or read().'); + } + + private function extractHeaders(mixed $call): GrpcMetadata + { + if (!is_object($call)) { + return new GrpcMetadata(); + } + + if (method_exists($call, 'getMetadata')) { + return $this->fromNativeMetadata($call->getMetadata()); + } + + return new GrpcMetadata(); + } + + /** + * @param array $status + */ + private function extractStatusCodeFromArray(array $status): int + { + if (isset($status['code']) && is_int($status['code'])) { + return $status['code']; + } + + if (isset($status[0]) && is_int($status[0])) { + return $status[0]; + } + + return GrpcStatus::Unknown->value; + } + + private function extractStatusCodeFromObject(object $status): int + { + if (isset($status->code) && is_int($status->code)) { + return $status->code; + } + + if (!method_exists($status, 'getCode')) { + return GrpcStatus::Unknown->value; + } + + $code = $status->getCode(); + + return is_int($code) ? $code : GrpcStatus::Unknown->value; + } + + private function extractTrailers(mixed $call): GrpcMetadata + { + if (!is_object($call)) { + return new GrpcMetadata(); + } + + if (method_exists($call, 'getTrailingMetadata')) { + return $this->fromNativeMetadata($call->getTrailingMetadata()); + } + + return new GrpcMetadata(); + } + + private function extractWaitStatusCode(mixed $status): int + { + if (is_int($status)) { + return $status; + } + + if (is_array($status)) { + return $this->extractStatusCodeFromArray($status); + } + + if (is_object($status)) { + return $this->extractStatusCodeFromObject($status); + } + + return GrpcStatus::Unknown->value; + } + + /** + * @return array + */ + private function extractWaitStatusMetadata(mixed $status): array + { + if (is_array($status)) { + return $this->normalizeStatusArrayMetadata($status); + } + + if (!is_object($status)) { + return []; + } + + $metadata = []; + if (isset($status->details) && is_string($status->details)) { + $metadata['details'] = $status->details; + } + + if (isset($status->metadata) && is_array($status->metadata)) { + $metadata['status_metadata'] = $status->metadata; + } + + return $metadata; + } + + private function finalizeCall(mixed $call): NativeGrpcResult + { + if (!is_object($call) || !method_exists($call, 'wait')) { + throw new \RuntimeException('Unsupported gRPC call object: expected wait() method.'); + } + + $wait = $call->wait(); + + $message = null; + $status = GrpcStatus::Unknown->value; + $metadata = []; + + if (is_array($wait)) { + $message = $wait[0] ?? null; + $statusRaw = $wait[1] ?? null; + $status = $this->extractWaitStatusCode($statusRaw); + $metadata = $this->extractWaitStatusMetadata($statusRaw); + } elseif ($wait !== null) { + $message = $wait; + $status = GrpcStatus::Ok->value; + } + + return new NativeGrpcResult( + statusCode: $status, + message: $message, + headers: $this->extractHeaders($call), + trailers: $this->extractTrailers($call), + metadata: $metadata, + ); + } + + private function finishClientWrites(mixed $call): void + { + if (!is_object($call)) { + return; + } + + if (method_exists($call, 'writesDone')) { + $call->writesDone(); + + return; + } + + if (method_exists($call, 'closeWrite')) { + $call->closeWrite(); + } + } + + private function fromNativeMetadata(mixed $metadata): GrpcMetadata + { + if ($metadata instanceof GrpcMetadata) { + return $metadata; + } + + if (!is_array($metadata)) { + return new GrpcMetadata(); + } + + /** @var array> $normalized */ + $normalized = []; + + foreach ($metadata as $name => $values) { + if (!is_string($name)) { + continue; + } + + $normalizedValues = $this->normalizeNativeMetadataValues($values); + $normalized[$name] = $normalizedValues; + } + + return new GrpcMetadata($normalized); + } + + private function invokeStreamOpen(string $method, GrpcMetadata $headers, ?float $deadlineSeconds): mixed + { + $methodName = $this->resolveMethodName($method); + $metadata = $this->toNativeMetadata($headers); + $options = $this->toNativeOptions($deadlineSeconds); + + try { + return $this->invokeStubMethod($methodName, [$metadata, $options]); + } catch (\ArgumentCountError|\TypeError) { + return $this->invokeStubMethod($methodName, [null, $metadata, $options]); + } + } + + /** + * @param list $args + */ + private function invokeStubMethod(string $methodName, array $args): mixed + { + if (!method_exists($this->stubClient, $methodName)) { + throw new \RuntimeException(sprintf( + 'Generated gRPC stub method "%s" was not found on %s.', + $methodName, + $this->stubClient::class, + )); + } + + return $this->stubClient->{$methodName}(...$args); + } + + /** + * @return list + */ + private function normalizeNativeMetadataValues(mixed $values): array + { + if (is_array($values)) { + $normalized = []; + + foreach ($values as $value) { + $normalizedValue = $this->stringifyNativeMetadataValue($value); + if ($normalizedValue !== null) { + $normalized[] = $normalizedValue; + } + } + + return $normalized; + } + + $normalizedValue = $this->stringifyNativeMetadataValue($values); + if ($normalizedValue === null) { + return []; + } + + return [$normalizedValue]; + } + + /** + * @param array $status + * @return array + */ + private function normalizeStatusArrayMetadata(array $status): array + { + $normalized = []; + + foreach ($status as $key => $value) { + if (is_string($key)) { + $normalized[$key] = $value; + } + } + + return $normalized; + } + + private function resolveMethodName(string $method): string + { + $mapped = $this->methodMap[$method] ?? null; + if (is_string($mapped) && $mapped !== '') { + return $mapped; + } + + $trimmed = ltrim($method, '/'); + $parts = explode('/', $trimmed); + + return $parts[array_key_last($parts)]; + } + + private function stringifyNativeMetadataValue(mixed $value): ?string + { + if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + + if ($value instanceof \Stringable) { + return (string) $value; + } + + return null; + } + + /** + * @return array> + */ + private function toNativeMetadata(GrpcMetadata $headers): array + { + return $headers->headers; + } + + /** + * @return array + */ + private function toNativeOptions(?float $deadlineSeconds): array + { + if ($deadlineSeconds === null) { + return []; + } + + return ['timeout' => GrpcDeadline::secondsToMicros($deadlineSeconds)]; + } + + /** + * @param iterable $messages + */ + private function writeMessages(mixed $call, iterable $messages): void + { + if (!is_object($call) || !method_exists($call, 'write')) { + throw new \RuntimeException('Unsupported client stream call object: expected write() method.'); + } + + foreach ($messages as $message) { + $call->write($message); + } + } +} diff --git a/src/Grpc/Native/NativeGrpcInvoker.php b/src/Grpc/Native/NativeGrpcInvoker.php new file mode 100644 index 0000000..1672400 --- /dev/null +++ b/src/Grpc/Native/NativeGrpcInvoker.php @@ -0,0 +1,21 @@ + $metadata + */ + public function __construct( + public int $statusCode, + public mixed $message = null, + public GrpcMetadata $headers = new GrpcMetadata(), + public GrpcMetadata $trailers = new GrpcMetadata(), + public array $metadata = [], + ) {} +} diff --git a/src/Grpc/Native/NativeGrpcStreamingInvoker.php b/src/Grpc/Native/NativeGrpcStreamingInvoker.php new file mode 100644 index 0000000..41f3e77 --- /dev/null +++ b/src/Grpc/Native/NativeGrpcStreamingInvoker.php @@ -0,0 +1,43 @@ + $messages + * @param callable(mixed):void $onMessage + */ + public function bidiStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult; + + /** + * @param iterable $messages + */ + public function clientStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult; + + /** + * @param callable(mixed):void $onMessage + */ + public function serverStream( + string $method, + mixed $message, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult; +} diff --git a/src/Grpc/Retry/GrpcRetryPolicy.php b/src/Grpc/Retry/GrpcRetryPolicy.php new file mode 100644 index 0000000..2909636 --- /dev/null +++ b/src/Grpc/Retry/GrpcRetryPolicy.php @@ -0,0 +1,98 @@ + $retryStatuses + */ + public function __construct( + private int $maxAttempts = 3, + private int $baseDelayMs = 100, + private array $retryStatuses = [ + GrpcStatus::Unavailable, + GrpcStatus::DeadlineExceeded, + GrpcStatus::ResourceExhausted, + GrpcStatus::Aborted, + GrpcStatus::Internal, + ], + private ?int $maxDelayMs = null, + private float $jitterRatio = 0.0, + ) { + if ($this->maxAttempts < 1) { + throw new InvalidArgumentException('gRPC maxAttempts must be at least 1.'); + } + + if ($this->baseDelayMs < 0) { + throw new InvalidArgumentException('gRPC baseDelayMs must be greater than or equal to 0.'); + } + + if ($this->maxDelayMs !== null && $this->maxDelayMs < 0) { + throw new InvalidArgumentException('gRPC maxDelayMs must be greater than or equal to 0.'); + } + + if ($this->jitterRatio < 0.0 || $this->jitterRatio > 1.0) { + throw new InvalidArgumentException('gRPC jitterRatio must be between 0.0 and 1.0.'); + } + } + + public static function standard( + int $attempts = 3, + int $baseDelayMs = 100, + ?int $maxDelayMs = null, + float $jitterRatio = 0.0, + ): self { + return new self($attempts, $baseDelayMs, maxDelayMs: $maxDelayMs, jitterRatio: $jitterRatio); + } + + public function delayMs(int $attempt): int + { + $power = max(0, $attempt - 1); + $base = $this->baseDelayMs * (2 ** $power); + $delay = $base; + + if ($this->jitterRatio > 0.0 && $base > 0) { + $maxJitter = (int) floor($base * $this->jitterRatio); + if ($maxJitter > 0) { + $delay = max(0, $base + random_int(-$maxJitter, $maxJitter)); + } + } + + if ($this->maxDelayMs !== null) { + $delay = min($delay, $this->maxDelayMs); + } + + return $delay; + } + + public function shouldRetry(int $attempt, ?CommunicationResult $result = null, ?Throwable $error = null): bool + { + if ($attempt >= $this->maxAttempts) { + return false; + } + + if ($error !== null) { + return true; + } + + if ($result === null || $result->successful) { + return false; + } + + if (!$result->response instanceof GrpcResponse) { + return true; + } + + return array_any($this->retryStatuses, fn($status): bool => $result->response->status === $status); + } +} diff --git a/src/Grpc/Testing/AssertableGrpcCaller.php b/src/Grpc/Testing/AssertableGrpcCaller.php new file mode 100644 index 0000000..8cb4622 --- /dev/null +++ b/src/Grpc/Testing/AssertableGrpcCaller.php @@ -0,0 +1,85 @@ +fake->requests()); + if ($actual !== $expected) { + throw new LogicException(sprintf('Expected %d gRPC call(s), got %d.', $expected, $actual)); + } + } + + public function assertCalledMethod(string $method): void + { + foreach ($this->fake->requests() as $request) { + if ($request->method === $method) { + return; + } + } + + throw new LogicException(sprintf('Expected gRPC method "%s" to be called.', $method)); + } + + /** + * @param callable(GrpcRequest):bool $assertion + */ + public function assertCalledWhere(callable $assertion): void + { + foreach ($this->fake->requests() as $request) { + if ($assertion($request)) { + return; + } + } + + throw new LogicException('Expected at least one gRPC call matching the assertion.'); + } + + public function assertCalledWithMessage(string $method, mixed $message): void + { + $this->assertCalledWhere(static fn(GrpcRequest $request): bool => $request->method === $method + && $request->message === $message); + } + + public function assertCalledWithMetadata(string $method, string $headerName, string $value): void + { + $this->assertCalledWhere(static function (GrpcRequest $request) use ($method, $headerName, $value): bool { + if ($request->method !== $method) { + return false; + } + + return in_array($value, $request->headers->values($headerName), true); + }); + } + + public function assertNothingCalled(): void + { + if ($this->fake->requests() !== []) { + throw new LogicException('Expected no gRPC calls to be made.'); + } + } + + public function firstRequest(): ?GrpcRequest + { + return $this->fake->requests()[0] ?? null; + } + + public function lastRequest(): ?GrpcRequest + { + $requests = $this->fake->requests(); + if ($requests === []) { + return null; + } + + return $requests[array_key_last($requests)]; + } +} diff --git a/src/Grpc/Testing/FakeGrpcCaller.php b/src/Grpc/Testing/FakeGrpcCaller.php new file mode 100644 index 0000000..560d9e2 --- /dev/null +++ b/src/Grpc/Testing/FakeGrpcCaller.php @@ -0,0 +1,60 @@ + */ + private array $queuedResponses = []; + + /** @var list */ + private array $requests = []; + + public function __invoke(GrpcRequest $request): GrpcResponse + { + $this->requests[] = $request; + + if ($this->queuedResponses === []) { + throw new LogicException('No fake gRPC response queued.'); + } + + return array_shift($this->queuedResponses); + } + + public function assert(): AssertableGrpcCaller + { + return new AssertableGrpcCaller($this); + } + + public function push(GrpcResponse $response): self + { + $this->queuedResponses[] = $response; + + return $this; + } + + public function pushOk(mixed $message = null): self + { + return $this->push(new GrpcResponse(GrpcStatus::Ok, $message)); + } + + public function pushStatus(GrpcStatus $status, mixed $message = null): self + { + return $this->push(new GrpcResponse($status, $message)); + } + + /** + * @return list + */ + public function requests(): array + { + return $this->requests; + } +} diff --git a/src/Http/Body/FormBody.php b/src/Http/Body/FormBody.php new file mode 100644 index 0000000..bed89e3 --- /dev/null +++ b/src/Http/Body/FormBody.php @@ -0,0 +1,23 @@ +> $fields + */ + public function __construct(private array $fields) {} + + public function contentType(): string + { + return 'application/x-www-form-urlencoded'; + } + + public function toCurlPayload(): string + { + return http_build_query($this->fields); + } +} diff --git a/src/Http/Body/HttpBody.php b/src/Http/Body/HttpBody.php new file mode 100644 index 0000000..29cb76a --- /dev/null +++ b/src/Http/Body/HttpBody.php @@ -0,0 +1,15 @@ + + */ + public function toCurlPayload(): string|array; +} diff --git a/src/Http/Body/JsonBody.php b/src/Http/Body/JsonBody.php new file mode 100644 index 0000000..64b0282 --- /dev/null +++ b/src/Http/Body/JsonBody.php @@ -0,0 +1,29 @@ +value, JSON_THROW_ON_ERROR | $this->flags); + } catch (JsonException $exception) { + throw new \InvalidArgumentException('Failed to encode JSON request body.', previous: $exception); + } + } +} diff --git a/src/Http/Body/MultipartBody.php b/src/Http/Body/MultipartBody.php new file mode 100644 index 0000000..3688599 --- /dev/null +++ b/src/Http/Body/MultipartBody.php @@ -0,0 +1,136 @@ + $parts + */ + private function __construct(private array $parts) {} + + public static function new(): self + { + return new self([]); + } + + public function addData(string $name, string $contents, string $filename, ?string $mimeType = null): self + { + $file = $this->createTemporaryUploadFile( + contents: $contents, + filename: $filename, + mimeType: $mimeType, + context: 'multipart data', + ); + + return $this->withPart($name, $file); + } + + public function addField(string $name, string|int|float|bool $value): self + { + return $this->withPart($name, (string) $value); + } + + public function addFile(string $name, string $path, ?string $mimeType = null, ?string $postFilename = null): self + { + if (!is_file($path) || !is_readable($path)) { + throw new InvalidArgumentException(sprintf('Multipart file is missing or unreadable: %s', $path)); + } + + return $this->withPart( + $name, + new CURLFile($path, $mimeType ?? 'application/octet-stream', $postFilename ?? basename($path)), + ); + } + + /** + * @param resource $stream + */ + public function addStream(string $name, mixed $stream, string $filename, ?string $mimeType = null): self + { + if (!is_resource($stream)) { + throw new InvalidArgumentException('Multipart stream part requires a valid stream resource.'); + } + + $contents = stream_get_contents($stream); + if ($contents === false) { + throw new InvalidArgumentException('Failed to read multipart stream contents.'); + } + + $file = $this->createTemporaryUploadFile( + contents: $contents, + filename: $filename, + mimeType: $mimeType, + context: 'multipart stream', + ); + + return $this->withPart($name, $file); + } + + public function contentType(): string + { + return 'multipart/form-data'; + } + + /** + * @return array + */ + public function toCurlPayload(): array + { + return $this->parts; + } + + private function createTemporaryUploadFile( + string $contents, + string $filename, + ?string $mimeType, + string $context, + ): CURLFile { + $tempPath = tempnam(sys_get_temp_dir(), 'tb-http-upload-'); + if ($tempPath === false) { + throw new InvalidArgumentException(sprintf( + 'Unable to allocate temporary upload file for %s.', + $context, + )); + } + + if (file_put_contents($tempPath, $contents) === false) { + if (is_file($tempPath)) { + unlink($tempPath); + } + + throw new InvalidArgumentException(sprintf('Unable to write temporary %s file.', $context)); + } + + return new CURLFile($tempPath, $mimeType ?? 'application/octet-stream', $filename); + } + + private function nextPartKey(string $name): string + { + if (!array_key_exists($name, $this->parts)) { + return $name; + } + + $index = 0; + do { + $key = sprintf('%s[%d]', $name, $index); + $index++; + } while (array_key_exists($key, $this->parts)); + + return $key; + } + + private function withPart(string $name, string|CURLFile $value): self + { + $key = $this->nextPartKey($name); + $parts = $this->parts; + $parts[$key] = $value; + + return new self($parts); + } +} diff --git a/src/Http/Body/RawBody.php b/src/Http/Body/RawBody.php new file mode 100644 index 0000000..e649792 --- /dev/null +++ b/src/Http/Body/RawBody.php @@ -0,0 +1,23 @@ +type; + } + + public function toCurlPayload(): string + { + return $this->content; + } +} diff --git a/src/Http/Concurrent/ConcurrencyLimit.php b/src/Http/Concurrent/ConcurrencyLimit.php new file mode 100644 index 0000000..c1692d9 --- /dev/null +++ b/src/Http/Concurrent/ConcurrencyLimit.php @@ -0,0 +1,17 @@ +value < 1) { + throw new InvalidArgumentException('Concurrency limit must be at least 1.'); + } + } +} diff --git a/src/Http/Concurrent/CurlMultiTransport.php b/src/Http/Concurrent/CurlMultiTransport.php new file mode 100644 index 0000000..c8b7488 --- /dev/null +++ b/src/Http/Concurrent/CurlMultiTransport.php @@ -0,0 +1,270 @@ + $requests + */ + public function sendMany(array $requests, int $maxConcurrency = 10, bool $failFast = false): PoolResult + { + CommunicationEventBus::dispatch('http.pool.start', [ + 'request_count' => count($requests), + 'max_concurrency' => $maxConcurrency, + 'fail_fast' => $failFast, + 'transport' => 'curl-multi', + ]); + + $limit = new ConcurrencyLimit($maxConcurrency); + $chunkSize = max(1, $limit->value); + $results = []; + $start = microtime(true); + + foreach (array_chunk($requests, $chunkSize, true) as $chunk) { + $chunkResults = $this->sendChunk($chunk); + foreach ($chunkResults as $key => $result) { + $results[$key] = $result; + } + + if ($failFast && $this->containsFailure($chunkResults)) { + $pool = new PoolResult( + $results, + ['duration_ms' => (int) ((microtime(true) - $start) * 1000), 'fail_fast' => true], + ); + CommunicationEventBus::dispatch('http.pool.finish', [ + 'request_count' => count($requests), + 'successful_count' => $pool->successfulCount(), + 'failed_count' => $pool->failedCount(), + 'duration_ms' => $pool->metadata['duration_ms'] ?? null, + 'fail_fast' => true, + 'transport' => 'curl-multi', + ]); + + return $pool; + } + } + + $pool = new PoolResult( + $results, + ['duration_ms' => (int) ((microtime(true) - $start) * 1000), 'fail_fast' => false], + ); + CommunicationEventBus::dispatch('http.pool.finish', [ + 'request_count' => count($requests), + 'successful_count' => $pool->successfulCount(), + 'failed_count' => $pool->failedCount(), + 'duration_ms' => $pool->metadata['duration_ms'] ?? null, + 'fail_fast' => false, + 'transport' => 'curl-multi', + ]); + + return $pool; + } + + private function cleanupUploadHandle(HttpRequest $request): void + { + UploadHandleManager::cleanup($request); + } + + /** + * @param array $results + */ + private function containsFailure(array $results): bool + { + return array_any($results, static fn(CommunicationResult $result): bool => !$result->successful); + } + + private function dispatchRequestResultEvent(HttpRequest $request, CommunicationResult $result): void + { + CommunicationEventBus::dispatch($result->successful ? 'http.request.finish' : 'http.request.failed', [ + 'method' => $request->method->value, + 'url' => HttpRedactor::redactUrl($request->buildUrl()), + 'status' => $result->statusCode, + 'error' => $result->error, + 'transport' => 'curl-multi', + ]); + } + + /** + * @param array{handle: \CurlHandle, request: HttpRequest, headerCollector: ResponseHeaderCollector, bodyCollector: ResponseBodyCollector} $context + */ + private function finalizeContext(array $context): CommunicationResult + { + $rawBody = curl_multi_getcontent($context['handle']); + $info = curl_getinfo($context['handle']); + $streamFinalizeError = $context['bodyCollector']->finalize(); + $body = is_string($rawBody) ? $rawBody : $context['bodyCollector']->responseBody(); + + $this->cleanupUploadHandle($context['request']); + + if ($streamFinalizeError !== null) { + return CommunicationResult::failure( + $streamFinalizeError, + metadata: ['transport' => 'curl-multi', 'curl' => is_array($info) ? $info : []], + ); + } + + $result = CurlResultFactory::fromExecution( + $context['request'], + 'curl-multi', + $body, + curl_errno($context['handle']), + $context['bodyCollector']->error() ?? curl_error($context['handle']), + $info, + $context['headerCollector']->headers(), + ); + + $effectiveUrl = $info !== false ? $info['url'] : ''; + if ($effectiveUrl === '') { + return $result; + } + + try { + RequestSecurityGuard::assertAllowed($context['request'], $effectiveUrl); + } catch (InvalidArgumentException $exception) { + return CommunicationResult::failure( + $exception->getMessage(), + $result->statusCode, + $result->response, + $result->metadata, + ); + } + + return $result; + } + + /** + * @param array $results + * @return array{handle: \CurlHandle, request: HttpRequest, headerCollector: ResponseHeaderCollector, bodyCollector: ResponseBodyCollector}|null + */ + private function prepareContext( + \CurlMultiHandle $multiHandle, + CurlHandleConfigurator $configurator, + HttpRequest $request, + array &$results, + int|string $index, + ): ?array { + $prepared = $request->applyAuthenticators(); + + try { + RequestSecurityGuard::assertAllowed($prepared); + } catch (InvalidArgumentException $exception) { + $results[$index] = CommunicationResult::failure($exception->getMessage(), metadata: ['transport' => 'curl-multi']); + + return null; + } + + $handle = curl_init(); + if ($handle === false) { + $results[$index] = CommunicationResult::failure('Unable to initialize cURL handle.'); + + return null; + } + + $collector = new ResponseHeaderCollector(); + $bodyCollector = null; + + try { + $bodyCollector = new ResponseBodyCollector($prepared); + $prepared = $configurator->configure($handle, $prepared, $collector, $bodyCollector); + } catch (InvalidArgumentException $exception) { + $bodyCollector?->finalize(); + curl_close($handle); + $results[$index] = CommunicationResult::failure($exception->getMessage()); + + return null; + } + + CommunicationEventBus::dispatch('http.request.start', [ + 'method' => $prepared->method->value, + 'url' => HttpRedactor::redactUrl($prepared->buildUrl()), + 'headers' => HttpRedactor::redactHeaders($prepared->headers->all()), + 'transport' => 'curl-multi', + ]); + + curl_multi_add_handle($multiHandle, $handle); + + return [ + 'handle' => $handle, + 'request' => $prepared, + 'headerCollector' => $collector, + 'bodyCollector' => $bodyCollector, + ]; + } + + private function runMultiLoop(\CurlMultiHandle $multiHandle): void + { + do { + $status = curl_multi_exec($multiHandle, $running); + if ($status !== CURLM_OK) { + break; + } + + curl_multi_select($multiHandle, 1.0); + } while ($running > 0); + } + + /** + * @param array $requests + * @return array + */ + private function sendChunk(array $requests): array + { + $multiHandle = curl_multi_init(); + + /** + * @var array $contexts + */ + $contexts = []; + + /** @var array $results */ + $results = []; + + $configurator = new CurlHandleConfigurator(); + + foreach ($requests as $index => $request) { + $context = $this->prepareContext($multiHandle, $configurator, $request, $results, $index); + if ($context === null) { + continue; + } + + $contexts[$index] = $context; + } + + $this->runMultiLoop($multiHandle); + + foreach ($contexts as $index => $context) { + $results[$index] = $this->finalizeContext($context); + $this->dispatchRequestResultEvent($context['request'], $results[$index]); + } + + foreach ($contexts as $context) { + curl_multi_remove_handle($multiHandle, $context['handle']); + } + + curl_multi_close($multiHandle); + + $orderedResults = []; + foreach (array_keys($requests) as $key) { + if (array_key_exists($key, $results)) { + $orderedResults[$key] = $results[$key]; + } + } + + return $orderedResults; + } +} diff --git a/src/Http/Concurrent/PoolResult.php b/src/Http/Concurrent/PoolResult.php new file mode 100644 index 0000000..7f33ee5 --- /dev/null +++ b/src/Http/Concurrent/PoolResult.php @@ -0,0 +1,69 @@ + $results + * @param array $metadata + */ + public function __construct( + public array $results, + public array $metadata = [], + ) {} + + /** + * @return array + */ + public function all(): array + { + return $this->results; + } + + /** + * @return array + */ + public function failed(): array + { + return array_filter($this->results, static fn(CommunicationResult $result): bool => !$result->successful); + } + + public function failedCount(): int + { + return count($this->failed()); + } + + public function firstError(): ?CommunicationResult + { + foreach ($this->results as $result) { + if (!$result->successful) { + return $result; + } + } + + return null; + } + + public function get(int|string $key): ?CommunicationResult + { + return $this->results[$key] ?? null; + } + + /** + * @return array + */ + public function successful(): array + { + return array_filter($this->results, static fn(CommunicationResult $result): bool => $result->successful); + } + + public function successfulCount(): int + { + return count($this->successful()); + } +} diff --git a/src/Http/Concurrent/RequestPool.php b/src/Http/Concurrent/RequestPool.php new file mode 100644 index 0000000..bdc2906 --- /dev/null +++ b/src/Http/Concurrent/RequestPool.php @@ -0,0 +1,34 @@ +transport, $this->maxConcurrency, $enabled); + } + + public function maxConcurrency(int $maxConcurrency): self + { + return new self($this->transport, $maxConcurrency, $this->failFast); + } + + /** + * @param array $requests + */ + public function sendMany(array $requests): PoolResult + { + return $this->transport->sendMany($requests, $this->maxConcurrency, $this->failFast); + } +} diff --git a/src/Http/Cookie/Cookie.php b/src/Http/Cookie/Cookie.php new file mode 100644 index 0000000..9d28c37 --- /dev/null +++ b/src/Http/Cookie/Cookie.php @@ -0,0 +1,77 @@ +name === '') { + throw new InvalidArgumentException('Cookie name must not be empty.'); + } + + if ($this->domain === '') { + throw new InvalidArgumentException('Cookie domain must not be empty.'); + } + + if (!str_starts_with($this->path, '/')) { + throw new InvalidArgumentException('Cookie path must start with "/".'); + } + } + + public function isExpired(?DateTimeImmutable $now = null): bool + { + if ($this->expiresAt === null) { + return false; + } + + $now ??= new DateTimeImmutable(); + + return $this->expiresAt <= $now; + } + + public function key(): string + { + return strtolower($this->domain) . '|' . $this->path . '|' . $this->name; + } + + public function matches(string $host, string $path, bool $secureRequest): bool + { + if ($this->secure && !$secureRequest) { + return false; + } + + if ($this->hostOnly) { + if (strcasecmp($host, $this->domain) !== 0) { + return false; + } + } else { + $host = strtolower($host); + $domain = strtolower($this->domain); + + if ($host !== $domain && !str_ends_with($host, '.' . $domain)) { + return false; + } + } + + return str_starts_with($path, $this->path); + } + + public function pair(): string + { + return sprintf('%s=%s', $this->name, $this->value); + } +} diff --git a/src/Http/Cookie/CookieJar.php b/src/Http/Cookie/CookieJar.php new file mode 100644 index 0000000..e38b491 --- /dev/null +++ b/src/Http/Cookie/CookieJar.php @@ -0,0 +1,403 @@ + + */ + private array $cookies = []; + + /** + * @return array + */ + public function all(): array + { + return $this->cookies; + } + + public function applyToRequest(HttpRequest $request): HttpRequest + { + $context = $this->requestContext($request->buildUrl()); + if ($context === null) { + return $request; + } + + $cookiePairs = $this->extractExistingCookiePairs($request); + $now = new DateTimeImmutable(); + + foreach ($this->cookies as $key => $cookie) { + if ($cookie->isExpired($now)) { + unset($this->cookies[$key]); + + continue; + } + + if (!$cookie->matches($context['host'], $context['path'], $context['secure'])) { + continue; + } + + if (array_key_exists($cookie->name, $cookiePairs)) { + continue; + } + + $cookiePairs[$cookie->name] = $cookie->value; + } + + if ($cookiePairs === []) { + return $request; + } + + $line = implode('; ', array_map( + static fn(string $name, string $value): string => sprintf('%s=%s', $name, $value), + array_keys($cookiePairs), + array_values($cookiePairs), + )); + + return $request->header('Cookie', $line); + } + + public function count(): int + { + return count($this->cookies); + } + + public function remember(Cookie $cookie): void + { + $this->cookies[$cookie->key()] = $cookie; + } + + public function storeFromResponse(HttpResponse $response, string $requestUrl): void + { + $setCookie = $response->header('Set-Cookie'); + if ($setCookie === null) { + return; + } + + $context = $this->requestContext($requestUrl); + if ($context === null) { + return; + } + + $lines = is_array($setCookie) ? $setCookie : [$setCookie]; + + foreach ($lines as $line) { + $cookie = $this->parseSetCookie($line, $context); + if ($cookie === null) { + continue; + } + + if ($cookie->isExpired()) { + unset($this->cookies[$cookie->key()]); + + continue; + } + + $this->cookies[$cookie->key()] = $cookie; + } + } + + /** + * @param array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } $attributes + * @return array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } + */ + private function applyAttribute(array $attributes, string $segment): array + { + if (!str_contains($segment, '=')) { + $attribute = strtolower($segment); + + return match ($attribute) { + 'secure' => [...$attributes, 'secure' => true], + 'httponly' => [...$attributes, 'httpOnly' => true], + default => $attributes, + }; + } + + [$attributeName, $attributeValue] = explode('=', $segment, 2); + $attributeName = strtolower(trim($attributeName)); + $attributeValue = trim($attributeValue, " \t\n\r\0\x0B\""); + + return match ($attributeName) { + 'domain' => $this->applyDomainAttribute($attributes, $attributeValue), + 'path' => $this->applyPathAttribute($attributes, $attributeValue), + 'expires' => $this->applyExpiresAttribute($attributes, $attributeValue), + 'max-age' => $this->applyMaxAgeAttribute($attributes, $attributeValue), + default => $attributes, + }; + } + + /** + * @param array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } $attributes + * @return array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } + */ + private function applyDomainAttribute(array $attributes, string $value): array + { + if ($value === '') { + return $attributes; + } + + return [ + ...$attributes, + 'domain' => ltrim(strtolower($value), '.'), + 'hostOnly' => false, + ]; + } + + /** + * @param array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } $attributes + * @return array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } + */ + private function applyExpiresAttribute(array $attributes, string $value): array + { + if ($value === '') { + return $attributes; + } + + return [ + ...$attributes, + 'expiresAt' => $this->parseExpires($value), + ]; + } + + /** + * @param array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } $attributes + * @return array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } + */ + private function applyMaxAgeAttribute(array $attributes, string $value): array + { + $maxAge = filter_var($value, FILTER_VALIDATE_INT); + if ($maxAge === false) { + return $attributes; + } + + return [ + ...$attributes, + 'expiresAt' => new DateTimeImmutable()->modify(sprintf('%+d seconds', $maxAge)), + ]; + } + + /** + * @param array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } $attributes + * @return array{ + * domain: string, + * path: string, + * expiresAt: ?DateTimeImmutable, + * secure: bool, + * httpOnly: bool, + * hostOnly: bool + * } + */ + private function applyPathAttribute(array $attributes, string $value): array + { + if ($value === '') { + return $attributes; + } + + return [ + ...$attributes, + 'path' => str_starts_with($value, '/') ? $value : '/' . $value, + ]; + } + + private function defaultPath(string $requestPath): string + { + if ($requestPath === '' || $requestPath[0] !== '/') { + return '/'; + } + + $lastSlash = strrpos($requestPath, '/'); + if ($lastSlash === false || $lastSlash === 0) { + return '/'; + } + + return substr($requestPath, 0, $lastSlash); + } + + /** + * @return array + */ + private function extractExistingCookiePairs(HttpRequest $request): array + { + $header = $request->headers->get('Cookie'); + + if ($header === null) { + return []; + } + + $lines = is_array($header) ? $header : [$header]; + $pairs = []; + + foreach ($lines as $line) { + $segments = explode(';', $line); + foreach ($segments as $segment) { + $segment = trim($segment); + if ($segment === '' || !str_contains($segment, '=')) { + continue; + } + + [$name, $value] = explode('=', $segment, 2); + $name = trim($name); + if ($name === '') { + continue; + } + + $pairs[$name] = trim($value); + } + } + + return $pairs; + } + + private function parseExpires(string $value): ?DateTimeImmutable + { + try { + return new DateTimeImmutable($value); + } catch (\Exception) { + return null; + } + } + + /** + * @param array{host: string, path: string, secure: bool} $context + */ + private function parseSetCookie(string $line, array $context): ?Cookie + { + $segments = array_map(trim(...), explode(';', $line)); + $nameValue = array_shift($segments); + + if (!str_contains($nameValue, '=')) { + return null; + } + + [$name, $value] = explode('=', $nameValue, 2); + $name = trim($name); + if ($name === '') { + return null; + } + + $attributes = [ + 'domain' => $context['host'], + 'path' => $this->defaultPath($context['path']), + 'expiresAt' => null, + 'secure' => false, + 'httpOnly' => false, + 'hostOnly' => true, + ]; + + foreach ($segments as $segment) { + if ($segment === '') { + continue; + } + + $attributes = $this->applyAttribute($attributes, $segment); + } + + return new Cookie( + $name, + $value, + $attributes['domain'], + $attributes['path'], + $attributes['expiresAt'], + $attributes['secure'], + $attributes['httpOnly'], + $attributes['hostOnly'], + ); + } + + /** + * @return array{host: string, path: string, secure: bool}|null + */ + private function requestContext(string $url): ?array + { + $parts = parse_url($url); + if ($parts === false) { + return null; + } + + $host = strtolower((string) ($parts['host'] ?? '')); + if ($host === '') { + return null; + } + + $path = (string) ($parts['path'] ?? '/'); + if ($path === '') { + $path = '/'; + } + + return [ + 'host' => $host, + 'path' => $path, + 'secure' => strtolower((string) ($parts['scheme'] ?? 'http')) === 'https', + ]; + } +} diff --git a/src/Http/CurlOptions.php b/src/Http/CurlOptions.php new file mode 100644 index 0000000..8518ee9 --- /dev/null +++ b/src/Http/CurlOptions.php @@ -0,0 +1,258 @@ + + * } + * @phpstan-type CurlOptionState array{ + * timeoutSeconds: int, + * connectTimeoutSeconds: int, + * followRedirects: bool, + * maxRedirects: int, + * proxy: ?string, + * proxyAuth: ?string, + * verifyPeer: bool, + * verifyHost: bool, + * caBundle: ?string, + * clientCertificate: ?string, + * clientKey: ?string, + * clientKeyPassphrase: ?string, + * userAgent: ?string, + * downloadPath: ?string, + * streamDownloadPath: ?string, + * maxResponseBytes: ?int, + * maxDownloadBytes: ?int, + * maxUploadBytes: ?int, + * httpVersion: ?int, + * additional: array + * } + */ +final readonly class CurlOptions +{ + /** + * @param array $additional + */ + public function __construct( + public int $timeoutSeconds = 10, + public int $connectTimeoutSeconds = 10, + public bool $followRedirects = false, + public int $maxRedirects = 5, + public ?string $proxy = null, + public ?string $proxyAuth = null, + public bool $verifyPeer = true, + public bool $verifyHost = true, + public ?string $caBundle = null, + public ?string $clientCertificate = null, + public ?string $clientKey = null, + public ?string $clientKeyPassphrase = null, + public ?string $userAgent = null, + public ?string $downloadPath = null, + public ?string $streamDownloadPath = null, + public ?int $maxResponseBytes = null, + public ?int $maxDownloadBytes = null, + public ?int $maxUploadBytes = null, + public ?int $httpVersion = null, + public array $additional = [], + ) { + if ($this->timeoutSeconds < 1) { + throw new \InvalidArgumentException('timeoutSeconds must be greater than 0.'); + } + + if ($this->connectTimeoutSeconds < 1) { + throw new \InvalidArgumentException('connectTimeoutSeconds must be greater than 0.'); + } + + if ($this->maxRedirects < 0) { + throw new \InvalidArgumentException('maxRedirects must be greater than or equal to 0.'); + } + + self::assertPositiveLimit($this->maxResponseBytes, 'maxResponseBytes'); + self::assertPositiveLimit($this->maxDownloadBytes, 'maxDownloadBytes'); + self::assertPositiveLimit($this->maxUploadBytes, 'maxUploadBytes'); + + if ($this->proxy !== null) { + self::assertValidProxy($this->proxy); + } + + self::assertReadableFileIfSet($this->caBundle, 'caBundle'); + self::assertReadableFileIfSet($this->clientCertificate, 'clientCertificate'); + self::assertReadableFileIfSet($this->clientKey, 'clientKey'); + } + + public function withAdditional(int $option, mixed $value): self + { + $additional = $this->additional; + $additional[$option] = $value; + + return $this->with(['additional' => $additional]); + } + + public function withConnectTimeoutSeconds(int $seconds): self + { + return $this->with(['connectTimeoutSeconds' => $seconds]); + } + + public function withDownloadPath(?string $downloadPath): self + { + return $this->with(['downloadPath' => $downloadPath]); + } + + public function withFollowRedirects(bool $enabled, ?int $maxRedirects = null): self + { + return $this->with([ + 'followRedirects' => $enabled, + 'maxRedirects' => $maxRedirects ?? $this->maxRedirects, + ]); + } + + public function withMaxRedirects(int $maxRedirects): self + { + return $this->withFollowRedirects($this->followRedirects, $maxRedirects); + } + + public function withMtls(string $certificatePath, string $keyPath, ?string $passphrase = null): self + { + return $this->with([ + 'clientCertificate' => $certificatePath, + 'clientKey' => $keyPath, + 'clientKeyPassphrase' => $passphrase, + ]); + } + + public function withProxy(?string $proxy): self + { + return $this->with(['proxy' => $proxy]); + } + + public function withProxyAuth(?string $proxyAuth): self + { + return $this->with(['proxyAuth' => $proxyAuth]); + } + + public function withResponseLimits(?int $maxResponseBytes, ?int $maxDownloadBytes, ?int $maxUploadBytes): self + { + return $this->with([ + 'maxResponseBytes' => $maxResponseBytes, + 'maxDownloadBytes' => $maxDownloadBytes, + 'maxUploadBytes' => $maxUploadBytes, + ]); + } + + public function withStreamDownloadPath(?string $streamDownloadPath): self + { + return $this->with(['streamDownloadPath' => $streamDownloadPath]); + } + + public function withTimeoutSeconds(int $seconds): self + { + return $this->with(['timeoutSeconds' => $seconds]); + } + + public function withTls(bool $verifyPeer = true, bool $verifyHost = true, ?string $caBundle = null): self + { + return $this->with([ + 'verifyPeer' => $verifyPeer, + 'verifyHost' => $verifyHost, + 'caBundle' => $caBundle ?? $this->caBundle, + ]); + } + + public function withUserAgent(?string $userAgent): self + { + return $this->with(['userAgent' => $userAgent]); + } + + private static function assertPositiveLimit(?int $value, string $field): void + { + if ($value !== null && $value < 1) { + throw new \InvalidArgumentException(sprintf('%s must be greater than 0 when provided.', $field)); + } + } + + private static function assertReadableFileIfSet(?string $path, string $field): void + { + if ($path === null) { + return; + } + + if (!is_file($path) || !is_readable($path)) { + throw new \InvalidArgumentException(sprintf('%s must point to a readable file: %s', $field, $path)); + } + } + + private static function assertValidProxy(string $proxy): void + { + if (trim($proxy) === '') { + throw new \InvalidArgumentException('proxy must not be empty when provided.'); + } + + if (preg_match('/[\x00-\x1F\x7F]/', $proxy) === 1) { + throw new \InvalidArgumentException('proxy contains control characters.'); + } + + $scheme = parse_url($proxy, PHP_URL_SCHEME); + if (!is_string($scheme)) { + throw new \InvalidArgumentException(sprintf('proxy must include a scheme: %s', $proxy)); + } + + if (!in_array(strtolower($scheme), ['http', 'https', 'socks5', 'socks5h'], true)) { + throw new \InvalidArgumentException(sprintf('proxy scheme is not supported: %s', $proxy)); + } + } + + /** @return CurlOptionState */ + private function state(): array + { + return [ + 'timeoutSeconds' => $this->timeoutSeconds, + 'connectTimeoutSeconds' => $this->connectTimeoutSeconds, + 'followRedirects' => $this->followRedirects, + 'maxRedirects' => $this->maxRedirects, + 'proxy' => $this->proxy, + 'proxyAuth' => $this->proxyAuth, + 'verifyPeer' => $this->verifyPeer, + 'verifyHost' => $this->verifyHost, + 'caBundle' => $this->caBundle, + 'clientCertificate' => $this->clientCertificate, + 'clientKey' => $this->clientKey, + 'clientKeyPassphrase' => $this->clientKeyPassphrase, + 'userAgent' => $this->userAgent, + 'downloadPath' => $this->downloadPath, + 'streamDownloadPath' => $this->streamDownloadPath, + 'maxResponseBytes' => $this->maxResponseBytes, + 'maxDownloadBytes' => $this->maxDownloadBytes, + 'maxUploadBytes' => $this->maxUploadBytes, + 'httpVersion' => $this->httpVersion, + 'additional' => $this->additional, + ]; + } + + /** @param CurlOptionChanges $changes */ + private function with(array $changes): self + { + return new self(...array_replace($this->state(), $changes)); + } +} diff --git a/src/Http/CurlTransport.php b/src/Http/CurlTransport.php new file mode 100644 index 0000000..b37aeaf --- /dev/null +++ b/src/Http/CurlTransport.php @@ -0,0 +1,216 @@ +payload instanceof HttpRequest) { + return CommunicationResult::failure('CurlTransport expects HttpRequest payload.'); + } + + return $this->sendRequest($request->payload); + } + + public function sendRequest(HttpRequest $request): CommunicationResult + { + $resolvedRequest = $request->applyAuthenticators(); + + try { + RequestSecurityGuard::assertAllowed($resolvedRequest); + } catch (InvalidArgumentException $exception) { + return CommunicationResult::failure($exception->getMessage(), metadata: ['transport' => 'curl']); + } + $url = $resolvedRequest->buildUrl(); + $startedAt = microtime(true); + $this->dispatchStartEvent($resolvedRequest, $url); + + $handle = curl_init(); + + if ($handle === false) { + $this->dispatchInitializationFailure($resolvedRequest, $url, $startedAt, 'Unable to initialize cURL handle.'); + + return CommunicationResult::failure('Unable to initialize cURL handle.'); + } + + $configured = $this->configureHandle($handle, $resolvedRequest, $url, $startedAt); + if ($configured instanceof CommunicationResult) { + curl_close($handle); + + return $configured; + } + + $resolvedRequest = $configured['request']; + $headerCollector = $configured['headerCollector']; + $bodyCollector = $configured['bodyCollector']; + + $rawBody = curl_exec($handle); + $errno = curl_errno($handle); + $error = curl_error($handle); + $info = curl_getinfo($handle); + + $streamFinalizeError = $bodyCollector->finalize(); + $this->cleanupUploadHandle($resolvedRequest); + + curl_close($handle); + + $result = $this->buildExecutionResult( + $resolvedRequest, + $rawBody, + $bodyCollector, + $streamFinalizeError, + $errno, + $error, + $info, + $headerCollector, + ); + + $effectiveUrl = $info !== false ? $info['url'] : ''; + if ($effectiveUrl !== '') { + try { + RequestSecurityGuard::assertAllowed($resolvedRequest, $effectiveUrl); + } catch (InvalidArgumentException $exception) { + $result = CommunicationResult::failure( + $exception->getMessage(), + $result->statusCode, + $result->response, + $result->metadata, + ); + } + } + + $this->dispatchResultEvents($resolvedRequest, $result, $startedAt); + + return $result; + } + + /** + * @param array|false $info + */ + private function buildExecutionResult( + HttpRequest $request, + mixed $rawBody, + ResponseBodyCollector $bodyCollector, + ?string $streamFinalizeError, + int $errno, + string $error, + array|false $info, + ResponseHeaderCollector $headerCollector, + ): CommunicationResult { + $body = is_string($rawBody) ? $rawBody : $bodyCollector->responseBody(); + + if ($streamFinalizeError !== null) { + return CommunicationResult::failure( + $streamFinalizeError, + metadata: ['curl' => is_array($info) ? $info : [], 'transport' => 'curl'], + ); + } + + if (!is_string($rawBody) && $body === '') { + return CommunicationResult::failure( + sprintf('cURL request failed (%d): %s', $errno, $bodyCollector->error() ?? $error), + metadata: ['curl' => is_array($info) ? $info : [], 'transport' => 'curl'], + ); + } + + return CurlResultFactory::fromExecution( + $request, + 'curl', + $body, + $errno, + $bodyCollector->error() ?? $error, + $info, + $headerCollector->headers(), + ); + } + + private function cleanupUploadHandle(HttpRequest $request): void + { + UploadHandleManager::cleanup($request); + } + + /** + * @return array{request: HttpRequest, headerCollector: ResponseHeaderCollector, bodyCollector: ResponseBodyCollector}|CommunicationResult + */ + private function configureHandle(\CurlHandle $handle, HttpRequest $request, string $url, float $startedAt): array|CommunicationResult + { + $headerCollector = new ResponseHeaderCollector(); + $bodyCollector = null; + + try { + $bodyCollector = new ResponseBodyCollector($request); + $configurator = new CurlHandleConfigurator(); + $request = $configurator->configure($handle, $request, $headerCollector, $bodyCollector); + } catch (InvalidArgumentException $exception) { + $bodyCollector?->finalize(); + $this->dispatchInitializationFailure($request, $url, $startedAt, $exception->getMessage()); + + return CommunicationResult::failure($exception->getMessage()); + } + + return [ + 'request' => $request, + 'headerCollector' => $headerCollector, + 'bodyCollector' => $bodyCollector, + ]; + } + + private function dispatchInitializationFailure(HttpRequest $request, string $url, float $startedAt, string $error): void + { + CommunicationEventBus::dispatch('http.request.failed', [ + 'method' => $request->method->value, + 'url' => HttpRedactor::redactUrl($url), + 'error' => $error, + 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'transport' => 'curl', + ]); + } + + private function dispatchResultEvents(HttpRequest $request, CommunicationResult $result, float $startedAt): void + { + $payload = [ + 'method' => $request->method->value, + 'url' => HttpRedactor::redactUrl($request->buildUrl()), + 'status' => $result->statusCode, + 'successful' => $result->successful, + 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'transport' => 'curl', + ]; + + if ($result->successful) { + CommunicationEventBus::dispatch('http.request.finish', $payload); + + return; + } + + CommunicationEventBus::dispatch('http.request.failed', [ + ...$payload, + 'error' => $result->error, + ]); + } + + private function dispatchStartEvent(HttpRequest $request, string $url): void + { + CommunicationEventBus::dispatch('http.request.start', [ + 'method' => $request->method->value, + 'url' => HttpRedactor::redactUrl($url), + 'headers' => HttpRedactor::redactHeaders($request->headers->all()), + 'transport' => 'curl', + ]); + } +} diff --git a/src/Http/Enum/BodyType.php b/src/Http/Enum/BodyType.php new file mode 100644 index 0000000..63c85ae --- /dev/null +++ b/src/Http/Enum/BodyType.php @@ -0,0 +1,16 @@ +> + */ + private array $headers; + + /** + * @param array> $headers + */ + public function __construct(array $headers = []) + { + $normalized = []; + + foreach ($headers as $name => $value) { + $normalizedName = $this->normalize($name); + self::assertValidHeaderName($normalizedName); + + if (is_array($value)) { + foreach ($value as $singleValue) { + self::assertValidHeaderValue($singleValue); + } + + $normalized[$normalizedName] = $value; + + continue; + } + + self::assertValidHeaderValue($value); + $normalized[$normalizedName] = $value; + } + + $this->headers = $normalized; + } + + public static function assertValidHeaderName(string $name): void + { + if ($name === '' || preg_match('/^[!#$%&\'\*+\-.\^_`|~0-9A-Za-z]+$/', $name) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid HTTP header name: %s', $name)); + } + } + + public static function assertValidHeaderValue(string $value): void + { + if (str_contains($value, "\r") || str_contains($value, "\n")) { + throw new InvalidArgumentException('HTTP header value must not contain CRLF characters.'); + } + + if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value) === 1) { + throw new InvalidArgumentException('HTTP header value contains invalid control characters.'); + } + } + + /** + * @return array> + */ + public function all(): array + { + return $this->headers; + } + + /** + * @return string|list|null + */ + public function get(string $name): string|array|null + { + $normalized = $this->normalize($name); + + return $this->headers[$normalized] ?? null; + } + + public function has(string $name): bool + { + return array_key_exists($this->normalize($name), $this->headers); + } + + /** + * @return list + */ + public function toCurlHeaders(): array + { + $lines = []; + + foreach ($this->headers as $name => $value) { + if (is_array($value)) { + foreach ($value as $singleValue) { + $lines[] = sprintf('%s: %s', $name, $singleValue); + } + + continue; + } + + $lines[] = sprintf('%s: %s', $name, $value); + } + + return $lines; + } + + /** + * @param string|list $value + */ + public function with(string $name, string|array $value): self + { + $normalizedName = $this->normalize($name); + self::assertValidHeaderName($normalizedName); + + if (is_array($value)) { + foreach ($value as $singleValue) { + self::assertValidHeaderValue($singleValue); + } + } else { + self::assertValidHeaderValue($value); + } + + $headers = $this->headers; + $headers[$normalizedName] = $value; + + return new self($headers); + } + + public function without(string $name): self + { + $headers = $this->headers; + unset($headers[$this->normalize($name)]); + + return new self($headers); + } + + private function normalize(string $name): string + { + return implode('-', array_map(ucfirst(...), explode('-', strtolower(trim($name))))); + } +} diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php new file mode 100644 index 0000000..8b49ad3 --- /dev/null +++ b/src/Http/HttpClient.php @@ -0,0 +1,367 @@ + $middlewares + * @param array> $defaultHeaders + * @param list $authenticators + */ + private function __construct( + private TransportInterface $transport, + private array $middlewares = [], + private CurlOptions $defaultOptions = new CurlOptions(), + private array $defaultHeaders = [], + private array $authenticators = [], + private ?CookieJar $cookieJar = null, + ) {} + + public static function curl(): self + { + return new self(new CurlTransport()); + } + + public static function fake(?FakeHttpTransport $transport = null): self + { + return new self($transport ?? new FakeHttpTransport()); + } + + public static function fromConfig(HttpClientConfig $config): self + { + return new self( + transport: new CurlTransport(), + defaultOptions: new CurlOptions( + timeoutSeconds: $config->timeoutSeconds, + connectTimeoutSeconds: $config->connectTimeoutSeconds, + followRedirects: $config->followRedirects, + maxRedirects: $config->maxRedirects, + proxy: $config->proxy, + proxyAuth: ($config->proxyUsername !== null || $config->proxyPassword !== null) + ? sprintf('%s:%s', (string) $config->proxyUsername, (string) $config->proxyPassword) + : null, + verifyPeer: $config->verifyPeer, + verifyHost: $config->verifyHost, + caBundle: $config->caBundle, + userAgent: $config->userAgent, + maxResponseBytes: $config->maxResponseBytes, + ), + defaultHeaders: $config->defaultHeaders, + ); + } + + public static function multi(int $maxConcurrency = 10): Concurrent\RequestPool + { + return new Concurrent\RequestPool(new Concurrent\CurlMultiTransport(), $maxConcurrency); + } + + public static function multipart(): MultipartBody + { + return MultipartBody::new(); + } + + public static function using(TransportInterface $transport): self + { + return new self($transport); + } + + public function assert(): AssertableHttpTransport + { + if (!$this->transport instanceof FakeHttpTransport) { + throw new RuntimeException('HttpClient assertions are only available for fake transports.'); + } + + return new AssertableHttpTransport($this->transport); + } + + public function connectTimeout(int $seconds): self + { + return new self($this->transport, $this->middlewares, $this->defaultOptions->withConnectTimeoutSeconds($seconds), $this->defaultHeaders, $this->authenticators, $this->cookieJar); + } + + public function delete(string $url): CommunicationResult + { + return $this->send(HttpRequest::delete($url)); + } + + public function get(string $url): CommunicationResult + { + return $this->send(HttpRequest::get($url)); + } + + public function head(string $url): CommunicationResult + { + return $this->send(HttpRequest::head($url)); + } + + public function options(string $url): CommunicationResult + { + return $this->send(HttpRequest::options($url)); + } + + /** + * @param array $payload + */ + public function patch(string $url, array $payload): CommunicationResult + { + return $this->patchJson($url, $payload); + } + + /** + * @param array $payload + */ + public function patchJson(string $url, array $payload): CommunicationResult + { + return $this->send(HttpRequest::patch($url)->json($payload)); + } + + /** + * @param array $payload + */ + public function post(string $url, array $payload): CommunicationResult + { + return $this->postJson($url, $payload); + } + + /** + * @param array> $payload + */ + public function postForm(string $url, array $payload): CommunicationResult + { + return $this->send(HttpRequest::post($url)->form($payload)); + } + + /** + * @param array $payload + */ + public function postJson(string $url, array $payload): CommunicationResult + { + return $this->send(HttpRequest::post($url)->json($payload)); + } + + public function postMultipart(string $url, MultipartBody $payload): CommunicationResult + { + return $this->send(HttpRequest::post($url)->multipart($payload)); + } + + public function postRaw(string $url, string $payload, string $contentType = 'text/plain'): CommunicationResult + { + return $this->send(HttpRequest::post($url)->raw($payload, $contentType)); + } + + /** + * @param array $payload + */ + public function put(string $url, array $payload): CommunicationResult + { + return $this->putJson($url, $payload); + } + + /** + * @param array $payload + */ + public function putJson(string $url, array $payload): CommunicationResult + { + return $this->send(HttpRequest::put($url)->json($payload)); + } + + public function send(HttpRequest $request): CommunicationResult + { + $resolvedRequest = $this->applyDefaults($request); + if ($this->cookieJar !== null) { + $resolvedRequest = $this->cookieJar->applyToRequest($resolvedRequest); + } + + $pipeline = new MiddlewarePipeline($this->transport, $this->middlewares); + $result = $pipeline->send($resolvedRequest->toCommunicationRequest()); + + if ($this->cookieJar !== null && $result->response instanceof HttpResponse) { + $this->cookieJar->storeFromResponse($result->response, $resolvedRequest->buildUrl()); + } + + return $result; + } + + public function timeout(int $seconds): self + { + return new self($this->transport, $this->middlewares, $this->defaultOptions->withTimeoutSeconds($seconds), $this->defaultHeaders, $this->authenticators, $this->cookieJar); + } + + public function withApiKey(string $header, string $value): self + { + return $this->withApiKeyHeader($header, $value); + } + + public function withApiKeyHeader(string $header, string $value): self + { + return $this->withAuthenticator(new ApiKeyAuth($header, $value)); + } + + public function withApiKeyQuery(string $key, string $value): self + { + return $this->withAuthenticator(new ApiKeyAuth($key, $value, true)); + } + + public function withAuthenticator(AuthenticatorInterface $authenticator): self + { + $authenticators = $this->authenticators; + $authenticators[] = $authenticator; + + return new self($this->transport, $this->middlewares, $this->defaultOptions, $this->defaultHeaders, $authenticators, $this->cookieJar); + } + + public function withBasicAuth(string $username, string $password): self + { + return $this->withAuthenticator(new BasicAuth($username, $password)); + } + + public function withBearerToken(string $token): self + { + return $this->withAuthenticator(new BearerTokenAuth($token)); + } + + public function withCircuitBreaker(CircuitBreaker $circuitBreaker): self + { + return $this->withMiddleware(new CircuitBreakerMiddleware($circuitBreaker)); + } + + public function withCookieJar(CookieJar $cookieJar): self + { + return new self($this->transport, $this->middlewares, $this->defaultOptions, $this->defaultHeaders, $this->authenticators, $cookieJar); + } + + /** + * @param array> $headers + */ + public function withDefaultHeaders(array $headers): self + { + return $this->withMiddleware(new HeaderMiddleware($headers)); + } + + /** + * @param array> $headers + */ + public function withHeaders(array $headers): self + { + return new self($this->transport, $this->middlewares, $this->defaultOptions, $headers, $this->authenticators, $this->cookieJar); + } + + public function withHttpRetry(?HttpRetryPolicy $policy = null): self + { + return $this->withRetry($policy ?? HttpRetryPolicy::standard()); + } + + public function withIdempotency(string $headerName = 'Idempotency-Key'): self + { + return $this->withMiddleware(new IdempotencyMiddleware($headerName)); + } + + /** + * @param callable(string, array): void $logger + */ + public function withLogging(callable $logger): self + { + return $this->withMiddleware(new LoggingMiddleware(Closure::fromCallable($logger))); + } + + public function withMiddleware(MiddlewareInterface $middleware): self + { + $middlewares = $this->middlewares; + $middlewares[] = $middleware; + + return new self($this->transport, $middlewares, $this->defaultOptions, $this->defaultHeaders, $this->authenticators, $this->cookieJar); + } + + public function withQueryAuth(string $key, string $value): self + { + return $this->withApiKeyQuery($key, $value); + } + + public function withRateLimit(RateLimiter $rateLimiter): self + { + return $this->withMiddleware(new RateLimitMiddleware($rateLimiter)); + } + + public function withRetry(RetryPolicy $policy): self + { + return $this->withMiddleware(new RetryMiddleware($policy)); + } + + public function withSigner(RequestSignerInterface $signer): self + { + return $this->withAuthenticator(new SignedRequestAuth($signer)); + } + + public function withTimeout(int $seconds): self + { + return $this->withMiddleware(new TimeoutMiddleware($seconds)); + } + + private function applyDefaults(HttpRequest $request): HttpRequest + { + $request = $request + ->timeout($this->defaultOptions->timeoutSeconds) + ->connectTimeout($this->defaultOptions->connectTimeoutSeconds) + ->followRedirects($this->defaultOptions->followRedirects, $this->defaultOptions->maxRedirects) + ->verifyTls($this->defaultOptions->verifyPeer, $this->defaultOptions->verifyHost) + ->headers($this->defaultHeaders); + + if ($this->defaultOptions->proxy !== null) { + $request = $request->proxy($this->defaultOptions->proxy); + } + + if ($this->defaultOptions->proxyAuth !== null && str_contains($this->defaultOptions->proxyAuth, ':')) { + [$username, $password] = explode(':', $this->defaultOptions->proxyAuth, 2); + $request = $request->proxyAuth($username, $password); + } + + if ($this->defaultOptions->caBundle !== null) { + $request = $request->caBundle($this->defaultOptions->caBundle); + } + + if ($this->defaultOptions->userAgent !== null) { + $request = $request->userAgent($this->defaultOptions->userAgent); + } + + if ($this->defaultOptions->maxResponseBytes !== null) { + $request = $request->maxResponseBytes($this->defaultOptions->maxResponseBytes); + } + + foreach ($this->authenticators as $authenticator) { + $request = $request->withAuthenticator($authenticator); + } + + return $request; + } +} diff --git a/src/Http/HttpClientConfig.php b/src/Http/HttpClientConfig.php new file mode 100644 index 0000000..03147c1 --- /dev/null +++ b/src/Http/HttpClientConfig.php @@ -0,0 +1,126 @@ +> $defaultHeaders + */ + public function __construct( + public int $timeoutSeconds = 10, + public int $connectTimeoutSeconds = 10, + public bool $followRedirects = false, + public int $maxRedirects = 5, + public bool $verifyPeer = true, + public bool $verifyHost = true, + public ?string $caBundle = null, + public ?string $proxy = null, + public ?string $proxyUsername = null, + public ?string $proxyPassword = null, + public ?string $userAgent = null, + public ?int $maxResponseBytes = null, + public array $defaultHeaders = [], + ) { + new CurlOptions( + timeoutSeconds: $this->timeoutSeconds, + connectTimeoutSeconds: $this->connectTimeoutSeconds, + followRedirects: $this->followRedirects, + maxRedirects: $this->maxRedirects, + proxy: $this->proxy, + verifyPeer: $this->verifyPeer, + verifyHost: $this->verifyHost, + caBundle: $this->caBundle, + userAgent: $this->userAgent, + maxResponseBytes: $this->maxResponseBytes, + ); + + new HeaderBag($this->defaultHeaders); + } + + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + return new self( + timeoutSeconds: self::intFrom($config, 'timeoutSeconds', 10), + connectTimeoutSeconds: self::intFrom($config, 'connectTimeoutSeconds', 10), + followRedirects: (bool) ($config['followRedirects'] ?? false), + maxRedirects: self::intFrom($config, 'maxRedirects', 5), + verifyPeer: (bool) ($config['verifyPeer'] ?? true), + verifyHost: (bool) ($config['verifyHost'] ?? true), + caBundle: is_string($config['caBundle'] ?? null) ? $config['caBundle'] : null, + proxy: is_string($config['proxy'] ?? null) ? $config['proxy'] : null, + proxyUsername: is_string($config['proxyUsername'] ?? null) ? $config['proxyUsername'] : null, + proxyPassword: is_string($config['proxyPassword'] ?? null) ? $config['proxyPassword'] : null, + userAgent: is_string($config['userAgent'] ?? null) ? $config['userAgent'] : null, + maxResponseBytes: isset($config['maxResponseBytes']) ? self::intFrom($config, 'maxResponseBytes', 0) : null, + defaultHeaders: self::parseDefaultHeaders($config['defaultHeaders'] ?? null), + ); + } + + /** + * @param array $config + */ + private static function intFrom(array $config, string $key, int $default): int + { + $value = $config[$key] ?? $default; + + if (is_int($value)) { + return $value; + } + + if (is_float($value)) { + return (int) $value; + } + + if (is_string($value) && is_numeric($value)) { + return (int) $value; + } + + return $default; + } + + /** + * @return array> + */ + private static function parseDefaultHeaders(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $defaultHeaders = []; + foreach ($value as $name => $headerValue) { + if (!is_string($name)) { + continue; + } + + if (is_string($headerValue)) { + $defaultHeaders[$name] = $headerValue; + + continue; + } + + if (!is_array($headerValue)) { + continue; + } + + $items = []; + foreach ($headerValue as $item) { + if (is_string($item)) { + $items[] = $item; + } + } + + if ($items !== []) { + $defaultHeaders[$name] = $items; + } + } + + return $defaultHeaders; + } +} diff --git a/src/Http/HttpRedactor.php b/src/Http/HttpRedactor.php new file mode 100644 index 0000000..2dee89d --- /dev/null +++ b/src/Http/HttpRedactor.php @@ -0,0 +1,96 @@ + + */ + private const array SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'api-key', + 'x-auth-token', + 'x-access-token', + ]; + + /** + * @var list + */ + private const array SENSITIVE_QUERY_KEYS = [ + 'token', + 'api_key', + 'access_token', + 'refresh_token', + 'client_secret', + 'password', + 'secret', + 'signature', + ]; + + /** + * @param array> $headers + * @return array> + */ + public static function redactHeaders(array $headers): array + { + $redacted = []; + + foreach ($headers as $name => $value) { + $normalized = strtolower($name); + if (in_array($normalized, self::SENSITIVE_HEADERS, true)) { + $redacted[$name] = '[REDACTED]'; + + continue; + } + + $redacted[$name] = $value; + } + + return $redacted; + } + + public static function redactUrl(string $url): string + { + $parts = parse_url($url); + if ($parts === false || !isset($parts['scheme'], $parts['host'])) { + return $url; + } + + $query = []; + parse_str((string) ($parts['query'] ?? ''), $query); + foreach ($query as $key => $value) { + if (!is_string($key)) { + continue; + } + + if (!in_array(strtolower($key), self::SENSITIVE_QUERY_KEYS, true)) { + continue; + } + + $query[$key] = '[REDACTED]'; + } + + $base = sprintf('%s://%s', $parts['scheme'], $parts['host']); + if (isset($parts['port'])) { + $base .= ':' . $parts['port']; + } + + $base .= $parts['path'] ?? ''; + $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + if ($queryString !== '') { + $base .= '?' . $queryString; + } + + if (isset($parts['fragment']) && $parts['fragment'] !== '') { + $base .= '#' . $parts['fragment']; + } + + return $base; + } +} diff --git a/src/Http/HttpRequest.php b/src/Http/HttpRequest.php new file mode 100644 index 0000000..29807fb --- /dev/null +++ b/src/Http/HttpRequest.php @@ -0,0 +1,614 @@ + $authenticators + * @param array $metadata + */ + public function __construct( + public HttpMethod $method, + public string $url, + public HeaderBag $headers = new HeaderBag(), + public QueryParams $queryParams = new QueryParams(), + public ?HttpBody $body = null, + public CurlOptions $options = new CurlOptions(), + public array $authenticators = [], + public array $metadata = [], + ) { + $this->assertValidUrl($this->url); + } + + public static function delete(string $url): self + { + return new self(HttpMethod::Delete, $url); + } + + public static function get(string $url): self + { + return new self(HttpMethod::Get, $url); + } + + public static function head(string $url): self + { + return new self(HttpMethod::Head, $url); + } + + public static function options(string $url): self + { + return new self(HttpMethod::Options, $url); + } + + public static function patch(string $url): self + { + return new self(HttpMethod::Patch, $url); + } + + public static function post(string $url): self + { + return new self(HttpMethod::Post, $url); + } + + public static function put(string $url): self + { + return new self(HttpMethod::Put, $url); + } + + public function acceptJson(): self + { + return $this->header('Accept', 'application/json'); + } + + /** + * @param list $hosts + */ + public function allowHosts(array $hosts): self + { + return $this->metadata([ + ...$this->metadata, + 'security_allow_hosts' => $hosts, + ]); + } + + public function applyAuthenticators(): self + { + $request = $this; + + foreach ($this->authenticators as $authenticator) { + $request = $authenticator->apply($request); + } + + return new self( + $request->method, + $request->url, + $request->headers, + $request->queryParams, + $request->body, + $request->options, + [], + $request->metadata, + ); + } + + /** + * @param list $hosts + */ + public function blockHosts(array $hosts): self + { + return $this->metadata([ + ...$this->metadata, + 'security_block_hosts' => $hosts, + ]); + } + + public function blockPrivateNetworks(bool $enabled = true): self + { + return $this->metadata([ + ...$this->metadata, + 'security_block_private_networks' => $enabled, + ]); + } + + public function body(HttpBody $body): self + { + return new self( + $this->method, + $this->url, + $this->headers, + $this->queryParams, + $body, + $this->options, + $this->authenticators, + $this->metadata, + ); + } + + public function buildUrl(): string + { + $additional = $this->queryParams->all(); + if ($additional === []) { + return $this->url; + } + + $parts = parse_url($this->url); + if ($parts === false) { + return $this->url; + } + + $existing = []; + parse_str((string) ($parts['query'] ?? ''), $existing); + foreach ($additional as $key => $value) { + if ($value === null) { + unset($existing[$key]); + + continue; + } + + $existing[$key] = $value; + } + + $query = http_build_query($existing, '', '&', PHP_QUERY_RFC3986); + + $base = ($parts['scheme'] ?? '') . '://' . ($parts['host'] ?? ''); + if (isset($parts['port'])) { + $base .= ':' . $parts['port']; + } + + $base .= $parts['path'] ?? ''; + if ($query !== '') { + $base .= '?' . $query; + } + if (isset($parts['fragment']) && $parts['fragment'] !== '') { + $base .= '#' . $parts['fragment']; + } + + return $base; + } + + public function caBundle(string $path): self + { + return $this->withOptions($this->options->withTls(caBundle: $path)); + } + + public function connectTimeout(int $seconds): self + { + return $this->withOptions($this->options->withConnectTimeoutSeconds($seconds)); + } + + public function contentType(string $contentType): self + { + return $this->header('Content-Type', $contentType); + } + + public function downloadTo(string $path): self + { + return $this->withOptions($this->options->withDownloadPath($path)->withStreamDownloadPath(null)); + } + + public function followRedirects(bool $enabled = true, ?int $max = null): self + { + return $this->withOptions($this->options->withFollowRedirects($enabled, $max)); + } + + /** + * @param array> $data + */ + public function form(array $data): self + { + return $this->body(new FormBody($data)); + } + + /** + * @param string|list $value + */ + public function header(string $name, string|array $value): self + { + return new self( + $this->method, + $this->url, + $this->headers->with($name, $this->normalizeHeaderValue($value)), + $this->queryParams, + $this->body, + $this->options, + $this->authenticators, + $this->metadata, + ); + } + + /** + * @param array> $headers + */ + public function headers(array $headers): self + { + $bag = $this->headers; + foreach ($headers as $name => $value) { + $bag = $bag->with($name, $value); + } + + return new self( + $this->method, + $this->url, + $bag, + $this->queryParams, + $this->body, + $this->options, + $this->authenticators, + $this->metadata, + ); + } + + public function json(mixed $value): self + { + return $this->body(new JsonBody($value))->acceptJson(); + } + + public function jsonWithFlags(mixed $value, int $flags): self + { + return $this->body(new JsonBody($value, $flags))->acceptJson(); + } + + public function maxDownloadBytes(int $bytes): self + { + return $this->withOptions($this->options->withResponseLimits($this->options->maxResponseBytes, $bytes, $this->options->maxUploadBytes)); + } + + public function maxRedirects(int $maxRedirects): self + { + return $this->withOptions($this->options->withMaxRedirects($maxRedirects)); + } + + public function maxResponseBytes(int $bytes): self + { + return $this->withOptions($this->options->withResponseLimits($bytes, $this->options->maxDownloadBytes, $this->options->maxUploadBytes)); + } + + public function maxUploadBytes(int $bytes): self + { + return $this->withOptions($this->options->withResponseLimits($this->options->maxResponseBytes, $this->options->maxDownloadBytes, $bytes)); + } + + /** + * @param array $metadata + */ + public function metadata(array $metadata): self + { + return new self( + $this->method, + $this->url, + $this->headers, + $this->queryParams, + $this->body, + $this->options, + $this->authenticators, + $metadata, + ); + } + + public function mtls(string $certificatePath, string $keyPath, ?string $passphrase = null): self + { + return $this->withOptions($this->options->withMtls($certificatePath, $keyPath, $passphrase)); + } + + public function multipart(?MultipartBody $multipartBody = null): self + { + return $this->body($multipartBody ?? MultipartBody::new()); + } + + public function option(int $option, mixed $value): self + { + return $this->withOptions($this->options->withAdditional($option, $value)); + } + + public function proxy(string $proxy): self + { + return $this->withOptions($this->options->withProxy($proxy)); + } + + public function proxyAuth(string $username, string $password): self + { + return $this->withOptions($this->options->withProxyAuth(sprintf('%s:%s', $username, $password))); + } + + /** + * @param array|null> $values + */ + public function queries(array $values): self + { + $params = $this->queryParams; + foreach ($values as $name => $value) { + $params = $params->with($name, $value); + } + + return new self( + $this->method, + $this->url, + $this->headers, + $params, + $this->body, + $this->options, + $this->authenticators, + $this->metadata, + ); + } + + public function query(string $key, mixed $value): self + { + return new self( + $this->method, + $this->url, + $this->headers, + $this->queryParams->with($key, $this->normalizeQueryValue($value)), + $this->body, + $this->options, + $this->authenticators, + $this->metadata, + ); + } + + public function raw(string $content, string $contentType = 'text/plain'): self + { + return $this->body(new RawBody($content, $contentType)); + } + + public function streamDownloadTo(string $path): self + { + return $this->withOptions($this->options->withStreamDownloadPath($path)->withDownloadPath(null)); + } + + public function timeout(int $seconds): self + { + return $this->withOptions($this->options->withTimeoutSeconds($seconds)); + } + + public function toCommunicationRequest(): CommunicationRequest + { + return new CommunicationRequest( + 'http', + $this, + $this->headers->all(), + [ + 'method' => $this->method->value, + 'url' => $this->buildUrl(), + 'timeout' => $this->options->timeoutSeconds, + 'connect_timeout' => $this->options->connectTimeoutSeconds, + ], + $this->metadata, + ); + } + + public function uploadFromFile(string $path): self + { + if (!is_file($path) || !is_readable($path)) { + throw new InvalidArgumentException(sprintf('Upload file is missing or unreadable: %s', $path)); + } + + $size = filesize($path); + if (!is_int($size)) { + throw new InvalidArgumentException(sprintf('Unable to determine upload file size: %s', $path)); + } + + return $this->metadata([ + ...$this->metadata, + 'upload_file_path' => $path, + 'upload_size' => $size, + ]); + } + + /** + * @param resource $stream + */ + public function uploadFromStream(mixed $stream, int $size): self + { + if (!is_resource($stream)) { + throw new InvalidArgumentException('Upload stream must be a valid stream resource.'); + } + + if ($size < 0) { + throw new InvalidArgumentException('Upload stream size must be greater than or equal to 0.'); + } + + return $this->metadata([ + ...$this->metadata, + 'upload_stream' => $stream, + 'upload_size' => $size, + ]); + } + + public function userAgent(string $userAgent): self + { + return $this->withOptions($this->options->withUserAgent($userAgent)); + } + + public function verifyTls(bool $verifyPeer = true, bool $verifyHost = true): self + { + return $this->withOptions($this->options->withTls($verifyPeer, $verifyHost)); + } + + public function withApiKeyHeader(string $header, string $value): self + { + return $this->withApiKey($header, $value, false); + } + + public function withApiKeyQuery(string $key, string $value): self + { + return $this->withApiKey($key, $value, true); + } + + public function withAuthenticator(AuthenticatorInterface $authenticator): self + { + $authenticators = $this->authenticators; + $authenticators[] = $authenticator; + + return new self( + $this->method, + $this->url, + $this->headers, + $this->queryParams, + $this->body, + $this->options, + $authenticators, + $this->metadata, + ); + } + + public function withBasicAuth(string $username, string $password): self + { + return $this->withAuthenticator(new BasicAuth(username: $username, password: $password)); + } + + public function withBearerToken(string $token): self + { + return $this->withAuthenticator(new BearerTokenAuth(token: $token)); + } + + public function withoutHeader(string $name): self + { + return new self( + $this->method, + $this->url, + $this->headers->without($name), + $this->queryParams, + $this->body, + $this->options, + $this->authenticators, + $this->metadata, + ); + } + + public function withoutQuery(string $name): self + { + return new self( + $this->method, + $this->url, + $this->headers, + $this->queryParams->with($name, null), + $this->body, + $this->options, + $this->authenticators, + $this->metadata, + ); + } + + public function withoutTlsVerification(): self + { + return $this->verifyTls(false, false); + } + + public function withSigner(RequestSignerInterface $signer): self + { + return $this->withAuthenticator(new SignedRequestAuth($signer)); + } + + private function assertValidUrl(string $url): void + { + $trimmed = trim($url); + + if ($trimmed === '') { + throw new InvalidArgumentException('HTTP URL must not be empty.'); + } + + if (preg_match('/[\x00-\x1F\x7F]/', $trimmed) === 1) { + throw new InvalidArgumentException('HTTP URL contains control characters.'); + } + + if (filter_var($trimmed, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException(sprintf('Invalid HTTP URL: %s', $url)); + } + + $scheme = parse_url($trimmed, PHP_URL_SCHEME); + if (!is_string($scheme) || !in_array(strtolower($scheme), ['http', 'https'], true)) { + throw new InvalidArgumentException('HTTP URL scheme must be http or https.'); + } + + $host = parse_url($trimmed, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + throw new InvalidArgumentException('HTTP URL host is required.'); + } + } + + /** + * @param string|array $value + * @return string|list + */ + private function normalizeHeaderValue(string|array $value): string|array + { + if (is_string($value)) { + return $value; + } + + $normalized = []; + foreach ($value as $item) { + if (!is_string($item)) { + throw new InvalidArgumentException('Header values must be strings.'); + } + + $normalized[] = $item; + } + + return $normalized; + } + + /** + * @return scalar|list|null + */ + private function normalizeQueryValue(mixed $value): mixed + { + if (is_scalar($value) || $value === null) { + return $value; + } + + if (!is_array($value)) { + throw new InvalidArgumentException('Query value must be scalar, list of scalar values, or null.'); + } + + $normalized = []; + + foreach ($value as $item) { + if (!is_scalar($item)) { + throw new InvalidArgumentException('Query list values must be scalar.'); + } + + $normalized[] = $item; + } + + return $normalized; + } + + private function withApiKey(string $key, string $value, bool $query): self + { + return $this->withAuthenticator(new ApiKeyAuth(key: $key, value: $value, inQuery: $query)); + } + + private function withOptions(CurlOptions $options): self + { + return new self( + $this->method, + $this->url, + $this->headers, + $this->queryParams, + $this->body, + $options, + $this->authenticators, + $this->metadata, + ); + } +} diff --git a/src/Http/HttpResponse.php b/src/Http/HttpResponse.php new file mode 100644 index 0000000..885e6f3 --- /dev/null +++ b/src/Http/HttpResponse.php @@ -0,0 +1,173 @@ +> $headers + * @param array $metadata + */ + public function __construct( + public ?int $statusCode, + public string $body, + public array $headers = [], + public array $metadata = [], + ) {} + + public function accepted(): bool + { + return $this->statusCode === 202; + } + + public function clientError(): bool + { + return $this->isClientError(); + } + + public function created(): bool + { + return $this->statusCode === 201; + } + + public function failed(): bool + { + return $this->isClientError() || $this->isServerError(); + } + + /** + * @return string|list|null + */ + public function header(string $name): string|array|null + { + foreach ($this->headers as $key => $value) { + if (strcasecmp($key, $name) === 0) { + return $value; + } + } + + return null; + } + + public function headerLine(string $name): ?string + { + $value = $this->header($name); + + if ($value === null) { + return null; + } + + return is_array($value) ? implode(', ', $value) : $value; + } + + public function isClientError(): bool + { + return $this->statusGroup() === 4; + } + + public function isInformational(): bool + { + return $this->statusGroup() === 1; + } + + public function isRedirection(): bool + { + return $this->statusGroup() === 3; + } + + public function isServerError(): bool + { + return $this->statusGroup() === 5; + } + + public function isSuccessful(): bool + { + return $this->statusGroup() === 2; + } + + /** + * @throws JsonException + */ + public function json(bool $associative = true): mixed + { + return json_decode($this->body, $associative, 512, JSON_THROW_ON_ERROR); + } + + /** + * @throws JsonException + */ + public function jsonOrFail(bool $associative = true): mixed + { + return $this->json($associative); + } + + public function jsonOrNull(bool $associative = true): mixed + { + try { + return $this->json($associative); + } catch (JsonException) { + return null; + } + } + + public function noContent(): bool + { + return $this->statusCode === 204; + } + + public function ok(): bool + { + return $this->isSuccessful(); + } + + public function redirect(): bool + { + return $this->isRedirection(); + } + + public function serverError(): bool + { + return $this->isServerError(); + } + + public function stats(): HttpTransferStats + { + $curlInfo = $this->metadata['curl'] ?? []; + if (!is_array($curlInfo)) { + $curlInfo = []; + } + + /** @var array $info */ + $info = $curlInfo; + + return HttpTransferStats::fromCurlInfo($info); + } + + public function statusGroup(): ?int + { + if ($this->statusCode === null || $this->statusCode < 100) { + return null; + } + + $group = intdiv($this->statusCode, 100); + if ($group < 1 || $group > 5) { + return null; + } + + return $group; + } + + public function successful(): bool + { + return $this->isSuccessful(); + } + + public function text(): string + { + return $this->body; + } +} diff --git a/src/Http/HttpTransferStats.php b/src/Http/HttpTransferStats.php new file mode 100644 index 0000000..950e43d --- /dev/null +++ b/src/Http/HttpTransferStats.php @@ -0,0 +1,74 @@ + $info + */ + public static function fromCurlInfo(array $info): self + { + $httpVersion = null; + if (is_int($info['http_version'] ?? null)) { + $httpVersion = match ($info['http_version']) { + CURL_HTTP_VERSION_1_0 => '1.0', + CURL_HTTP_VERSION_1_1 => '1.1', + CURL_HTTP_VERSION_2_0 => '2', + CURL_HTTP_VERSION_3 => '3', + default => null, + }; + } + + return new self( + totalTimeMs: self::secondsToMs($info['total_time'] ?? null), + dnsTimeMs: self::secondsToMs($info['namelookup_time'] ?? null), + connectTimeMs: self::secondsToMs($info['connect_time'] ?? null), + ttfbMs: self::secondsToMs($info['starttransfer_time'] ?? null), + effectiveUrl: is_string($info['url'] ?? null) ? $info['url'] : null, + primaryIp: is_string($info['primary_ip'] ?? null) ? $info['primary_ip'] : null, + redirectCount: is_int($info['redirect_count'] ?? null) ? $info['redirect_count'] : null, + uploadedBytes: is_float($info['size_upload'] ?? null) || is_int($info['size_upload'] ?? null) + ? (int) $info['size_upload'] + : null, + downloadedBytes: is_float($info['size_download'] ?? null) || is_int($info['size_download'] ?? null) + ? (int) $info['size_download'] + : null, + uploadSpeed: is_float($info['speed_upload'] ?? null) || is_int($info['speed_upload'] ?? null) + ? (float) $info['speed_upload'] + : null, + downloadSpeed: is_float($info['speed_download'] ?? null) || is_int($info['speed_download'] ?? null) + ? (float) $info['speed_download'] + : null, + contentType: is_string($info['content_type'] ?? null) ? $info['content_type'] : null, + httpVersion: $httpVersion, + ); + } + + private static function secondsToMs(mixed $seconds): ?int + { + if (!is_int($seconds) && !is_float($seconds)) { + return null; + } + + return (int) round($seconds * 1000); + } +} diff --git a/src/Http/Internal/CurlHandleConfigurator.php b/src/Http/Internal/CurlHandleConfigurator.php new file mode 100644 index 0000000..8f9486e --- /dev/null +++ b/src/Http/Internal/CurlHandleConfigurator.php @@ -0,0 +1,177 @@ +applyBodyAndContentType($request, $handle); + $resolvedRequest = $this->applyUpload($resolvedRequest, $handle); + + $this->setRequiredStringOption($handle, CURLOPT_URL, $resolvedRequest->buildUrl(), 'Request URL must not be empty.'); + $this->setRequiredStringOption($handle, CURLOPT_CUSTOMREQUEST, $resolvedRequest->method->value, 'HTTP method must not be empty.'); + + curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($handle, CURLOPT_FOLLOWLOCATION, $resolvedRequest->options->followRedirects); + curl_setopt($handle, CURLOPT_MAXREDIRS, $resolvedRequest->options->maxRedirects); + curl_setopt($handle, CURLOPT_TIMEOUT, $resolvedRequest->options->timeoutSeconds); + curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, $resolvedRequest->options->connectTimeoutSeconds); + curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, $resolvedRequest->options->verifyPeer); + curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, $resolvedRequest->options->verifyHost ? 2 : 0); + + $this->setOptionalStringOption($handle, CURLOPT_PROXY, $resolvedRequest->options->proxy); + $this->setOptionalStringOption($handle, CURLOPT_PROXYUSERPWD, $resolvedRequest->options->proxyAuth); + $this->setOptionalStringOption($handle, CURLOPT_CAINFO, $resolvedRequest->options->caBundle); + $this->setOptionalStringOption($handle, CURLOPT_SSLCERT, $resolvedRequest->options->clientCertificate); + $this->setOptionalStringOption($handle, CURLOPT_SSLKEY, $resolvedRequest->options->clientKey); + $this->setOptionalStringOption($handle, CURLOPT_KEYPASSWD, $resolvedRequest->options->clientKeyPassphrase); + $this->setOptionalStringOption($handle, CURLOPT_USERAGENT, $resolvedRequest->options->userAgent); + + if ($resolvedRequest->options->httpVersion !== null) { + curl_setopt($handle, CURLOPT_HTTP_VERSION, $resolvedRequest->options->httpVersion); + } + + if ($resolvedRequest->method === HttpMethod::Head) { + curl_setopt($handle, CURLOPT_NOBODY, true); + } + + $curlHeaders = $resolvedRequest->headers->toCurlHeaders(); + if ($curlHeaders !== []) { + curl_setopt($handle, CURLOPT_HTTPHEADER, $curlHeaders); + } + + curl_setopt( + $handle, + CURLOPT_HEADERFUNCTION, + static function (\CurlHandle $curlHandle, string $line) use ($headers): int { + // cURL requires the handle parameter in this callback signature. + unset($curlHandle); + + return $headers->collect($line); + }, + ); + curl_setopt( + $handle, + CURLOPT_WRITEFUNCTION, + static function (\CurlHandle $curlHandle, string $chunk) use ($bodyCollector): int { + // cURL requires the handle parameter in this callback signature. + unset($curlHandle); + + return $bodyCollector->collect($chunk); + }, + ); + + foreach ($resolvedRequest->options->additional as $option => $value) { + curl_setopt($handle, $option, $value); + } + + return $resolvedRequest; + } + + private function applyBodyAndContentType(HttpRequest $request, \CurlHandle $handle): HttpRequest + { + if (isset($request->metadata['upload_file_path']) || isset($request->metadata['upload_stream'])) { + if ($request->body !== null) { + throw new InvalidArgumentException('HTTP request cannot combine uploadFromFile/uploadFromStream with a request body.'); + } + + return $request; + } + + if ($request->body === null) { + return $request; + } + + $resolvedRequest = $request; + if ($request->headers->get('Content-Type') === null) { + $resolvedRequest = $resolvedRequest->header('Content-Type', $request->body->contentType()); + } + + $payload = $request->body->toCurlPayload(); + if (is_string($payload) && $request->options->maxUploadBytes !== null && strlen($payload) > $request->options->maxUploadBytes) { + throw new InvalidArgumentException(sprintf('HTTP request body exceeded max upload bytes (%d).', $request->options->maxUploadBytes)); + } + + curl_setopt($handle, CURLOPT_POSTFIELDS, $payload); + + return $resolvedRequest; + } + + private function applyUpload(HttpRequest $request, \CurlHandle $handle): HttpRequest + { + $uploadPath = $request->metadata['upload_file_path'] ?? null; + $uploadStream = $request->metadata['upload_stream'] ?? null; + + if (!is_string($uploadPath) && !is_resource($uploadStream)) { + return $request; + } + + $size = $request->metadata['upload_size'] ?? null; + if (!is_int($size) || $size < 0) { + throw new InvalidArgumentException('Upload size metadata is missing or invalid.'); + } + + if ($request->options->maxUploadBytes !== null && $size > $request->options->maxUploadBytes) { + throw new InvalidArgumentException(sprintf('HTTP upload exceeded max upload bytes (%d).', $request->options->maxUploadBytes)); + } + + $resolvedRequest = $request; + if ($resolvedRequest->headers->get('Content-Type') === null) { + $resolvedRequest = $resolvedRequest->header('Content-Type', 'application/octet-stream'); + } + + $resource = null; + if (is_string($uploadPath)) { + $resource = fopen($uploadPath, 'rb'); + if ($resource === false) { + throw new InvalidArgumentException(sprintf('Failed to open upload file: %s', $uploadPath)); + } + } elseif (is_resource($uploadStream)) { + $resource = $uploadStream; + rewind($resource); + } + + if (!is_resource($resource)) { + throw new InvalidArgumentException('Upload source must be a file path or stream resource.'); + } + + curl_setopt($handle, CURLOPT_UPLOAD, true); + curl_setopt($handle, CURLOPT_INFILE, $resource); + curl_setopt($handle, CURLOPT_INFILESIZE, $size); + + return $resolvedRequest->metadata([ + ...$resolvedRequest->metadata, + '_upload_handle' => $resource, + '_upload_opened_by_configurator' => is_string($uploadPath), + ]); + } + + private function setOptionalStringOption(\CurlHandle $handle, int $option, ?string $value): void + { + if ($value === null || $value === '') { + return; + } + + curl_setopt($handle, $option, $value); + } + + private function setRequiredStringOption(\CurlHandle $handle, int $option, string $value, string $error): void + { + if ($value === '') { + throw new InvalidArgumentException($error); + } + + curl_setopt($handle, $option, $value); + } +} diff --git a/src/Http/Internal/CurlResultFactory.php b/src/Http/Internal/CurlResultFactory.php new file mode 100644 index 0000000..87b0a0b --- /dev/null +++ b/src/Http/Internal/CurlResultFactory.php @@ -0,0 +1,119 @@ +|false $rawInfo + * @param array> $responseHeaders + */ + public static function fromExecution( + HttpRequest $request, + string $transport, + string $body, + int $errno, + string $error, + array|false $rawInfo, + array $responseHeaders, + ): CommunicationResult { + $info = is_array($rawInfo) ? $rawInfo : []; + $statusCode = self::statusCode($info); + $downloadPath = $request->options->downloadPath; + + if ($request->options->maxResponseBytes !== null && strlen($body) > $request->options->maxResponseBytes) { + return CommunicationResult::failure( + sprintf('HTTP response exceeded max allowed bytes (%d).', $request->options->maxResponseBytes), + $statusCode, + null, + ['transport' => $transport], + ); + } + + if ($request->options->maxDownloadBytes !== null && $downloadPath !== null && strlen($body) > $request->options->maxDownloadBytes) { + return CommunicationResult::failure( + sprintf('HTTP download exceeded max allowed bytes (%d).', $request->options->maxDownloadBytes), + $statusCode, + null, + ['transport' => $transport], + ); + } + + if ($downloadPath !== null) { + $downloadError = self::writeDownloadBody($downloadPath, $body); + + if ($downloadError !== null) { + return CommunicationResult::failure( + $downloadError, + $statusCode, + metadata: ['transport' => $transport, 'curl' => $info], + ); + } + } + + if ($errno !== 0) { + return CommunicationResult::failure( + sprintf('cURL request failed (%d): %s', $errno, $error), + $statusCode, + null, + ['curl' => $info, 'transport' => $transport], + ); + } + + $response = new HttpResponse($statusCode, $body, $responseHeaders, ['curl' => $info]); + + if ($statusCode !== null && $statusCode >= 400) { + return CommunicationResult::failure( + sprintf('HTTP request failed with status %d.', $statusCode), + $statusCode, + $response, + ['transport' => $transport, 'curl' => $info], + ); + } + + return CommunicationResult::success($statusCode, $response, ['transport' => $transport, 'curl' => $info]); + } + + /** + * @param array $info + */ + private static function statusCode(array $info): ?int + { + $code = $info['http_code'] ?? null; + + if (!is_int($code) || $code < 1) { + return null; + } + + return $code; + } + + private static function writeDownloadBody(string $path, string $body): ?string + { + if (is_dir($path)) { + return sprintf('Download path points to a directory: %s', $path); + } + + $directory = dirname($path); + + if (!is_dir($directory)) { + return sprintf('Download directory does not exist: %s', $directory); + } + + if (!is_writable($directory)) { + return sprintf('Download directory is not writable: %s', $directory); + } + + if (file_put_contents($path, $body) === false) { + return sprintf('Failed to write download file: %s', $path); + } + + return null; + } +} diff --git a/src/Http/Internal/RequestSecurityGuard.php b/src/Http/Internal/RequestSecurityGuard.php new file mode 100644 index 0000000..661b5e6 --- /dev/null +++ b/src/Http/Internal/RequestSecurityGuard.php @@ -0,0 +1,161 @@ +buildUrl(); + $host = parse_url($url, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + return; + } + $host = self::normalizeHost($host); + + $allowHosts = self::normalizeHosts($request->metadata['security_allow_hosts'] ?? []); + if ($allowHosts !== [] && !in_array(strtolower($host), $allowHosts, true)) { + throw new InvalidArgumentException(sprintf('HTTP request host is not allowed: %s', $host)); + } + + $blockHosts = self::normalizeHosts($request->metadata['security_block_hosts'] ?? []); + if (in_array(strtolower($host), $blockHosts, true)) { + throw new InvalidArgumentException(sprintf('HTTP request host is blocked: %s', $host)); + } + + if (($request->metadata['security_block_private_networks'] ?? false) !== true) { + return; + } + + if (self::isPrivateOrLocalHost($host)) { + throw new InvalidArgumentException(sprintf('HTTP request host resolves to a private or reserved address: %s', $host)); + } + } + + private static function isPrivateOrLocalHost(string $host): bool + { + $normalized = strtolower(self::normalizeHost($host)); + if ($normalized === 'localhost') { + return true; + } + + if (filter_var($normalized, FILTER_VALIDATE_IP) !== false) { + return self::isPrivateOrReservedIp($normalized); + } + + $resolved = self::resolveHostAddresses($normalized); + + return array_any($resolved, static fn(string $ip): bool => self::isPrivateOrReservedIp($ip)); + } + + private static function isPrivateOrReservedIp(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return true; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + $long = ip2long($ip); + if ($long === false) { + return true; + } + + return + ($long >= ip2long('0.0.0.0') && $long <= ip2long('0.255.255.255')) + || ($long >= ip2long('127.0.0.0') && $long <= ip2long('127.255.255.255')) + || ($long >= ip2long('169.254.0.0') && $long <= ip2long('169.254.255.255')); + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + $normalized = strtolower($ip); + + return $normalized === '::' + || $normalized === '::1' + || str_starts_with($normalized, 'fc') + || str_starts_with($normalized, 'fd') + || str_starts_with($normalized, 'fe8') + || str_starts_with($normalized, 'fe9') + || str_starts_with($normalized, 'fea') + || str_starts_with($normalized, 'feb'); + } + + return true; + } + + private static function normalizeHost(string $host): string + { + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + return substr($host, 1, -1); + } + + return $host; + } + + /** + * @return list + */ + private static function normalizeHosts(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $hosts = []; + foreach ($value as $item) { + if (!is_string($item)) { + continue; + } + + $host = strtolower(self::normalizeHost(trim($item))); + if ($host !== '') { + $hosts[] = $host; + } + } + + return $hosts; + } + + /** + * @return list + */ + private static function resolveHostAddresses(string $host): array + { + $addresses = []; + + set_error_handler(static fn(): bool => true); + + try { + $records = dns_get_record($host, DNS_A + DNS_AAAA); + } finally { + restore_error_handler(); + } + + if (is_array($records)) { + foreach ($records as $record) { + $ip = $record['ip'] ?? $record['ipv6'] ?? null; + if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP) !== false) { + $addresses[] = $ip; + } + } + } + + if ($addresses !== []) { + return $addresses; + } + + $fallback = gethostbynamel($host); + if ($fallback === false) { + return []; + } + + return array_values(array_filter( + $fallback, + static fn(string $value): bool => filter_var($value, FILTER_VALIDATE_IP) !== false, + )); + } +} diff --git a/src/Http/Internal/ResponseBodyCollector.php b/src/Http/Internal/ResponseBodyCollector.php new file mode 100644 index 0000000..820f656 --- /dev/null +++ b/src/Http/Internal/ResponseBodyCollector.php @@ -0,0 +1,166 @@ +options->streamDownloadPath; + if ($path === null) { + return; + } + + $this->prepareStreamDownload($path); + } + + public function collect(string $chunk): int + { + if ($this->error !== null) { + return 0; + } + + $length = strlen($chunk); + $this->receivedBytes += $length; + + if ( + $this->request->options->maxResponseBytes !== null + && $this->request->options->streamDownloadPath === null + && $this->receivedBytes > $this->request->options->maxResponseBytes + ) { + $this->error = sprintf('HTTP response exceeded max allowed bytes (%d).', $this->request->options->maxResponseBytes); + + return 0; + } + + if ( + $this->request->options->maxDownloadBytes !== null + && $this->request->options->streamDownloadPath !== null + && $this->receivedBytes > $this->request->options->maxDownloadBytes + ) { + $this->error = sprintf('HTTP download exceeded max allowed bytes (%d).', $this->request->options->maxDownloadBytes); + + return 0; + } + + if ($this->stream !== null) { + $written = fwrite($this->stream, $chunk); + + if ($written === false || $written !== $length) { + $this->error = sprintf('Failed to write streamed download file: %s', (string) $this->targetPath); + + return 0; + } + + return $length; + } + + $this->body .= $chunk; + + return $length; + } + + public function error(): ?string + { + return $this->error; + } + + public function finalize(): ?string + { + if ($this->stream === null) { + return $this->error; + } + + fclose($this->stream); + $this->stream = null; + + if ($this->error !== null) { + $this->cleanupTempFile(); + + return $this->error; + } + + if ($this->targetPath === null || $this->tempPath === null) { + $this->error = 'Stream download destination was not initialized.'; + + return $this->error; + } + + if (!rename($this->tempPath, $this->targetPath)) { + $this->error = sprintf('Failed to finalize streamed download file: %s', $this->targetPath); + $this->cleanupTempFile(); + + return $this->error; + } + + $this->tempPath = null; + + return null; + } + + public function responseBody(): string + { + return $this->body; + } + + private function cleanupTempFile(): void + { + if ($this->tempPath !== null && is_file($this->tempPath)) { + unlink($this->tempPath); + } + + $this->tempPath = null; + } + + private function prepareStreamDownload(string $path): void + { + if (is_dir($path)) { + throw new InvalidArgumentException(sprintf('Stream download path points to a directory: %s', $path)); + } + + $directory = dirname($path); + if (!is_dir($directory)) { + throw new InvalidArgumentException(sprintf('Stream download directory does not exist: %s', $directory)); + } + + if (!is_writable($directory)) { + throw new InvalidArgumentException(sprintf('Stream download directory is not writable: %s', $directory)); + } + + $tempPath = tempnam($directory, 'tb-http-download-'); + if ($tempPath === false) { + throw new InvalidArgumentException(sprintf('Unable to allocate temporary stream download file in directory: %s', $directory)); + } + + $stream = fopen($tempPath, 'wb'); + if ($stream === false) { + if (is_file($tempPath)) { + unlink($tempPath); + } + + throw new InvalidArgumentException(sprintf('Unable to open stream download temp file for writing: %s', $tempPath)); + } + + $this->targetPath = $path; + $this->tempPath = $tempPath; + $this->stream = $stream; + } +} diff --git a/src/Http/Internal/ResponseHeaderCollector.php b/src/Http/Internal/ResponseHeaderCollector.php new file mode 100644 index 0000000..fe63c8c --- /dev/null +++ b/src/Http/Internal/ResponseHeaderCollector.php @@ -0,0 +1,71 @@ +> + */ + private array $activeHeaders = []; + + /** + * @var array> + */ + private array $headers = []; + + public function collect(string $line): int + { + $trimmed = trim($line); + + if ($trimmed === '') { + if ($this->activeHeaders !== []) { + $this->headers = $this->activeHeaders; + } + + return strlen($line); + } + + if (str_starts_with($trimmed, 'HTTP/')) { + $this->activeHeaders = []; + + return strlen($line); + } + + $position = strpos($line, ':'); + if ($position === false) { + return strlen($line); + } + + $name = trim(substr($line, 0, $position)); + $value = trim(substr($line, $position + 1)); + + if (!array_key_exists($name, $this->activeHeaders)) { + $this->activeHeaders[$name] = $value; + + return strlen($line); + } + + $existing = $this->activeHeaders[$name]; + if (is_array($existing)) { + $existing[] = $value; + $this->activeHeaders[$name] = $existing; + + return strlen($line); + } + + $this->activeHeaders[$name] = [$existing, $value]; + + return strlen($line); + } + + /** + * @return array> + */ + public function headers(): array + { + return $this->headers; + } +} diff --git a/src/Http/Internal/UploadHandleManager.php b/src/Http/Internal/UploadHandleManager.php new file mode 100644 index 0000000..ecae61c --- /dev/null +++ b/src/Http/Internal/UploadHandleManager.php @@ -0,0 +1,22 @@ +metadata['_upload_opened_by_configurator'] ?? false; + $resource = $request->metadata['_upload_handle'] ?? null; + + if ($openedByConfigurator !== true || !is_resource($resource)) { + return; + } + + fclose($resource); + } +} diff --git a/src/Http/QueryParams.php b/src/Http/QueryParams.php new file mode 100644 index 0000000..2e3a11b --- /dev/null +++ b/src/Http/QueryParams.php @@ -0,0 +1,121 @@ +|null> $params + */ + public function __construct(private array $params = []) + { + foreach ($params as $name => $value) { + $this->assertValidName($name); + $this->normalizeValue($value); + } + } + + /** + * @return array|null> + */ + public function all(): array + { + return $this->params; + } + + public function toQueryString(): string + { + $flattened = []; + foreach ($this->params as $key => $value) { + if ($value === null) { + continue; + } + + $flattened[$key] = $this->normalizeValue($value); + } + + return http_build_query($flattened, '', '&', PHP_QUERY_RFC3986); + } + + /** + * @param scalar|list|null $value + */ + public function with(string $name, mixed $value): self + { + $this->assertValidName($name); + $params = $this->params; + $params[$name] = $value === null ? null : $this->normalizeValue($value); + /** @var array|null> $params */ + + return new self($params); + } + + public function without(string $name): self + { + $params = $this->params; + unset($params[$name]); + + return new self($params); + } + + private function assertValidName(string $name): void + { + if (trim($name) === '') { + throw new InvalidArgumentException('Query parameter name must not be empty.'); + } + } + + /** + * @param array $values + * @return list + */ + private function normalizeList(array $values): array + { + $normalized = []; + foreach ($values as $item) { + if (is_bool($item)) { + $normalized[] = $item ? 1 : 0; + + continue; + } + + if (is_int($item) || is_float($item) || is_string($item)) { + $normalized[] = $item; + + continue; + } + + throw new InvalidArgumentException('Query list values must be scalar.'); + } + + return $normalized; + } + + /** + * @return int|float|string|list|null + */ + private function normalizeValue(mixed $value): int|float|string|array|null + { + if ($value === null) { + return null; + } + + if (is_bool($value)) { + return $value ? 1 : 0; + } + + if (is_int($value) || is_float($value) || is_string($value)) { + return $value; + } + + if (is_array($value)) { + return $this->normalizeList($value); + } + + throw new InvalidArgumentException('Query parameter values must be scalar, list, or null.'); + } +} diff --git a/src/Http/Retry/HttpRetryPolicy.php b/src/Http/Retry/HttpRetryPolicy.php new file mode 100644 index 0000000..01f7e9c --- /dev/null +++ b/src/Http/Retry/HttpRetryPolicy.php @@ -0,0 +1,144 @@ + + */ + private array $delaysByAttempt = []; + + /** + * @var array + */ + private array $retryStatuses; + + /** + * @param list $retryStatuses + */ + public function __construct( + private readonly int $maxAttempts = 3, + private readonly int $baseDelayMs = 250, + array $retryStatuses = [408, 425, 429, 500, 502, 503, 504], + private readonly ?int $maxRetryAfterSeconds = 30, + private readonly bool $retryOnTransportError = true, + ) { + if ($this->maxAttempts < 1) { + throw new InvalidArgumentException('maxAttempts must be at least 1.'); + } + + if ($this->baseDelayMs < 0) { + throw new InvalidArgumentException('baseDelayMs must be greater than or equal to 0.'); + } + + if ($this->maxRetryAfterSeconds !== null && $this->maxRetryAfterSeconds < 0) { + throw new InvalidArgumentException('maxRetryAfterSeconds must be greater than or equal to 0.'); + } + + $mapped = []; + foreach ($retryStatuses as $status) { + $mapped[$status] = true; + } + $this->retryStatuses = $mapped; + } + + public static function standard(int $attempts = 3, int $baseDelayMs = 250, int $maxRetryAfterSeconds = 30): self + { + return new self( + maxAttempts: $attempts, + baseDelayMs: $baseDelayMs, + retryStatuses: [408, 425, 429, 500, 502, 503, 504], + maxRetryAfterSeconds: $maxRetryAfterSeconds, + retryOnTransportError: true, + ); + } + + public function delayMs(int $attempt): int + { + return $this->delaysByAttempt[$attempt] ?? $this->exponentialDelayMs($attempt); + } + + public function shouldRetry(int $attempt, ?CommunicationResult $result = null, ?Throwable $error = null): bool + { + if ($attempt >= $this->maxAttempts) { + return false; + } + + if ($error !== null) { + if (!$this->retryOnTransportError) { + return false; + } + + $this->delaysByAttempt[$attempt] = $this->exponentialDelayMs($attempt); + + return true; + } + + if ($result === null) { + return false; + } + + if ($result->successful) { + return false; + } + + $status = $result->statusCode; + if ($status === null) { + if (!$this->retryOnTransportError) { + return false; + } + + $this->delaysByAttempt[$attempt] = $this->exponentialDelayMs($attempt); + + return true; + } + + if (!isset($this->retryStatuses[$status])) { + return false; + } + + $this->delaysByAttempt[$attempt] = $this->resolveRetryDelayMs($attempt, $result); + + return true; + } + + private function exponentialDelayMs(int $attempt): int + { + $power = max(0, $attempt - 1); + + return $this->baseDelayMs * (2 ** $power); + } + + private function resolveRetryDelayMs(int $attempt, CommunicationResult $result): int + { + $response = $result->response; + if (!$response instanceof HttpResponse) { + return $this->exponentialDelayMs($attempt); + } + + $retryAfter = $response->headerLine('Retry-After'); + if ($retryAfter === null || trim($retryAfter) === '') { + return $this->exponentialDelayMs($attempt); + } + + $seconds = RetryAfter::parseDelaySeconds($retryAfter); + if ($seconds === null) { + return $this->exponentialDelayMs($attempt); + } + + if ($this->maxRetryAfterSeconds !== null) { + $seconds = min($seconds, $this->maxRetryAfterSeconds); + } + + return $seconds * 1000; + } +} diff --git a/src/Http/Retry/RetryAfter.php b/src/Http/Retry/RetryAfter.php new file mode 100644 index 0000000..79178e5 --- /dev/null +++ b/src/Http/Retry/RetryAfter.php @@ -0,0 +1,34 @@ +getTimestamp() - $current->getTimestamp(); + + return max(0, $seconds); + } +} diff --git a/src/Http/Testing/AssertableHttpTransport.php b/src/Http/Testing/AssertableHttpTransport.php new file mode 100644 index 0000000..2434284 --- /dev/null +++ b/src/Http/Testing/AssertableHttpTransport.php @@ -0,0 +1,137 @@ +assertRequestedWhere( + static function (HttpRequest $request) use ($fragment): bool { + if ($request->body === null) { + return false; + } + + $payload = $request->body->toCurlPayload(); + + return is_string($payload) && str_contains($payload, $fragment); + }, + sprintf('Expected a request body containing "%s".', $fragment), + ); + } + + public function assertForm(): void + { + $this->assertRequestedWhere( + static fn(HttpRequest $request): bool => $request->body instanceof FormBody, + 'Expected at least one form request body.', + ); + } + + public function assertHeader(string $name, string $value): void + { + $this->assertRequestedWhere( + static fn(HttpRequest $request): bool => $request->headers->get($name) === $value, + sprintf('Expected a request with header "%s: %s".', $name, $value), + ); + } + + public function assertJson(): void + { + $this->assertRequestedWhere( + static fn(HttpRequest $request): bool => $request->body instanceof JsonBody, + 'Expected at least one JSON request body.', + ); + } + + public function assertMultipartFile(string $field): void + { + $this->assertRequestedWhere( + static fn(HttpRequest $request): bool => $request->body instanceof MultipartBody + && array_any( + array_keys($request->body->toCurlPayload()), + static fn(string $name): bool => $name === $field || str_starts_with($name, $field . '['), + ), + sprintf('Expected multipart request containing field "%s".', $field), + ); + } + + public function assertNothingRequested(): void + { + if ($this->fakeTransport->sentRequests() !== []) { + throw new RuntimeException('Expected no HTTP request to be sent.'); + } + } + + public function assertRequestCount(int $count): void + { + $actual = count($this->fakeTransport->sentRequests()); + + if ($actual !== $count) { + throw new RuntimeException(sprintf('Expected %d HTTP request(s), got %d.', $count, $actual)); + } + } + + public function assertRequested(HttpMethod|string $method, string $url): void + { + $expectedMethod = $method instanceof HttpMethod ? $method : HttpMethod::from(strtoupper($method)); + + $this->assertRequestedWhere( + static fn(HttpRequest $request): bool => $request->method === $expectedMethod && $request->buildUrl() === $url, + sprintf('Expected request "%s %s" to be sent.', $expectedMethod->value, $url), + ); + } + + public function assertRequestedWhere(callable $predicate, string $message = 'Expected an HTTP request matching predicate.'): void + { + foreach ($this->fakeTransport->sentRequests() as $request) { + if ($predicate($request) === true) { + return; + } + } + + throw new RuntimeException($message); + } + + public function firstRequest(): ?HttpRequest + { + $requests = $this->fakeTransport->sentRequests(); + + return $requests[0] ?? null; + } + + public function lastRequest(): ?HttpRequest + { + $requests = $this->fakeTransport->sentRequests(); + + return $requests === [] ? null : $requests[array_key_last($requests)]; + } + + public function requestBodyType(): ?string + { + $request = $this->lastRequest(); + if ($request === null || $request->body === null) { + return null; + } + + return match (true) { + $request->body instanceof JsonBody => 'json', + $request->body instanceof FormBody => 'form', + $request->body instanceof MultipartBody => 'multipart', + $request->body instanceof RawBody => 'raw', + default => 'unknown', + }; + } +} diff --git a/src/Http/Testing/FakeHttpTransport.php b/src/Http/Testing/FakeHttpTransport.php new file mode 100644 index 0000000..25fc8b1 --- /dev/null +++ b/src/Http/Testing/FakeHttpTransport.php @@ -0,0 +1,107 @@ + + */ + private array $queuedResults = []; + + /** + * @var list + */ + private array $sentRequests = []; + + public function push(CommunicationResult $result): self + { + $this->queuedResults[] = $result; + + return $this; + } + + /** + * @param array> $headers + * @param array $metadata + */ + public function pushJson(mixed $payload, int $statusCode = 200, array $headers = [], array $metadata = []): self + { + $encoded = json_encode($payload, JSON_THROW_ON_ERROR); + $responseHeaders = $headers; + if (!array_any(array_keys($responseHeaders), static fn(string $name): bool => strcasecmp($name, 'Content-Type') === 0)) { + $responseHeaders['Content-Type'] = 'application/json'; + } + + $response = new HttpResponse( + statusCode: $statusCode, + body: $encoded, + headers: $responseHeaders, + metadata: $metadata, + ); + + $result = $statusCode >= 400 + ? CommunicationResult::failure( + error: sprintf('HTTP request failed with status code %d.', $statusCode), + statusCode: $statusCode, + response: $response, + metadata: $metadata, + ) + : CommunicationResult::success( + statusCode: $statusCode, + response: $response, + metadata: $metadata, + ); + + return $this->push($result); + } + + public function send(CommunicationRequest $request): CommunicationResult + { + if (!$request->payload instanceof HttpRequest) { + return CommunicationResult::failure('FakeHttpTransport expects HttpRequest payload.'); + } + + $this->sentRequests[] = $request->payload; + + if ($this->queuedResults === []) { + return CommunicationResult::success( + response: new HttpResponse( + statusCode: 200, + body: '', + headers: [], + metadata: ['transport' => 'fake-http'], + ), + statusCode: 200, + metadata: ['transport' => 'fake-http'], + ); + } + + /** @var CommunicationResult $result */ + $result = array_shift($this->queuedResults); + + return $result; + } + + /** + * @return list + */ + public function sentRequests(): array + { + return $this->sentRequests; + } + + public function wasRequested(Closure $predicate): bool + { + return array_any($this->sentRequests, static fn(HttpRequest $request): bool => $predicate($request) === true); + } +} diff --git a/src/Http/Testing/SequenceHttpTransport.php b/src/Http/Testing/SequenceHttpTransport.php new file mode 100644 index 0000000..779e346 --- /dev/null +++ b/src/Http/Testing/SequenceHttpTransport.php @@ -0,0 +1,55 @@ + + */ + private array $sentRequests = []; + + /** + * @param list $sequence + */ + public function __construct(private array $sequence = []) {} + + public function push(CommunicationResult $result): self + { + $this->sequence[] = $result; + + return $this; + } + + public function send(CommunicationRequest $request): CommunicationResult + { + if (!$request->payload instanceof HttpRequest) { + return CommunicationResult::failure('SequenceHttpTransport expects HttpRequest payload.'); + } + + $this->sentRequests[] = $request->payload; + + $next = array_shift($this->sequence); + if ($next === null) { + throw new RuntimeException('SequenceHttpTransport has no more queued responses.'); + } + + return $next; + } + + /** + * @return list + */ + public function sentRequests(): array + { + return $this->sentRequests; + } +} diff --git a/src/Http/Testing/SpyHttpTransport.php b/src/Http/Testing/SpyHttpTransport.php new file mode 100644 index 0000000..a93bce4 --- /dev/null +++ b/src/Http/Testing/SpyHttpTransport.php @@ -0,0 +1,37 @@ + + */ + private array $sentRequests = []; + + public function __construct(private readonly TransportInterface $inner) {} + + public function send(CommunicationRequest $request): CommunicationResult + { + if ($request->payload instanceof HttpRequest) { + $this->sentRequests[] = $request->payload; + } + + return $this->inner->send($request); + } + + /** + * @return list + */ + public function sentRequests(): array + { + return $this->sentRequests; + } +} diff --git a/src/Resilience/CircuitBreaker.php b/src/Resilience/CircuitBreaker.php new file mode 100644 index 0000000..cef2840 --- /dev/null +++ b/src/Resilience/CircuitBreaker.php @@ -0,0 +1,59 @@ +failureThreshold < 1) { + throw new InvalidArgumentException('failureThreshold must be at least 1.'); + } + + if ($this->coolDownSeconds < 1) { + throw new InvalidArgumentException('coolDownSeconds must be at least 1.'); + } + } + + public function assertCanProceed(): void + { + if ($this->openedAt === null) { + return; + } + + if ((time() - $this->openedAt) >= $this->coolDownSeconds) { + $this->openedAt = null; + $this->failureCount = 0; + + return; + } + + throw new RuntimeException('Circuit breaker is open.'); + } + + public function onFailure(): void + { + $this->failureCount++; + + if ($this->failureCount >= $this->failureThreshold) { + $this->openedAt = time(); + } + } + + public function onSuccess(): void + { + $this->failureCount = 0; + $this->openedAt = null; + } +} diff --git a/src/Resilience/RateLimiter.php b/src/Resilience/RateLimiter.php new file mode 100644 index 0000000..d63bbe4 --- /dev/null +++ b/src/Resilience/RateLimiter.php @@ -0,0 +1,43 @@ + */ + private array $timestamps = []; + + public function __construct( + private readonly int $maxRequests, + private readonly int $perSeconds, + ) { + if ($this->maxRequests < 1) { + throw new InvalidArgumentException('maxRequests must be at least 1.'); + } + + if ($this->perSeconds < 1) { + throw new InvalidArgumentException('perSeconds must be at least 1.'); + } + } + + public function assertCanProceed(): void + { + $now = microtime(true); + $windowStart = $now - $this->perSeconds; + + $this->timestamps = array_values( + array_filter($this->timestamps, static fn(float $timestamp): bool => $timestamp >= $windowStart), + ); + + if (count($this->timestamps) >= $this->maxRequests) { + throw new RuntimeException('Rate limit exceeded.'); + } + + $this->timestamps[] = $now; + } +} diff --git a/src/Retry/ExponentialBackoffRetryPolicy.php b/src/Retry/ExponentialBackoffRetryPolicy.php new file mode 100644 index 0000000..dc35f1b --- /dev/null +++ b/src/Retry/ExponentialBackoffRetryPolicy.php @@ -0,0 +1,45 @@ +maxAttempts < 1) { + throw new InvalidArgumentException('maxAttempts must be at least 1.'); + } + + if ($this->baseDelayMs < 0) { + throw new InvalidArgumentException('baseDelayMs must be greater than or equal to 0.'); + } + } + + public function delayMs(int $attempt): int + { + $power = max(0, $attempt - 1); + + return $this->baseDelayMs * (2 ** $power); + } + + public function shouldRetry(int $attempt, ?CommunicationResult $result = null, ?Throwable $error = null): bool + { + if ($attempt >= $this->maxAttempts) { + return false; + } + + if ($error !== null) { + return true; + } + + return $result !== null && !$result->successful; + } +} diff --git a/src/Retry/FixedDelayRetryPolicy.php b/src/Retry/FixedDelayRetryPolicy.php new file mode 100644 index 0000000..3fbf5f1 --- /dev/null +++ b/src/Retry/FixedDelayRetryPolicy.php @@ -0,0 +1,45 @@ +maxAttempts < 1) { + throw new InvalidArgumentException('maxAttempts must be at least 1.'); + } + + if ($this->delayMsValue < 0) { + throw new InvalidArgumentException('delayMsValue must be greater than or equal to 0.'); + } + } + + public function delayMs(int $attempt): int + { + unset($attempt); + + return $this->delayMsValue; + } + + public function shouldRetry(int $attempt, ?CommunicationResult $result = null, ?Throwable $error = null): bool + { + if ($attempt >= $this->maxAttempts) { + return false; + } + + if ($error !== null) { + return true; + } + + return $result !== null && !$result->successful; + } +} diff --git a/src/Retry/JitterBackoffRetryPolicy.php b/src/Retry/JitterBackoffRetryPolicy.php new file mode 100644 index 0000000..43ee61b --- /dev/null +++ b/src/Retry/JitterBackoffRetryPolicy.php @@ -0,0 +1,45 @@ +maxAttempts < 1) { + throw new InvalidArgumentException('maxAttempts must be at least 1.'); + } + + if ($this->baseDelayMs < 1) { + throw new InvalidArgumentException('baseDelayMs must be at least 1 for jitter strategy.'); + } + } + + public function delayMs(int $attempt): int + { + $exponentialDelay = $this->baseDelayMs * (2 ** max(0, $attempt - 1)); + + return random_int((int) floor($exponentialDelay / 2), $exponentialDelay); + } + + public function shouldRetry(int $attempt, ?CommunicationResult $result = null, ?Throwable $error = null): bool + { + if ($attempt >= $this->maxAttempts) { + return false; + } + + if ($error !== null) { + return true; + } + + return $result !== null && !$result->successful; + } +} diff --git a/src/Retry/NoRetryPolicy.php b/src/Retry/NoRetryPolicy.php new file mode 100644 index 0000000..400587b --- /dev/null +++ b/src/Retry/NoRetryPolicy.php @@ -0,0 +1,21 @@ +secret); + } +} diff --git a/src/Signing/RequestSignerInterface.php b/src/Signing/RequestSignerInterface.php new file mode 100644 index 0000000..6d15d25 --- /dev/null +++ b/src/Signing/RequestSignerInterface.php @@ -0,0 +1,10 @@ +signer->sign($payload), $signature); + } +} diff --git a/src/Testing/AssertableTransport.php b/src/Testing/AssertableTransport.php new file mode 100644 index 0000000..2462a24 --- /dev/null +++ b/src/Testing/AssertableTransport.php @@ -0,0 +1,21 @@ +spyTransport->requests()); + + if ($count !== $expected) { + throw new LogicException(sprintf('Expected %d sent requests, got %d.', $expected, $count)); + } + } +} diff --git a/src/Testing/FakeTransport.php b/src/Testing/FakeTransport.php new file mode 100644 index 0000000..ec78ed1 --- /dev/null +++ b/src/Testing/FakeTransport.php @@ -0,0 +1,56 @@ + */ + private array $requests = []; + + public function __construct(?SequenceTransport $sequence = null) + { + $this->sequence = $sequence ?? new SequenceTransport(); + } + + public static function sequence(): self + { + return new self(new SequenceTransport()); + } + + public function pushFailure(string $error, ?int $statusCode = null, mixed $response = null): self + { + $this->sequence->push(CommunicationResult::failure($error, $statusCode, $response)); + + return $this; + } + + public function pushSuccess(?int $statusCode = null, mixed $response = null): self + { + $this->sequence->push(CommunicationResult::success($statusCode, $response)); + + return $this; + } + + /** + * @return list + */ + public function requests(): array + { + return $this->requests; + } + + public function send(CommunicationRequest $request): CommunicationResult + { + $this->requests[] = $request; + + return $this->sequence->send($request); + } +} diff --git a/src/Testing/NullTransport.php b/src/Testing/NullTransport.php new file mode 100644 index 0000000..fe5f0e6 --- /dev/null +++ b/src/Testing/NullTransport.php @@ -0,0 +1,19 @@ + 'null']); + } +} diff --git a/src/Testing/SequenceTransport.php b/src/Testing/SequenceTransport.php new file mode 100644 index 0000000..018c8de --- /dev/null +++ b/src/Testing/SequenceTransport.php @@ -0,0 +1,38 @@ + $sequence + */ + public function __construct(private array $sequence = []) {} + + public function push(CommunicationResult $result): self + { + $this->sequence[] = $result; + + return $this; + } + + public function send(CommunicationRequest $request): CommunicationResult + { + unset($request); + + $next = array_shift($this->sequence); + + if ($next === null) { + throw new RuntimeException('SequenceTransport has no more queued responses.'); + } + + return $next; + } +} diff --git a/src/Testing/SpyTransport.php b/src/Testing/SpyTransport.php new file mode 100644 index 0000000..8c28808 --- /dev/null +++ b/src/Testing/SpyTransport.php @@ -0,0 +1,32 @@ + */ + private array $requests = []; + + public function __construct(private readonly TransportInterface $inner) {} + + /** + * @return list + */ + public function requests(): array + { + return $this->requests; + } + + public function send(CommunicationRequest $request): CommunicationResult + { + $this->requests[] = $request; + + return $this->inner->send($request); + } +} diff --git a/src/Webhook/InMemoryWebhookReplayStore.php b/src/Webhook/InMemoryWebhookReplayStore.php new file mode 100644 index 0000000..7d586da --- /dev/null +++ b/src/Webhook/InMemoryWebhookReplayStore.php @@ -0,0 +1,36 @@ + + */ + private array $entries = []; + + public function remember(string $deliveryId, int $ttlSeconds): void + { + $this->entries[$deliveryId] = time() + max(1, $ttlSeconds); + } + + public function seen(string $deliveryId): bool + { + $this->purgeExpired(); + + return array_key_exists($deliveryId, $this->entries); + } + + private function purgeExpired(): void + { + $now = time(); + + foreach ($this->entries as $deliveryId => $expiresAt) { + if ($expiresAt <= $now) { + unset($this->entries[$deliveryId]); + } + } + } +} diff --git a/src/Webhook/Signing/HmacWebhookSigner.php b/src/Webhook/Signing/HmacWebhookSigner.php new file mode 100644 index 0000000..03a2fad --- /dev/null +++ b/src/Webhook/Signing/HmacWebhookSigner.php @@ -0,0 +1,23 @@ +parseOptionalTimestamp($timestampHeader); + if ($timestampHeader !== null && $timestampHeader !== '' && $timestamp === null) { + return null; + } + + $signature = null; + + $segments = explode(',', $signatureHeader); + foreach ($segments as $segment) { + [$timestamp, $signature] = $this->parseSegment($segment, $timestamp, $signature); + } + + if ($timestamp === null || $signature === null) { + return null; + } + + return ['timestamp' => $timestamp, 'signature' => $signature]; + } + + private function parseOptionalTimestamp(?string $timestampHeader): ?int + { + if ($timestampHeader === null || $timestampHeader === '') { + return null; + } + + $trimmedTimestamp = trim($timestampHeader); + if (!ctype_digit($trimmedTimestamp)) { + return null; + } + + return (int) $trimmedTimestamp; + } + + /** + * @return array{0:?int,1:?string} + */ + private function parseSegment(string $segment, ?int $timestamp, ?string $signature): array + { + $parts = explode('=', trim($segment), 2); + if (count($parts) !== 2) { + return [$timestamp, $signature]; + } + + if ($parts[0] === 't' && $timestamp === null) { + $candidate = trim($parts[1]); + if (ctype_digit($candidate)) { + return [(int) $candidate, $signature]; + } + + return [$timestamp, $signature]; + } + + if ($parts[0] !== 'v1') { + return [$timestamp, $signature]; + } + + $candidate = strtolower(trim($parts[1])); + if (preg_match('/^[a-f0-9]{64}$/', $candidate) === 1) { + return [$timestamp, $candidate]; + } + + return [$timestamp, $signature]; + } +} diff --git a/src/Webhook/Signing/WebhookSigner.php b/src/Webhook/Signing/WebhookSigner.php new file mode 100644 index 0000000..2e739b5 --- /dev/null +++ b/src/Webhook/Signing/WebhookSigner.php @@ -0,0 +1,10 @@ +assertSent( + static fn(WebhookMessage $message): bool => $message->event === $event, + sprintf('Expected webhook event "%s" to be sent.', $event), + ); + } + + public function assertHeader(string $name, string $value): void + { + $this->assertSent( + static fn(WebhookMessage $message): bool => ($message->headers[$name] ?? null) === $value, + sprintf('Expected webhook header "%s: %s" to be sent.', $name, $value), + ); + } + + public function assertNothingSent(): void + { + if ($this->fakeSender->sentMessages() !== []) { + throw new RuntimeException('Expected no webhook message to be sent.'); + } + } + + /** + * @param array|string $expected + */ + public function assertPayload(array|string $expected): void + { + $this->assertSent( + static fn(WebhookMessage $message): bool => $message->payload === $expected, + 'Expected webhook payload to match expected value.', + ); + } + + public function assertPayloadWhere(callable $predicate): void + { + $this->assertSent( + static fn(WebhookMessage $message): bool => $predicate($message->payload) === true, + 'Expected webhook payload to satisfy predicate.', + ); + } + + public function assertSent(?callable $predicate = null, string $message = 'Expected webhook message to be sent.'): void + { + foreach ($this->fakeSender->sentMessages() as $sent) { + if ($predicate === null || $predicate($sent) === true) { + return; + } + } + + throw new RuntimeException($message); + } + + public function assertSentCount(int $count): void + { + $actual = count($this->fakeSender->sentMessages()); + if ($actual !== $count) { + throw new RuntimeException(sprintf('Expected %d webhook message(s), got %d.', $count, $actual)); + } + } + + public function assertSentTo(string $url): void + { + $this->assertSent( + static fn(WebhookMessage $message): bool => $message->url === $url, + sprintf('Expected webhook to be sent to "%s".', $url), + ); + } + + public function first(): ?WebhookMessage + { + $messages = $this->fakeSender->sentMessages(); + + return $messages[0] ?? null; + } + + public function last(): ?WebhookMessage + { + $messages = $this->fakeSender->sentMessages(); + if ($messages === []) { + return null; + } + + return $messages[array_key_last($messages)]; + } +} diff --git a/src/Webhook/Testing/FakeWebhookSender.php b/src/Webhook/Testing/FakeWebhookSender.php new file mode 100644 index 0000000..04645a9 --- /dev/null +++ b/src/Webhook/Testing/FakeWebhookSender.php @@ -0,0 +1,49 @@ + + */ + private array $sent = []; + + public function assert(): AssertableWebhookSender + { + return new AssertableWebhookSender($this); + } + + public function send(WebhookMessage $message): WebhookDelivery + { + $this->sent[] = $message; + + return new WebhookDelivery( + message: $message, + result: CommunicationResult::success(statusCode: 200), + delivery: new WebhookDeliveryResult( + deliveryId: $message->deliveryId, + event: $message->event, + url: $message->url ?? '', + attempts: 1, + delivered: true, + statusCode: 200, + ), + ); + } + + /** + * @return list + */ + public function sentMessages(): array + { + return $this->sent; + } +} diff --git a/src/Webhook/Testing/WebhookTestFactory.php b/src/Webhook/Testing/WebhookTestFactory.php new file mode 100644 index 0000000..753ecba --- /dev/null +++ b/src/Webhook/Testing/WebhookTestFactory.php @@ -0,0 +1,42 @@ + $payload + * @return array{0:string,1:array} + */ + public static function signedJson( + string $secret, + string $event, + array $payload, + ?string $deliveryId = null, + ?int $timestamp = null, + ): array { + WebhookNameGuard::assertEvent($event); + $rawPayload = json_encode($payload, JSON_THROW_ON_ERROR); + $issuedAt = $timestamp ?? time(); + $delivery = $deliveryId ?? bin2hex(random_bytes(16)); + WebhookNameGuard::assertDeliveryId($delivery); + $signature = new WebhookSignature($secret)->buildHeader($rawPayload, $issuedAt); + + return [ + $rawPayload, + [ + WebhookHeaders::EVENT => $event, + WebhookHeaders::DELIVERY => $delivery, + WebhookHeaders::TIMESTAMP => (string) $issuedAt, + WebhookHeaders::SIGNATURE => $signature, + WebhookHeaders::CONTENT_TYPE => 'application/json', + ], + ]; + } +} diff --git a/src/Webhook/Webhook.php b/src/Webhook/Webhook.php new file mode 100644 index 0000000..b8e1274 --- /dev/null +++ b/src/Webhook/Webhook.php @@ -0,0 +1,31 @@ + $metadata + */ + public function __construct( + public string $deliveryId, + public string $event, + public string $url, + public int $attempts, + public bool $delivered, + public ?int $statusCode = null, + public ?string $error = null, + public array $metadata = [], + ) {} +} diff --git a/src/Webhook/WebhookEvent.php b/src/Webhook/WebhookEvent.php new file mode 100644 index 0000000..5f362cc --- /dev/null +++ b/src/Webhook/WebhookEvent.php @@ -0,0 +1,22 @@ + $payload + * @param array $headers + * @param array $metadata + */ + public function __construct( + public string $event, + public string $deliveryId, + public array $payload, + public int $timestamp, + public array $headers = [], + public array $metadata = [], + ) {} +} diff --git a/src/Webhook/WebhookHeaders.php b/src/Webhook/WebhookHeaders.php new file mode 100644 index 0000000..b7c9c40 --- /dev/null +++ b/src/Webhook/WebhookHeaders.php @@ -0,0 +1,45 @@ + strtolower((string) $reserved) === $normalized); + } + + /** + * @return list + */ + public static function reserved(): array + { + return [ + self::EVENT, + self::DELIVERY, + self::TIMESTAMP, + self::SIGNATURE, + self::ATTEMPT, + self::CONTENT_TYPE, + self::USER_AGENT, + ]; + } +} diff --git a/src/Webhook/WebhookMessage.php b/src/Webhook/WebhookMessage.php new file mode 100644 index 0000000..2794dd8 --- /dev/null +++ b/src/Webhook/WebhookMessage.php @@ -0,0 +1,184 @@ +|string $payload + * @param array $headers + * @param array $metadata + */ + private function __construct( + public string $event, + public ?string $url, + public array|string $payload, + public array $headers, + public string $deliveryId, + public array $metadata = [], + ) {} + + public static function event(string $event): self + { + WebhookNameGuard::assertEvent($event); + + return new self( + event: $event, + url: null, + payload: [], + headers: [], + deliveryId: bin2hex(random_bytes(16)), + metadata: [], + ); + } + + public static function new(string $event): self + { + return self::event($event); + } + + public function deliveryId(string $deliveryId): self + { + WebhookNameGuard::assertDeliveryId($deliveryId); + + return new self($this->event, $this->url, $this->payload, $this->headers, $deliveryId, $this->metadata); + } + + public function header(string $name, string $value): self + { + HeaderBag::assertValidHeaderName($name); + HeaderBag::assertValidHeaderValue($value); + self::assertAllowedHeaderName($name); + + $headers = $this->headers; + $headers[$name] = $value; + + return new self($this->event, $this->url, $this->payload, $headers, $this->deliveryId, $this->metadata); + } + + /** + * @param array $headers + */ + public function headers(array $headers): self + { + $validated = self::validateHeaders($headers); + + /** @var array $merged */ + $merged = array_merge($this->headers, $validated); + + return new self( + $this->event, + $this->url, + $this->payload, + $merged, + $this->deliveryId, + $this->metadata, + ); + } + + /** + * @param array $metadata + */ + public function metadata(array $metadata): self + { + return new self($this->event, $this->url, $this->payload, $this->headers, $this->deliveryId, $metadata); + } + + /** + * @param array|JsonSerializable $payload + */ + public function payload(array|JsonSerializable $payload): self + { + if ($payload instanceof JsonSerializable) { + return $this->withPayloadObject($payload); + } + + return new self($this->event, $this->url, $payload, $this->headers, $this->deliveryId, $this->metadata); + } + + public function rawJsonPayload(string $payload): self + { + return $this->rawPayload($payload); + } + + public function rawPayload(string $payload): self + { + json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + + return new self($this->event, $this->url, $payload, $this->headers, $this->deliveryId, $this->metadata); + } + + public function tag(string $key, mixed $value): self + { + WebhookNameGuard::assertMetadataKey($key); + + $metadata = $this->metadata; + $metadata[$key] = $value; + + return new self($this->event, $this->url, $this->payload, $this->headers, $this->deliveryId, $metadata); + } + + public function url(string $url): self + { + return new self($this->event, $url, $this->payload, $this->headers, $this->deliveryId, $this->metadata); + } + + /** + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $validated = self::validateHeaders($headers); + + return new self($this->event, $this->url, $this->payload, $validated, $this->deliveryId, $this->metadata); + } + + public function withPayloadObject(JsonSerializable $payload): self + { + $normalized = $payload->jsonSerialize(); + if (is_array($normalized)) { + /** @var array $asArray */ + $asArray = []; + foreach ($normalized as $key => $value) { + $asArray[(string) $key] = $value; + } + + $normalized = $asArray; + } elseif (!is_string($normalized)) { + $normalized = json_encode($normalized, JSON_THROW_ON_ERROR); + } + + return new self($this->event, $this->url, $normalized, $this->headers, $this->deliveryId, $this->metadata); + } + + private static function assertAllowedHeaderName(string $name): void + { + if (WebhookHeaders::isReserved($name)) { + throw new InvalidArgumentException(sprintf('Webhook header "%s" is reserved and cannot be overridden.', $name)); + } + } + + /** + * @param array $headers + * @return array + */ + private static function validateHeaders(array $headers): array + { + $validated = []; + foreach ($headers as $name => $value) { + $name = (string) $name; + HeaderBag::assertValidHeaderName($name); + HeaderBag::assertValidHeaderValue($value); + self::assertAllowedHeaderName($name); + $validated[$name] = $value; + } + + return $validated; + } +} diff --git a/src/Webhook/WebhookNameGuard.php b/src/Webhook/WebhookNameGuard.php new file mode 100644 index 0000000..14a81e2 --- /dev/null +++ b/src/Webhook/WebhookNameGuard.php @@ -0,0 +1,41 @@ + 255) { + throw new InvalidArgumentException(sprintf('%s must not exceed 255 characters.', $label)); + } + + if (str_contains($trimmed, "\r") || str_contains($trimmed, "\n") || str_contains($trimmed, "\0")) { + throw new InvalidArgumentException(sprintf('%s must not contain control characters.', $label)); + } + } +} diff --git a/src/Webhook/WebhookReceiver.php b/src/Webhook/WebhookReceiver.php new file mode 100644 index 0000000..9bca126 --- /dev/null +++ b/src/Webhook/WebhookReceiver.php @@ -0,0 +1,144 @@ + $headers + */ + public function receive(string $rawBody, array $headers): WebhookEvent + { + $signatureHeader = $this->header($headers, WebhookHeaders::SIGNATURE) ?? ''; + $timestampHeader = $this->header($headers, WebhookHeaders::TIMESTAMP); + + $verification = $this->verifier->verifyResult($rawBody, $signatureHeader, $timestampHeader); + if (!$verification->valid) { + throw new RuntimeException(sprintf('Webhook verification failed: %s', (string) $verification->reason)); + } + + try { + $decoded = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidArgumentException('Webhook payload must be valid JSON.', previous: $exception); + } + + if (!is_array($decoded)) { + throw new InvalidArgumentException('Webhook payload must decode to an object/array JSON value.'); + } + + $event = $this->header($headers, WebhookHeaders::EVENT); + $deliveryId = $this->header($headers, WebhookHeaders::DELIVERY); + if ($event === null) { + throw new InvalidArgumentException('Webhook event header is missing.'); + } + + if ($deliveryId === null) { + throw new InvalidArgumentException('Webhook delivery header is missing.'); + } + + WebhookNameGuard::assertEvent($event); + WebhookNameGuard::assertDeliveryId($deliveryId); + + if ($this->replayStore !== null) { + if ($this->replayStore->seen($deliveryId)) { + throw new RuntimeException(sprintf('Webhook delivery "%s" has already been processed.', $deliveryId)); + } + + $this->replayStore->remember($deliveryId, $this->replayTtlSeconds); + } + + $timestamp = $verification->timestamp ?? time(); + $normalizedPayload = $this->normalizePayload($decoded); + $received = new WebhookEvent( + event: $event, + deliveryId: $deliveryId, + payload: $normalizedPayload, + timestamp: $timestamp, + headers: $this->normalizeHeaders($headers), + metadata: [ + 'verification' => $verification->metadata, + ], + ); + + CommunicationEventBus::dispatch('webhook.received', [ + 'event' => $received->event, + 'delivery_id' => $received->deliveryId, + 'timestamp' => $received->timestamp, + ]); + + return $received; + } + + public function withReplayStore(WebhookReplayStore $store, int $ttlSeconds = 86400): self + { + if ($ttlSeconds < 1) { + throw new InvalidArgumentException('Webhook replay TTL must be greater than zero.'); + } + + return new self($this->verifier, $store, $ttlSeconds); + } + + /** + * @param array $headers + */ + private function header(array $headers, string $name): ?string + { + foreach ($headers as $key => $value) { + if (!is_string($key) || strcasecmp($key, $name) !== 0) { + continue; + } + + if (is_string($value)) { + return $value; + } + } + + return null; + } + + /** + * @param array $headers + * @return array + */ + private function normalizeHeaders(array $headers): array + { + $normalized = []; + foreach ($headers as $key => $value) { + if (!is_string($key) || !is_string($value)) { + continue; + } + + $normalized[$key] = $value; + } + + return $normalized; + } + + /** + * @param array $payload + * @return array + */ + private function normalizePayload(array $payload): array + { + $normalized = []; + foreach ($payload as $key => $value) { + $normalized[(string) $key] = $value; + } + + return $normalized; + } +} diff --git a/src/Webhook/WebhookReplayStore.php b/src/Webhook/WebhookReplayStore.php new file mode 100644 index 0000000..aa282f1 --- /dev/null +++ b/src/Webhook/WebhookReplayStore.php @@ -0,0 +1,12 @@ + $retryStatuses + */ + public function __construct( + public int $attempts = 3, + public int $baseDelayMs = 250, + public array $retryStatuses = [408, 429, 500, 502, 503, 504], + public ?int $maxRetryAfterSeconds = 30, + public bool $retryOnTransportError = true, + ) { + if ($this->attempts < 1) { + throw new InvalidArgumentException('Webhook retry attempts must be at least 1.'); + } + + if ($this->baseDelayMs < 0) { + throw new InvalidArgumentException('Webhook retry baseDelayMs must be greater than or equal to 0.'); + } + } + + public static function standard(int $attempts = 3, int $baseDelayMs = 250, int $maxRetryAfterSeconds = 30): self + { + return new self( + attempts: $attempts, + baseDelayMs: $baseDelayMs, + retryStatuses: [408, 429, 500, 502, 503, 504], + maxRetryAfterSeconds: $maxRetryAfterSeconds, + retryOnTransportError: true, + ); + } + + public function toHttpRetryPolicy(): HttpRetryPolicy + { + return new HttpRetryPolicy( + maxAttempts: $this->attempts, + baseDelayMs: $this->baseDelayMs, + retryStatuses: $this->retryStatuses, + maxRetryAfterSeconds: $this->maxRetryAfterSeconds, + retryOnTransportError: $this->retryOnTransportError, + ); + } +} diff --git a/src/Webhook/WebhookSender.php b/src/Webhook/WebhookSender.php new file mode 100644 index 0000000..5d85b24 --- /dev/null +++ b/src/Webhook/WebhookSender.php @@ -0,0 +1,169 @@ +url === null || $webhook->url === '') { + throw new LogicException('Webhook URL is required before sending.'); + } + + $timestamp = time(); + $payload = is_string($webhook->payload) + ? $webhook->payload + : json_encode($webhook->payload, JSON_THROW_ON_ERROR); + + $redactedUrl = HttpRedactor::redactUrl($webhook->url); + CommunicationEventBus::dispatch('webhook.send.start', [ + 'event' => $webhook->event, + 'delivery_id' => $webhook->deliveryId, + 'url' => $redactedUrl, + 'attempt' => 1, + ]); + $startedAt = microtime(true); + $attempt = 1; + $retryPolicy = $this->retryProfile?->toHttpRetryPolicy(); + + while (true) { + $request = HttpRequest::post($webhook->url) + ->raw($payload, 'application/json') + ->headers($webhook->headers) + ->header(WebhookHeaders::EVENT, $webhook->event) + ->header(WebhookHeaders::DELIVERY, $webhook->deliveryId) + ->header(WebhookHeaders::TIMESTAMP, (string) $timestamp) + ->header(WebhookHeaders::ATTEMPT, (string) $attempt) + ->header(WebhookHeaders::USER_AGENT, 'TalkingBytes/1.0') + ->header(WebhookHeaders::CONTENT_TYPE, 'application/json'); + + if ($this->signingSecret !== null) { + $signature = $this->signature($payload, $timestamp); + $request = $request->header(WebhookHeaders::SIGNATURE, sprintf('t=%d,v1=%s', $timestamp, $signature)); + } + + $result = $this->httpClient->send($request); + + if ($retryPolicy === null || !$retryPolicy->shouldRetry($attempt, $result)) { + break; + } + + CommunicationEventBus::dispatch('webhook.retry', [ + 'event' => $webhook->event, + 'delivery_id' => $webhook->deliveryId, + 'url' => $redactedUrl, + 'attempt' => $attempt + 1, + 'status_code' => $result->statusCode, + 'error' => $result->error, + ]); + + $delayMs = $retryPolicy->delayMs($attempt); + if ($delayMs > 0) { + usleep($delayMs * 1000); + } + + $attempt++; + } + $delivery = new WebhookDeliveryResult( + deliveryId: $webhook->deliveryId, + event: $webhook->event, + url: $webhook->url, + attempts: $attempt, + delivered: $result->successful, + statusCode: $result->statusCode, + error: $result->error, + metadata: [ + 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'has_signature' => $this->signingSecret !== null, + ], + ); + + CommunicationEventBus::dispatch( + $result->successful ? 'webhook.send.finish' : 'webhook.send.failed', + [ + 'event' => $webhook->event, + 'delivery_id' => $webhook->deliveryId, + 'url' => $redactedUrl, + 'status_code' => $result->statusCode, + 'error' => $result->error, + 'duration_ms' => $delivery->metadata['duration_ms'] ?? null, + 'attempt' => $attempt, + ], + ); + + return new WebhookDelivery($webhook, $result, $delivery); + } + + public function signingSecret(string $secret): self + { + if ($secret === '') { + throw new InvalidArgumentException('Webhook signing secret must not be empty.'); + } + + return new self($this->httpClient, $secret, $this->signer, $this->retryProfile); + } + + public function withRetryProfile( + int $attempts = 3, + int $baseDelayMs = 250, + int $maxRetryAfterSeconds = 30, + ): self { + $profile = WebhookRetryProfile::standard($attempts, $baseDelayMs, $maxRetryAfterSeconds); + + return new self($this->httpClient, $this->signingSecret, $this->signer, $profile); + } + + public function withSecret(string $secret): self + { + return $this->signingSecret($secret); + } + + public function withSigner(WebhookSigner $signer): self + { + return new self($this->httpClient, $this->signingSecret, $signer, $this->retryProfile); + } + + private function signature(string $payload, int $timestamp): string + { + if ($this->signingSecret === null) { + throw new LogicException('Webhook signing secret is not configured.'); + } + + return ($this->signer ?? new HmacWebhookSigner()) + ->sign($payload, $timestamp, $this->signingSecret); + } +} diff --git a/src/Webhook/WebhookSignature.php b/src/Webhook/WebhookSignature.php new file mode 100644 index 0000000..4f69841 --- /dev/null +++ b/src/Webhook/WebhookSignature.php @@ -0,0 +1,25 @@ +secret === '') { + throw new InvalidArgumentException('Webhook signing secret must not be empty.'); + } + } + + public function buildHeader(string $payload, int $timestamp): string + { + $signature = new HmacWebhookSigner()->sign($payload, $timestamp, $this->secret); + + return sprintf('t=%d,v1=%s', $timestamp, $signature); + } +} diff --git a/src/Webhook/WebhookVerificationResult.php b/src/Webhook/WebhookVerificationResult.php new file mode 100644 index 0000000..b77981e --- /dev/null +++ b/src/Webhook/WebhookVerificationResult.php @@ -0,0 +1,20 @@ + $metadata + */ + public function __construct( + public bool $valid, + public ?string $reason = null, + public ?int $timestamp = null, + public bool $signaturePresent = false, + public ?string $signaturePrefix = null, + public array $metadata = [], + ) {} +} diff --git a/src/Webhook/WebhookVerifier.php b/src/Webhook/WebhookVerifier.php new file mode 100644 index 0000000..2d3b564 --- /dev/null +++ b/src/Webhook/WebhookVerifier.php @@ -0,0 +1,111 @@ +secret === '') { + throw new InvalidArgumentException('Webhook secret must not be empty.'); + } + + if ($this->maxAgeSeconds < 1) { + throw new InvalidArgumentException('Webhook max age must be greater than zero.'); + } + + $this->signatureParser = new WebhookSignatureParser(); + } + + public function verify( + string $payload, + string $signatureHeader, + ?int $now = null, + ?string $timestampHeader = null, + ): bool { + return $this->verifyResult($payload, $signatureHeader, $timestampHeader, $now)->valid; + } + + public function verifyResult( + string $payload, + string $signatureHeader, + ?string $timestampHeader = null, + ?int $now = null, + ): WebhookVerificationResult { + $now ??= time(); + + if (trim($signatureHeader) === '') { + return $this->reject('missing_signature_header'); + } + + if ($timestampHeader !== null && trim($timestampHeader) === '') { + return $this->reject('missing_timestamp'); + } + + $parsed = $this->signatureParser->parse($signatureHeader, $timestampHeader); + if ($parsed === null) { + if ($timestampHeader !== null && trim($timestampHeader) !== '' && !ctype_digit(trim($timestampHeader))) { + return $this->reject('invalid_timestamp'); + } + + return $this->reject('malformed_signature'); + } + + $timestamp = $parsed['timestamp']; + $signature = $parsed['signature']; + + if (abs($now - $timestamp) > $this->maxAgeSeconds) { + return $this->reject('expired_timestamp', $timestamp, $signature); + } + + $expected = new HmacWebhookSigner()->sign($payload, $timestamp, $this->secret); + if (!hash_equals($expected, $signature)) { + return $this->reject('signature_mismatch', $timestamp, $signature); + } + + $result = new WebhookVerificationResult( + valid: true, + reason: null, + timestamp: $timestamp, + signaturePresent: true, + signaturePrefix: substr($signature, 0, 8), + metadata: ['max_age_seconds' => $this->maxAgeSeconds], + ); + + CommunicationEventBus::dispatch('webhook.verified', [ + 'timestamp' => $timestamp, + 'signature' => '[REDACTED]', + ]); + + return $result; + } + + private function reject(string $reason, ?int $timestamp = null, ?string $signature = null): WebhookVerificationResult + { + CommunicationEventBus::dispatch('webhook.rejected', [ + 'reason' => $reason, + 'timestamp' => $timestamp, + 'signature' => $signature !== null ? '[REDACTED]' : null, + ]); + + return new WebhookVerificationResult( + valid: false, + reason: $reason, + timestamp: $timestamp, + signaturePresent: $signature !== null, + signaturePrefix: $signature !== null ? substr($signature, 0, 8) : null, + metadata: ['max_age_seconds' => $this->maxAgeSeconds], + ); + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php deleted file mode 100644 index 609b6b6..0000000 --- a/tests/ArchTest.php +++ /dev/null @@ -1,9 +0,0 @@ -each->not()->toBeUsed(); -}); - -test('No echo statements', function () { - expect(['echo', 'print'])->each->not()->toBeUsed(); -}); diff --git a/tests/BounceParserTest.php b/tests/BounceParserTest.php new file mode 100644 index 0000000..623e351 --- /dev/null +++ b/tests/BounceParserTest.php @@ -0,0 +1,228 @@ +parse($raw); + $report = (new BounceParser)->parse($email); + + expect($report)->not->toBeNull(); + expect($report?->type)->toBe(BounceType::UserUnknown); + expect($report?->recipient)->toBe('user@example.com'); + expect($report?->status)->toBe('5.1.1'); + expect($report?->diagnosticCode)->toContain('550 5.1.1 User unknown'); + expect($report?->metadata['source'] ?? null)->toBe('delivery-status'); +}); + +it('classifies plain text heuristic bounce when dsn part is missing', function (): void { + $raw = implode("\r\n", [ + 'From: postmaster@example.com', + 'To: sender@example.com', + 'Subject: Undelivered Mail Returned to Sender', + '', + 'This is the mail system at host mx.example.com.', + 'Diagnostic-Code: smtp; 552 5.2.2 Mailbox full', + 'Failed recipient: fullbox@example.com', + ]); + + $email = (new RawEmailParser)->parse($raw); + $report = (new BounceParser)->parse($email); + + expect($report)->not->toBeNull(); + expect($report?->type)->toBe(BounceType::MailboxFull); + expect($report?->recipient)->toBe('fullbox@example.com'); + expect($report?->status)->toBe('5.2.2'); +}); + +it('returns null for non-bounce emails', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Welcome', + '', + 'Hello there.', + ]); + + $email = (new RawEmailParser)->parse($raw); + $report = (new BounceParser)->parse($email); + + expect($report)->toBeNull(); +}); + +it('dispatches bounce.detected event when bounce is parsed', function (): void { + $events = []; + EmailEventBus::listen(static function (string $event, array $payload) use (&$events): void { + $events[] = ['event' => $event, 'payload' => $payload]; + }); + + $raw = implode("\r\n", [ + 'From: postmaster@example.com', + 'To: sender@example.com', + 'Subject: Undelivered Mail Returned to Sender', + '', + 'Diagnostic-Code: smtp; 552 5.2.2 Mailbox full', + 'Failed recipient: fullbox@example.com', + ]); + + $email = (new RawEmailParser)->parse($raw); + $report = (new BounceParser)->parse($email); + + expect($report)->not->toBeNull(); + expect($events)->not->toBeEmpty(); + expect($events[0]['event'])->toBe('bounce.detected'); + expect($events[0]['payload']['type'])->toBe(BounceType::MailboxFull->value); +}); + +it('parses many bounce reports from multi-recipient dsn payload', function (): void { + $raw = implode("\r\n", [ + 'From: MAILER-DAEMON@example.com', + 'To: sender@example.com', + 'Subject: Delivery Status Notification', + 'Content-Type: multipart/report; report-type=delivery-status; boundary="b1"', + '', + '--b1', + 'Content-Type: message/delivery-status', + '', + 'Reporting-MTA: dns; mx.example.net', + '', + 'Final-Recipient: rfc822; unknown@example.com', + 'Action: failed', + 'Status: 5.1.1', + 'Diagnostic-Code: smtp; 550 5.1.1 User unknown', + '', + 'Final-Recipient: rfc822; full@example.com', + 'Action: failed', + 'Status: 5.2.2', + 'Diagnostic-Code: smtp; 552 5.2.2 Mailbox full', + '', + '--b1--', + ]); + + $email = (new RawEmailParser)->parse($raw); + $reports = (new BounceParser)->parseMany($email); + + expect($reports)->toHaveCount(2); + expect($reports[0]->recipient)->toBe('unknown@example.com'); + expect($reports[0]->type)->toBe(BounceType::UserUnknown); + expect($reports[1]->recipient)->toBe('full@example.com'); + expect($reports[1]->type)->toBe(BounceType::MailboxFull); +}); + +it('parses repeated delivery-status recipient blocks', function (): void { + $body = implode("\r\n", [ + 'Reporting-MTA: dns; mx.example.net', + '', + 'Final-Recipient: rfc822; unknown@example.com', + 'Action: failed', + 'Status: 5.1.1', + 'Diagnostic-Code: smtp; 550 User unknown', + '', + 'Final-Recipient: rfc822; blocked@example.com', + 'Action: failed', + 'Status: 5.7.1', + 'Diagnostic-Code: smtp; 550 blocked by policy', + ]); + + $reports = (new DeliveryStatusParser)->parseMany($body); + + expect($reports)->toHaveCount(2); + expect($reports[0]['final_recipient'])->toBe('unknown@example.com'); + expect($reports[0]['reporting_mta'])->toBe('mx.example.net'); + expect($reports[1]['final_recipient'])->toBe('blocked@example.com'); + expect($reports[1]['diagnostic_code'])->toContain('blocked by policy'); +}); + +it('classifies blocked and temporary bounce diagnostics', function (): void { + $blockedRaw = implode("\r\n", [ + 'From: postmaster@example.com', + 'To: sender@example.com', + 'Subject: Undelivered Mail Returned to Sender', + '', + 'Status: 5.7.1', + 'Diagnostic-Code: smtp; 550 blocked by policy', + ]); + + $temporaryRaw = implode("\r\n", [ + 'From: postmaster@example.com', + 'To: sender@example.com', + 'Subject: Mail delivery failed', + '', + 'Status: 4.4.1', + 'Diagnostic-Code: smtp; Temporary failure, try again later', + ]); + + $blocked = (new BounceParser)->parse((new RawEmailParser)->parse($blockedRaw)); + $temporary = (new BounceParser)->parse((new RawEmailParser)->parse($temporaryRaw)); + + expect($blocked?->type)->toBe(BounceType::SpamRejected); + expect($temporary?->type)->toBe(BounceType::Temporary); +}); + +it('classifies status-code specific bounce categories', function (): void { + $cases = [ + ['status' => '5.1.1', 'diagnostic' => 'smtp; 550 5.1.1 User unknown', 'expected' => BounceType::UserUnknown], + ['status' => '5.1.2', 'diagnostic' => 'smtp; 550 5.1.2 Domain not found', 'expected' => BounceType::DomainNotFound], + ['status' => '5.2.2', 'diagnostic' => 'smtp; 552 5.2.2 Mailbox full', 'expected' => BounceType::MailboxFull], + ['status' => '4.2.2', 'diagnostic' => 'smtp; 452 4.2.2 Mailbox full temporary', 'expected' => BounceType::MailboxFull], + ['status' => '4.4.1', 'diagnostic' => 'smtp; 451 temporary failure', 'expected' => BounceType::Soft], + ['status' => '5.7.1', 'diagnostic' => 'smtp; 550 rejected by policy spam', 'expected' => BounceType::SpamRejected], + ]; + + $parser = new BounceParser; + + foreach ($cases as $case) { + $raw = implode("\r\n", [ + 'From: MAILER-DAEMON@example.com', + 'To: sender@example.com', + 'Subject: Delivery Status Notification', + 'Content-Type: multipart/report; report-type=delivery-status; boundary="b1"', + '', + '--b1', + 'Content-Type: message/delivery-status', + '', + 'Reporting-MTA: dns; mx.example.net', + '', + 'Final-Recipient: rfc822; user@example.com', + 'Action: failed', + 'Status: '.$case['status'], + 'Diagnostic-Code: '.$case['diagnostic'], + '', + '--b1--', + ]); + + $report = $parser->parse((new RawEmailParser)->parse($raw)); + expect($report)->not->toBeNull(); + expect($report?->type)->toBe($case['expected']); + } +}); diff --git a/tests/CharsetDecoderTest.php b/tests/CharsetDecoderTest.php new file mode 100644 index 0000000..bc172e2 --- /dev/null +++ b/tests/CharsetDecoderTest.php @@ -0,0 +1,86 @@ +markTestSkipped('charset conversion extensions are unavailable.'); + } + + $decoder = new CharsetDecoder; + + $cases = [ + ['charset' => 'ISO-8859-1', 'text' => 'Café'], + ['charset' => 'ISO-8859-15', 'text' => 'Prix €'], + ['charset' => 'WINDOWS-1252', 'text' => 'Résumé'], + ['charset' => 'KOI8-R', 'text' => 'Привет'], + ['charset' => 'SHIFT_JIS', 'text' => 'テスト'], + ]; + + foreach ($cases as $case) { + $bytes = null; + if (function_exists('iconv')) { + $bytes = iconv('UTF-8', $case['charset'].'//IGNORE', $case['text']); + } elseif (function_exists('mb_convert_encoding')) { + $bytes = mb_convert_encoding($case['text'], $case['charset'], 'UTF-8'); + } + + if (! is_string($bytes) || $bytes === '') { + continue; + } + + expect($decoder->toUtf8($bytes, $case['charset']))->toBe($case['text']); + } +}); + +it('uses configured fallback charset when source charset is unknown', function (): void { + if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) { + $this->markTestSkipped('charset conversion extensions are unavailable.'); + } + + $source = function_exists('iconv') + ? iconv('UTF-8', 'WINDOWS-1252//IGNORE', 'Résumé') + : mb_convert_encoding('Résumé', 'WINDOWS-1252', 'UTF-8'); + + if (! is_string($source) || $source === '') { + $this->markTestSkipped('Unable to prepare fallback charset test payload.'); + } + + $decoder = new CharsetDecoder('WINDOWS-1252'); + + expect($decoder->toUtf8($source, 'X-UNKNOWN-CHARSET'))->toBe('Résumé'); +}); + +it('supports additional charset aliases and handles invalid byte payloads safely', function (): void { + if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) { + $this->markTestSkipped('charset conversion extensions are unavailable.'); + } + + $decoder = new CharsetDecoder('WINDOWS-1252'); + $cases = [ + ['charset' => 'CP850', 'text' => 'Cafe'], + ['charset' => 'GB18030', 'text' => '中文'], + ['charset' => 'BIG5', 'text' => '中文'], + ]; + + foreach ($cases as $case) { + $bytes = null; + if (function_exists('iconv')) { + $bytes = iconv('UTF-8', $case['charset'].'//IGNORE', $case['text']); + } elseif (function_exists('mb_convert_encoding')) { + $bytes = mb_convert_encoding($case['text'], $case['charset'], 'UTF-8'); + } + + if (! is_string($bytes) || $bytes === '') { + continue; + } + + expect($decoder->toUtf8($bytes, $case['charset']))->toBe($case['text']); + } + + $invalidBytes = "\xFF\xFE\xFA"; + $decoded = $decoder->toUtf8($invalidBytes, 'X-UNKNOWN'); + expect($decoded)->toBeString(); +}); diff --git a/tests/CoreMiddlewareHardeningTest.php b/tests/CoreMiddlewareHardeningTest.php new file mode 100644 index 0000000..1713fce --- /dev/null +++ b/tests/CoreMiddlewareHardeningTest.php @@ -0,0 +1,240 @@ +attempts++; + + if ($this->attempts === 1) { + throw new RuntimeException('temporary failure'); + } + + return CommunicationResult::success(200); + } + }; + + $pipeline = new MiddlewarePipeline($transport, [new RetryMiddleware(new FixedDelayRetryPolicy(2, 0))]); + + $result = $pipeline->send(new CommunicationRequest('test', ['hello' => 'world'])); + + expect($result->successful)->toBeTrue(); + expect($attempts)->toBe(2); +}); + +it('applies timeout middleware to http request payload', function (): void { + $timeoutSeen = null; + + $transport = new class($timeoutSeen) implements TransportInterface + { + public function __construct(private ?int &$timeoutSeen) {} + + public function send(CommunicationRequest $request): CommunicationResult + { + expect($request->payload)->toBeInstanceOf(HttpRequest::class); + + /** @var HttpRequest $httpRequest */ + $httpRequest = $request->payload; + $this->timeoutSeen = $httpRequest->options->timeoutSeconds; + + return CommunicationResult::success(200); + } + }; + + $pipeline = new MiddlewarePipeline($transport, [new TimeoutMiddleware(33)]); + + $pipeline->send(new CommunicationRequest('http', HttpRequest::get('https://example.com'))); + + expect($timeoutSeen)->toBe(33); +}); + +it('applies timeout middleware to grpc request payload', function (): void { + $deadlineSeen = null; + + $transport = new class($deadlineSeen) implements TransportInterface + { + public function __construct(private ?float &$deadlineSeen) {} + + public function send(CommunicationRequest $request): CommunicationResult + { + expect($request->payload)->toBeInstanceOf(GrpcRequest::class); + + /** @var GrpcRequest $grpcRequest */ + $grpcRequest = $request->payload; + $this->deadlineSeen = $grpcRequest->deadlineSeconds; + + return CommunicationResult::success(200); + } + }; + + $pipeline = new MiddlewarePipeline($transport, [new TimeoutMiddleware(12)]); + + $pipeline->send(new CommunicationRequest('grpc', new GrpcRequest('Svc/Call', ['ok' => true]))); + + expect($deadlineSeen)->toBe(12.0); +}); + +it('applies header middleware to http request payload', function (): void { + $headerValue = null; + + $transport = new class($headerValue) implements TransportInterface + { + public function __construct(private ?string &$headerValue) {} + + public function send(CommunicationRequest $request): CommunicationResult + { + expect($request->payload)->toBeInstanceOf(HttpRequest::class); + + /** @var HttpRequest $httpRequest */ + $httpRequest = $request->payload; + $value = $httpRequest->headers->get('X-App'); + + expect($value)->toBeString(); + $this->headerValue = $value; + + return CommunicationResult::success(200); + } + }; + + $pipeline = new MiddlewarePipeline($transport, [new HeaderMiddleware(['X-App' => 'talkingbytes'])]); + + $pipeline->send(new CommunicationRequest('http', HttpRequest::get('https://example.com'))); + + expect($headerValue)->toBe('talkingbytes'); +}); + +it('rate limit middleware blocks excess requests', function (): void { + $transport = new class implements TransportInterface + { + public function send(CommunicationRequest $request): CommunicationResult + { + unset($request); + + return CommunicationResult::success(200); + } + }; + + $pipeline = new MiddlewarePipeline( + $transport, + [new RateLimitMiddleware(new RateLimiter(1, 60))], + ); + + $pipeline->send(new CommunicationRequest('test', null)); + + expect(fn () => $pipeline->send(new CommunicationRequest('test', null))) + ->toThrow(RuntimeException::class, 'Rate limit exceeded.'); +}); + +it('circuit breaker middleware opens after failures', function (): void { + $transport = new class implements TransportInterface + { + public function send(CommunicationRequest $request): CommunicationResult + { + unset($request); + + return CommunicationResult::failure('downstream failed', 503); + } + }; + + $pipeline = new MiddlewarePipeline( + $transport, + [new CircuitBreakerMiddleware(new CircuitBreaker(failureThreshold: 1, coolDownSeconds: 60))], + ); + + $first = $pipeline->send(new CommunicationRequest('test', null)); + + expect($first->successful)->toBeFalse(); + expect(fn () => $pipeline->send(new CommunicationRequest('test', null))) + ->toThrow(RuntimeException::class, 'Circuit breaker is open.'); +}); + +it('applies idempotency key to both communication headers and http payload headers', function (): void { + $requestHeaders = []; + $httpHeader = null; + + $transport = new class($requestHeaders, $httpHeader) implements TransportInterface + { + /** + * @param array $requestHeaders + */ + public function __construct( + private array &$requestHeaders, + private string|array|null &$httpHeader, + ) {} + + public function send(CommunicationRequest $request): CommunicationResult + { + expect($request->payload)->toBeInstanceOf(HttpRequest::class); + + $this->requestHeaders = $request->headers; + + /** @var HttpRequest $httpRequest */ + $httpRequest = $request->payload; + $this->httpHeader = $httpRequest->headers->get('Idempotency-Key'); + + return CommunicationResult::success(200); + } + }; + + $pipeline = new MiddlewarePipeline($transport, [new IdempotencyMiddleware]); + $pipeline->send(new CommunicationRequest('http', HttpRequest::post('https://example.com')->json(['a' => 1]))); + + expect($requestHeaders)->toHaveKey('Idempotency-Key'); + expect($requestHeaders['Idempotency-Key'])->toMatch('/^[a-f0-9]{32}$/'); + expect($httpHeader)->toBe($requestHeaders['Idempotency-Key']); +}); + +it('logging middleware logs request end on transport exception', function (): void { + $events = []; + + $transport = new class implements TransportInterface + { + public function send(CommunicationRequest $request): CommunicationResult + { + unset($request); + + throw new RuntimeException('boom'); + } + }; + + $logger = function (string $event, array $context) use (&$events): void { + $events[] = ['event' => $event, 'context' => $context]; + }; + + $pipeline = new MiddlewarePipeline($transport, [new LoggingMiddleware($logger)]); + + expect(fn () => $pipeline->send(new CommunicationRequest('test', null))) + ->toThrow(RuntimeException::class, 'boom'); + + expect($events)->toHaveCount(2); + expect($events[0]['event'])->toBe('request.start'); + expect($events[1]['event'])->toBe('request.end'); + expect($events[1]['context']['successful'])->toBeFalse(); + expect($events[1]['context']['error'])->toBe('boom'); +}); diff --git a/tests/CorePipelineTest.php b/tests/CorePipelineTest.php new file mode 100644 index 0000000..dd34210 --- /dev/null +++ b/tests/CorePipelineTest.php @@ -0,0 +1,76 @@ + $events + */ + public function __construct(private array &$events) {} + + public function send(CommunicationRequest $request): CommunicationResult + { + unset($request); + $this->events[] = 'transport'; + + return CommunicationResult::success(200); + } + }; + + $first = new class($events) implements MiddlewareInterface + { + /** + * @param array $events + */ + public function __construct(private array &$events) {} + + public function handle(CommunicationRequest $request, Closure $next): CommunicationResult + { + $this->events[] = 'first.before'; + $result = $next($request); + $this->events[] = 'first.after'; + + return $result; + } + }; + + $second = new class($events) implements MiddlewareInterface + { + /** + * @param array $events + */ + public function __construct(private array &$events) {} + + public function handle(CommunicationRequest $request, Closure $next): CommunicationResult + { + $this->events[] = 'second.before'; + $result = $next($request); + $this->events[] = 'second.after'; + + return $result; + } + }; + + $pipeline = new MiddlewarePipeline($transport, [$first, $second]); + + $result = $pipeline->send(new CommunicationRequest('test', ['hello' => 'world'])); + + expect($result->successful)->toBeTrue(); + expect($events)->toBe([ + 'first.before', + 'second.before', + 'transport', + 'second.after', + 'first.after', + ]); +}); diff --git a/tests/DkimVerificationTest.php b/tests/DkimVerificationTest.php new file mode 100644 index 0000000..74659f9 --- /dev/null +++ b/tests/DkimVerificationTest.php @@ -0,0 +1,249 @@ + OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 1024, + 'config' => $configPath, + ]); + + if ($resource === false) { + unlink($configPath); + + throw new RuntimeException('Unable to generate DKIM test key pair.'); + } + + $privateKey = ''; + if (! openssl_pkey_export($resource, $privateKey, null, ['config' => $configPath])) { + unlink($configPath); + + throw new RuntimeException('Unable to export DKIM private key.'); + } + + $details = openssl_pkey_get_details($resource); + if (! is_array($details) || ! is_string($details['key'] ?? null)) { + unlink($configPath); + + throw new RuntimeException('Unable to export DKIM public key.'); + } + + unlink($configPath); + + return [$privateKey, $details['key']]; +} + +/** + * @return array + */ +function dkimParseTags(string $value): array +{ + $tags = []; + foreach (explode(';', $value) as $part) { + $part = trim($part); + if ($part === '' || ! str_contains($part, '=')) { + continue; + } + + [$name, $tagValue] = explode('=', $part, 2); + $tags[trim($name)] = trim($tagValue); + } + + return $tags; +} + +/** + * @return list + */ +function dkimUnfoldedHeaders(string $headers): array +{ + $lines = preg_split('/\r\n/', $headers) ?: []; + $result = []; + + foreach ($lines as $line) { + if ($line === '') { + continue; + } + + if (($line[0] ?? '') === ' ' || ($line[0] ?? '') === "\t") { + $last = array_key_last($result); + if ($last !== null) { + $result[$last] .= ' '.ltrim($line); + } + + continue; + } + + $result[] = $line; + } + + return $result; +} + +function dkimFindHeaderValue(string $headers, string $name): ?string +{ + $needle = strtolower($name).':'; + $matches = []; + + foreach (dkimUnfoldedHeaders($headers) as $line) { + if (! str_starts_with(strtolower($line), $needle)) { + continue; + } + + $matches[] = ltrim(substr($line, strlen($needle))); + } + + if ($matches === []) { + return null; + } + + return $matches[array_key_last($matches)]; +} + +it('generates a verifiable DKIM signature and matching body hash', function (): void { + [$privateKey, $publicKey] = dkimBuildKeyPair(); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('DKIM') + ->text("Line 1\r\nLine 2"); + + $raw = (new RawEmailBuilder)->build($message, includeSubject: true); + $config = new DkimConfig('example.com', 'selector', $privateKey); + + $signer = new DkimSigner; + $headerLine = $signer->buildSignatureHeader($raw->headers, $raw->body, $config); + $headerValue = trim(substr($headerLine, strlen('DKIM-Signature:'))); + $tags = dkimParseTags($headerValue); + + expect($tags)->toHaveKeys(['h', 'bh', 'b', 'd', 's']); + + $canonicalizer = new DkimCanonicalizer; + $expectedBh = base64_encode(hash('sha256', $canonicalizer->canonicalizeBody($raw->body), true)); + expect($tags['bh'])->toBe($expectedBh); + + $signedHeaders = array_filter(array_map('trim', explode(':', strtolower($tags['h'])))); + $canonicalized = []; + + foreach ($signedHeaders as $headerName) { + $value = dkimFindHeaderValue($raw->headers, $headerName); + expect($value)->not->toBeNull(); + + $canonicalized[] = $canonicalizer->canonicalizeHeader($headerName, (string) $value); + } + + $withoutSignatureValue = preg_replace('/\bb=[^;]*/', 'b=', $headerValue, 1); + expect($withoutSignatureValue)->not->toBeNull(); + $canonicalized[] = $canonicalizer->canonicalizeHeader('dkim-signature', (string) $withoutSignatureValue); + + $verificationPayload = implode("\r\n", $canonicalized); + $signatureBinary = base64_decode($tags['b'], true); + expect($signatureBinary)->not->toBeFalse(); + + $verified = openssl_verify($verificationPayload, $signatureBinary, $publicKey, OPENSSL_ALGO_SHA256); + expect($verified)->toBe(1); +}); + +it('adds dkim-signature header via signing transport decorator', function (): void { + [$privateKey] = dkimBuildKeyPair(); + + $inner = new FakeEmailTransport; + $transport = new DkimSigningTransport($inner, new DkimConfig('example.com', 'selector', $privateKey)); + + $result = $transport->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Decorated') + ->text('Body'), + ); + + expect($result->successful)->toBeTrue(); + $sent = $inner->sentMessages(); + expect($sent)->toHaveCount(1); + expect($sent[0]->headersData()->customHeaders)->toHaveKey('DKIM-Signature'); + expect((string) $sent[0]->headersData()->customHeaders['DKIM-Signature'])->toContain('v=1'); +}); + +it('builds dkim config from private key helpers', function (): void { + [$privateKey] = dkimBuildKeyPair(); + $path = sys_get_temp_dir().'/tb-dkim-key-'.bin2hex(random_bytes(4)).'.pem'; + file_put_contents($path, $privateKey); + + $fromPath = DkimConfig::fromPrivateKeyPath('example.com', 'selector', $path); + $fromString = DkimConfig::fromPrivateKeyString('example.com', 'selector', $privateKey); + + expect($fromPath->privateKey)->toBe($privateKey); + expect($fromString->privateKey)->toBe($privateKey); + + unlink($path); +}); + +it('keeps dkim algorithm extensible and rejects unsupported signer algorithms', function (): void { + [$privateKey] = dkimBuildKeyPair(); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('DKIM Alg') + ->text('Body'); + + $raw = (new RawEmailBuilder)->build($message, includeSubject: true); + $config = DkimConfig::fromPrivateKeyString( + 'example.com', + 'selector', + $privateKey, + algorithm: DkimAlgorithm::Ed25519Sha256, + ); + + expect(fn () => (new DkimSigner)->buildSignatureHeader($raw->headers, $raw->body, $config)) + ->toThrow(RuntimeException::class, 'Unsupported DKIM algorithm'); +}); + +it('resolves dkim txt records with split entries and ignores unrelated records', function (): void { + $resolver = new DnsDkimPublicKeyResolver(static function (string $name, int $type): array { + expect($name)->toBe('selector._domainkey.example.com'); + expect($type)->toBe(DNS_TXT); + + return [ + ['txt' => 'google-site-verification=abc123'], + ['entries' => ['v=DKIM1; k=rsa; ', 'p=abcDEF123']], + ]; + }); + + $record = $resolver->resolve('example.com', 'selector'); + + expect($record)->toBe('v=DKIM1; k=rsa; p=abcDEF123'); +}); + +it('treats dkim txt records with empty p tag as revoked', function (): void { + $resolver = new DnsDkimPublicKeyResolver(static function (string $name, int $type): array { + expect($name)->toBe('selector._domainkey.example.com'); + expect($type)->toBe(DNS_TXT); + + return [ + ['txt' => 'v=DKIM1; p='], + ]; + }); + + expect($resolver->resolve('example.com', 'selector'))->toBeNull(); +}); diff --git a/tests/EmailFacadeAndConfigFactoryTest.php b/tests/EmailFacadeAndConfigFactoryTest.php new file mode 100644 index 0000000..c7633fe --- /dev/null +++ b/tests/EmailFacadeAndConfigFactoryTest.php @@ -0,0 +1,86 @@ + 'smtp.example.com', + 'port' => 2525, + 'security' => SmtpSecurity::Ssl->value, + 'credentials' => ['username' => 'user', 'password' => 'pass'], + 'timeoutSeconds' => 15, + ]); + + $sendmail = SendmailConfig::fromArray([ + 'path' => '/usr/sbin/sendmail', + 'extraArguments' => ['-t', '-i'], + 'timeoutSeconds' => 20, + ]); + + $spool = SpoolConfig::fromArray([ + 'directory' => sys_get_temp_dir(), + 'writeMetadata' => false, + 'extension' => 'eml', + ]); + + $imap = ImapConfig::fromArray([ + 'host' => 'imap.example.com', + 'port' => 993, + 'security' => ImapSecurity::Ssl->value, + 'username' => 'u', + 'password' => 'p', + ]); + + $pop3 = Pop3Config::fromArray([ + 'host' => 'pop.example.com', + 'port' => 110, + 'security' => Pop3Security::None->value, + 'username' => 'u', + 'password' => 'p', + ]); + + $log = LogEmailConfig::fromArray([ + 'directory' => sys_get_temp_dir(), + 'dailyFiles' => true, + 'filenamePrefix' => 'mail', + ]); + + expect($smtp->host)->toBe('smtp.example.com'); + expect($smtp->credentials?->username)->toBe('user'); + expect($sendmail->timeoutSeconds)->toBe(20); + expect($spool->writeMetadata)->toBeFalse(); + expect($imap->security)->toBe(ImapSecurity::Ssl); + expect($pop3->security)->toBe(Pop3Security::None); + expect($log->filenamePrefix)->toBe('mail'); +}); + +it('exposes unified email facade factories', function (): void { + $sender = Email::sender()->usingNull(); + $receiver = Email::receiver()->usingSpool(new SpoolConfig(directory: sys_get_temp_dir())); + $pop3 = Email::mailbox()->usingPop3(new Pop3Config( + host: 'pop.example.com', + security: Pop3Security::None, + username: 'u', + password: 'p', + )); + + expect($sender)->toBeInstanceOf(Emailer::class); + expect($receiver)->toBeInstanceOf(SpoolEmailReceiver::class); + expect($pop3)->toBeInstanceOf(Pop3Mailbox::class); + expect(method_exists(Mailbox::class, 'usingPop3'))->toBeFalse(); +}); diff --git a/tests/EmailLimitsTest.php b/tests/EmailLimitsTest.php new file mode 100644 index 0000000..fb27ebd --- /dev/null +++ b/tests/EmailLimitsTest.php @@ -0,0 +1,219 @@ + $parser->parse($raw))->toThrow(EmailParseException::class, 'max message size'); +}); + +it('rejects raw email that exceeds header limits', function (): void { + $limits = new EmailLimits(maxHeaderBytes: 30, maxHeaderCount: 2); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: Test', + '', + 'Body', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class); +}); + +it('rejects invalid email limits configuration', function (): void { + expect(fn () => new EmailLimits(maxMessageBytes: 0))->toThrow(InvalidArgumentException::class); + expect(fn () => new EmailLimits(maxAttachmentCount: 0))->toThrow(InvalidArgumentException::class); + expect(fn () => new EmailLimits(maxDecodedBodyBytes: 0))->toThrow(InvalidArgumentException::class); +}); + +it('rejects parsed emails that exceed attachment count limit', function (): void { + $limits = new EmailLimits(maxAttachmentCount: 1); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: Attachments', + 'Content-Type: multipart/mixed; boundary="mix"', + '', + '--mix', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Hello', + '--mix', + 'Content-Type: application/octet-stream; name="a.txt"', + 'Content-Disposition: attachment; filename="a.txt"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('a'), + '--mix', + 'Content-Type: application/octet-stream; name="b.txt"', + 'Content-Disposition: attachment; filename="b.txt"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('b'), + '--mix--', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class, 'Attachment count exceeds limit'); +}); + +it('rejects parsed emails that exceed decoded body size limit', function (): void { + $limits = new EmailLimits(maxDecodedBodyBytes: 8); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: Body size', + 'Content-Type: text/plain; charset=UTF-8', + '', + '123456789', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class, 'Decoded MIME body exceeds limit'); +}); + +it('rejects streamed raw email when output exceeds max bytes', function (): void { + $builder = new RawEmailBuilder; + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Size limit') + ->text(str_repeat('x', 64)); + + expect(fn () => $builder->buildToStream( + $message, + static function (): void {}, + includeSubject: true, + maxBytes: 32, + ))->toThrow(RuntimeException::class, 'exceeds configured limit'); +}); + +it('rejects parsed emails that exceed MIME depth limit', function (): void { + $limits = new EmailLimits(maxMimeDepth: 2); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: Deep nesting', + 'Content-Type: multipart/mixed; boundary="b1"', + '', + '--b1', + 'Content-Type: multipart/alternative; boundary="b2"', + '', + '--b2', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'hello', + '--b2--', + '--b1--', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class, 'MIME nesting depth exceeds limit'); +}); + +it('rejects parsed emails that exceed MIME part-count limit', function (): void { + $limits = new EmailLimits(maxMimeParts: 2); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: Part count', + 'Content-Type: multipart/mixed; boundary="m1"', + '', + '--m1', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'one', + '--m1', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'two', + '--m1--', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class, 'MIME part count exceeds limit'); +}); + +it('rejects parsed emails that exceed header-count limit specifically', function (): void { + $limits = new EmailLimits(maxHeaderCount: 2, maxHeaderBytes: 1024); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: Too many', + '', + 'Body', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class, 'Header count exceeds limit'); +}); + +it('rejects parsed emails that exceed header-bytes limit specifically', function (): void { + $limits = new EmailLimits(maxHeaderBytes: 48, maxHeaderCount: 100); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'X-Very-Long: '.str_repeat('x', 120), + '', + 'Body', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class, 'Header section exceeds limit'); +}); + +it('rejects recursive-looking multipart payloads when depth limit is exceeded', function (): void { + $limits = new EmailLimits(maxMimeDepth: 3, maxMimeParts: 64); + $parser = new RawEmailParser(limits: $limits); + + $raw = implode("\r\n", [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: Recursive multipart', + 'Content-Type: multipart/mixed; boundary="b1"', + '', + '--b1', + 'Content-Type: multipart/mixed; boundary="b2"', + '', + '--b2', + 'Content-Type: multipart/mixed; boundary="b3"', + '', + '--b3', + 'Content-Type: multipart/mixed; boundary="b4"', + '', + '--b4', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'payload', + '--b4--', + '--b3--', + '--b2--', + '--b1--', + ]); + + expect(fn () => $parser->parse($raw))->toThrow(EmailParseException::class, 'MIME nesting depth exceeds limit'); +}); diff --git a/tests/EmailMessageTest.php b/tests/EmailMessageTest.php new file mode 100644 index 0000000..b2f4300 --- /dev/null +++ b/tests/EmailMessageTest.php @@ -0,0 +1,269 @@ +from('sender@example.com', 'Sender') + ->to('alice@example.com') + ->cc('bob@example.com') + ->bcc('hidden@example.com') + ->subject('Welcome') + ->text('Hello'); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->toContain('To: alice@example.com'); + expect($headers)->toContain('Cc: bob@example.com'); + expect($headers)->not->toContain('Bcc:'); + expect($headers)->toMatch('/Message-ID: <[a-f0-9]{32}@example\.com>/'); +}); + +it('validates header injection attempts', function (): void { + expect(fn () => EmailMessage::new()->subject("Hello\r\nBcc: x@example.com")) + ->toThrow(InvalidHeaderValueException::class); + + expect(fn () => EmailMessage::new()->subject("Hello\x00World")) + ->toThrow(InvalidHeaderValueException::class); +}); + +it('throws on missing attachment file', function (): void { + expect(fn () => EmailMessage::new()->attach('/tmp/does-not-exist-file.txt')) + ->toThrow(AttachmentException::class); +}); + +it('encodes utf8 plain text as quoted printable', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('utf8') + ->text('Cafe élan'); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($mime->contentType)->toBe('text/plain; charset=UTF-8'); + expect($mime->contentTransferEncoding)->toBe(ContentTransferEncoding::QuotedPrintable); + expect($mime->body)->toContain('=C3=A9'); + expect($headers)->toContain('Content-Transfer-Encoding: quoted-printable'); +}); + +it('keeps explicit in-reply-to and references message ids intact', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('thread') + ->text('Hello') + ->messageDetails( + messageId: 'custom@example.com', + inReplyTo: '', + references: ['', 'second@example.org'], + ); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->toContain('Message-ID: '); + expect($headers)->toContain('In-Reply-To: '); + expect($headers)->toContain('References: '); +}); + +it('uses undisclosed recipients when to list is empty', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->cc('bob@example.com') + ->subject('notice') + ->text('Hello'); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->toContain('To: undisclosed-recipients:;'); + expect($headers)->toContain('Cc: bob@example.com'); +}); + +it('appends recipients by default and allows replacing with withTo/withCc/withBcc', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('first@example.com') + ->to('second@example.com') + ->cc('copy-one@example.com') + ->cc('copy-two@example.com') + ->bcc('blind-one@example.com') + ->bcc('blind-two@example.com'); + + expect($message->envelope()->to)->toHaveCount(2); + expect($message->envelope()->cc)->toHaveCount(2); + expect($message->envelope()->bcc)->toHaveCount(2); + + $replaced = $message + ->withTo('replace-to@example.com') + ->withCc('replace-cc@example.com') + ->withBcc('replace-bcc@example.com'); + + expect($replaced->envelope()->to)->toHaveCount(1); + expect($replaced->envelope()->cc)->toHaveCount(1); + expect($replaced->envelope()->bcc)->toHaveCount(1); +}); + +it('deduplicates recipients case-insensitively for envelope delivery', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('User@example.com') + ->cc('user@example.com') + ->bcc('USER@example.com'); + + expect($message->envelope()->recipients())->toHaveCount(1); +}); + +it('validates smtp config values', function (): void { + expect(fn () => new SmtpConfig(''))->toThrow(InvalidArgumentException::class); + expect(fn () => new SmtpConfig('smtp.example.com', 0))->toThrow(InvalidArgumentException::class); + expect(fn () => new SmtpConfig('smtp.example.com', 587, timeoutSeconds: 0))->toThrow(InvalidArgumentException::class); +}); + +it('validates smtp credentials values', function (): void { + expect(fn () => new SmtpCredentials('', 'password'))->toThrow(InvalidArgumentException::class); + expect(fn () => new SmtpCredentials('username', ''))->toThrow(InvalidArgumentException::class); +}); + +it('supports bounce alias and dsn envelope id customization', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('DSN') + ->text('Body') + ->bounceTo('bounces@example.com') + ->deliveryNotification(envelopeId: 'envid-123'); + + expect($message->envelope()->returnPath?->email)->toBe('bounces@example.com'); + expect($message->headersData()->dsnEnvelopeId)->toBe('envid-123'); +}); + +it('validates dsn envelope id format', function (): void { + expect(fn () => EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('DSN') + ->text('Body') + ->deliveryNotification(envelopeId: 'bad id!')) + ->toThrow(InvalidArgumentException::class); +}); + +it('allows clearing nullable email headers', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Reset headers') + ->text('body') + ->replyTo('reply@example.com') + ->sender('mailer@example.com') + ->listHeaders('list.example.com', 'https://example.com/unsub', 'https://example.com/sub', 'https://example.com/archive') + ->withoutReplyTo() + ->withoutSender() + ->withoutListHeaders(); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->not->toContain('Reply-To:'); + expect($headers)->not->toContain('Sender:'); + expect($headers)->not->toContain('List-Id:'); + expect($headers)->not->toContain('List-Unsubscribe:'); +}); + +it('normalizes raw email line endings to crlf', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Line endings') + ->text("Hello\nWorld\r\nDone\r"); + + $raw = (new RawEmailBuilder)->build($message, includeSubject: true); + + expect($raw->raw)->toContain("\r\n\r\n"); + expect($raw->raw)->not->toContain("\n\n"); + expect(str_contains(str_replace("\r\n", '', $raw->raw), "\n"))->toBeFalse(); +}); + +it('validates attachment data limits and identifiers', function (): void { + expect(fn () => EmailMessage::new()->attachData(str_repeat('x', 6), 'file.txt', maxSizeBytes: 5)) + ->toThrow(AttachmentException::class); + + expect(fn () => EmailMessage::new()->attachData('ok', "bad\r\nname.txt")) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => EmailMessage::new()->attachInlineData('ok', 'file.txt', "bad\r\ncid")) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => EmailMessage::new()->attachData('ok', 'file.txt', 'not/mime@type')) + ->toThrow(InvalidArgumentException::class); +}); + +it('enforces stream attachment max size when content is read', function (): void { + $stream = fopen('php://temp', 'rb+'); + fwrite($stream, str_repeat('x', 8)); + rewind($stream); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Stream size') + ->text('Body') + ->attachStream($stream, 'stream.bin', maxSizeBytes: 4); + + expect(fn () => (new MimeMessageBuilder)->build($message)) + ->toThrow(AttachmentException::class, 'exceeds max size'); + + fclose($stream); +}); + +it('normalizes idn domains to punycode for envelope addresses', function (): void { + if (! function_exists('idn_to_ascii')) { + expect(fn () => EmailMessage::new()->to('user@bücher.example'))->toThrow(InvalidArgumentException::class); + + return; + } + + $message = EmailMessage::new() + ->from('sender@bücher.example') + ->to('user@bücher.example') + ->subject('IDN') + ->text('Body'); + + expect($message->envelope()->from?->email)->toBe('sender@xn--bcher-kva.example'); + expect($message->envelope()->to[0]->email)->toBe('user@xn--bcher-kva.example'); +}); + +it('provides embed helper that returns cid and inline attachment', function (): void { + $path = sys_get_temp_dir().'/talkingbytes-embed-'.bin2hex(random_bytes(4)).'.txt'; + file_put_contents($path, 'logo'); + + [$message, $cid] = EmailMessage::new()->embed($path); + + expect($cid)->toBeString(); + expect($message->attachments())->toHaveCount(1); + expect($message->attachments()[0]->isInline())->toBeTrue(); + expect($message->attachments()[0]->contentId)->toBe($cid); + + unlink($path); +}); + +it('renders templates into html and text bodies', function (): void { + $html = EmailMessage::new()->template('

Hello {{name}}

', ['name' => 'Alice']); + $text = EmailMessage::new()->template('Hi {{name}}', ['name' => 'Bob'], asHtml: false); + + expect($html->htmlBody())->toBe('

Hello Alice

'); + expect($text->textBody())->toBe('Hi Bob'); +}); diff --git a/tests/EmailObservabilityTest.php b/tests/EmailObservabilityTest.php new file mode 100644 index 0000000..8c99156 --- /dev/null +++ b/tests/EmailObservabilityTest.php @@ -0,0 +1,99 @@ +}> + */ + public array $entries = []; + + /** + * @param array $context + */ + public function log(string $level, string $message, array $context = []): void + { + $this->entries[] = ['level' => $level, 'message' => $message, 'context' => $context]; + } +} + +it('dispatches global email lifecycle events', function (): void { + $events = []; + Email::events(static function (string $event, array $payload) use (&$events): void { + $events[] = ['event' => $event, 'payload' => $payload]; + }); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.com') + ->subject('Event') + ->text('body'); + + $result = Emailer::usingNull()->send($message); + + Email::events(null); + + expect($result->successful)->toBeTrue(); + expect($events)->toHaveCount(2); + expect($events[0]['event'])->toBe('email.send.start'); + expect($events[1]['event'])->toBe('email.send.finish'); +}); + +it('adapts psr-style logger object to callable logger transport', function (): void { + $logger = new DummyPsrLogger; + $callable = (new Psr3LoggerAdapter($logger))->toCallable('notice'); + + $callable('email.send.start', ['transport' => 'null-email']); + + expect($logger->entries)->toHaveCount(1); + expect($logger->entries[0]['level'])->toBe('notice'); + expect($logger->entries[0]['message'])->toBe('email.send.start'); +}); + +it('resets event listener and prevents cross-test leakage', function (): void { + $events = []; + EmailEventBus::listen(static function (string $event, array $payload) use (&$events): void { + $events[] = ['event' => $event, 'payload' => $payload]; + }); + + EmailEventBus::dispatch('email.send.start', ['transport' => 'null-email']); + EmailEventBus::listen(null); + EmailEventBus::dispatch('email.send.finish', ['transport' => 'null-email']); + + expect($events)->toHaveCount(1); + expect($events[0]['event'])->toBe('email.send.start'); +}); + +it('bubbles listener exceptions by design', function (): void { + EmailEventBus::listen(static function (): void { + throw new RuntimeException('listener failed'); + }); + + expect(fn () => EmailEventBus::dispatch('email.send.start', [])) + ->toThrow(RuntimeException::class, 'listener failed'); + + EmailEventBus::listen(null); +}); + +it('supports dispatcher swapping while keeping static facade convenience', function (): void { + $events = []; + EmailEventBus::useDispatcher(new CallableEmailEventDispatcher(static function (string $event, array $payload) use (&$events): void { + $events[] = ['event' => $event, 'payload' => $payload]; + })); + + EmailEventBus::dispatch('mailbox.command.start', ['command' => 'NOOP']); + EmailEventBus::useDispatcher(new NullEmailEventDispatcher); + EmailEventBus::dispatch('mailbox.command.finish', ['command' => 'NOOP']); + + expect($events)->toHaveCount(1); + expect($events[0]['event'])->toBe('mailbox.command.start'); +}); diff --git a/tests/EmailOutboundExtensionsTest.php b/tests/EmailOutboundExtensionsTest.php new file mode 100644 index 0000000..9c2735e --- /dev/null +++ b/tests/EmailOutboundExtensionsTest.php @@ -0,0 +1,243 @@ +from('sender@example.com', 'Sender') + ->sender('mailer@example.com') + ->to('alice@example.com') + ->subject('Welcome') + ->text('Hello') + ->oneClickUnsubscribe('https://example.com/unsubscribe') + ->header('X-Campaign', 'welcome-v1'); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->toContain('Sender: mailer@example.com'); + expect($headers)->toContain('List-Unsubscribe: '); + expect($headers)->toContain('List-Unsubscribe-Post: List-Unsubscribe=One-Click'); + expect($headers)->toContain('X-Campaign: welcome-v1'); +}); + +it('builds multipart related with inline attachments', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Inline image') + ->html('') + ->attachInlineData('logo-bytes', 'logo.png', 'logo', 'image/png'); + + $mime = (new MimeMessageBuilder)->build($message); + + expect($mime->contentType)->toContain('multipart/related'); + expect($mime->body)->toContain('Content-ID: '); + expect($mime->body)->toContain('Content-Disposition: inline;'); +}); + +it('provides fake email assertions', function (): void { + $emailer = Emailer::fake(); + + $result = $emailer->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Hello') + ->text('Body') + ->attachData('pdf-content', 'invoice.pdf', 'application/pdf'), + ); + + expect($result->successful)->toBeTrue(); + + $assert = $emailer->assertable(); + $assert->assertSent(); + $assert->assertSentCount(1); + $assert->assertSentTo('alice@example.com'); + $assert->assertSentSubject('Hello'); + $assert->assertHasAttachment('invoice.pdf'); +}); + +it('parses smtp capabilities including auth mechanisms and size', function (): void { + $parser = new SmtpCapabilityParser; + + $capabilities = $parser->parse([ + '250-mail.example.com', + '250-STARTTLS', + '250-SIZE 10485760', + '250 AUTH PLAIN LOGIN', + ]); + + expect($capabilities->has('STARTTLS'))->toBeTrue(); + expect($capabilities->sizeLimit())->toBe(10485760); + expect($capabilities->authMechanisms)->toBe(['PLAIN', 'LOGIN']); +}); + +it('receives queued .eml files from spool receiver', function (): void { + $directory = sys_get_temp_dir().'/talkingbytes-spool-'.bin2hex(random_bytes(4)); + + $emailer = Emailer::usingSpool(new SpoolConfig($directory)); + + $emailer->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Spool Test') + ->text('Queued body'), + ); + + $receiver = new SpoolEmailReceiver(new SpoolConfig($directory), deleteAfterRead: true); + $received = $receiver->receive(); + + expect($received)->not->toBeNull(); + expect($received?->subject)->toBe('Spool Test'); + expect($received?->textBody)->toContain('Queued body'); + + array_map(static fn ($path): bool => unlink($path), glob($directory.'/*') ?: []); + if (is_dir($directory)) { + rmdir($directory); + } +}); + +it('honors queued fake transport results', function (): void { + $transport = (new FakeEmailTransport) + ->pushResult(CommunicationResult::failure('failed')); + + $emailer = new Emailer($transport); + + $result = $emailer->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Hello') + ->text('Body'), + ); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toBe('failed'); +}); + +it('logging email transport logs finish event when inner transport throws', function (): void { + $events = []; + + $transport = new class implements EmailTransport + { + public function send(EmailMessage $message): CommunicationResult + { + unset($message); + + throw new RuntimeException('transport boom'); + } + }; + + $logger = static function (string $event, array $context) use (&$events): void { + $events[] = ['event' => $event, 'context' => $context]; + }; + + $loggingTransport = new LoggingEmailTransport($transport, $logger); + + expect(fn () => $loggingTransport->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Boom') + ->text('Body'), + ))->toThrow(RuntimeException::class, 'transport boom'); + + expect($events)->toHaveCount(2); + expect($events[0]['event'])->toBe('email.send.start'); + expect($events[1]['event'])->toBe('email.send.finish'); + expect($events[1]['context']['successful'])->toBeFalse(); + expect($events[1]['context']['error'])->toBe('transport boom'); +}); + +it('fails clearly when log transport directory path is not a directory', function (): void { + $filePath = sys_get_temp_dir().'/talkingbytes-log-file-'.bin2hex(random_bytes(4)); + file_put_contents($filePath, 'x'); + + $transport = new LogEmailTransport(new LogEmailConfig($filePath)); + $result = $transport->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Log fail') + ->text('Body'), + ); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('not a directory'); + + if (is_file($filePath)) { + unlink($filePath); + } +}); + +it('keeps spool success when metadata sidecar encoding fails', function (): void { + $directory = sys_get_temp_dir().'/talkingbytes-spool-meta-'.bin2hex(random_bytes(4)); + $emailer = Emailer::usingSpool(new SpoolConfig($directory, writeMetadata: true)); + + $result = $emailer->send( + EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Spool metadata') + ->text('Body') + ->tag('invalid', NAN), + ); + + expect($result->successful)->toBeTrue(); + expect($result->metadata['metadata_write_failed'] ?? null)->toBeTrue(); + expect($result->metadata['metadata_write_error'] ?? null)->toBeString(); + + array_map(static fn ($path): bool => unlink($path), glob($directory.'/*') ?: []); + if (is_dir($directory)) { + rmdir($directory); + } +}); + +it('folds dkim headers on semicolon boundaries', function (): void { + $folder = new HeaderFolder; + $line = 'DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector; t=123456789; h=from:to:subject:date:message-id; bh=abc; b='.str_repeat('x', 120); + + $folded = $folder->fold($line, 78); + + expect($folded)->toContain("\r\n "); + expect($folded)->toContain('DKIM-Signature:'); +}); + +it('dkim header parser unfolds lines and preserves duplicate headers', function (): void { + $headers = implode("\r\n", [ + 'From: sender@example.com', + 'Received: by mx1.example.net', + "\twith ESMTP", + 'Received: by mx2.example.net', + 'Subject: Test', + '', + ]); + + $signer = new DkimSigner; + $reflection = new ReflectionMethod($signer, 'parseHeaders'); + + /** @var array> $parsed */ + $parsed = $reflection->invoke($signer, $headers); + + expect($parsed)->toHaveKey('received'); + expect($parsed['received'])->toHaveCount(2); + expect($parsed['received'][0])->toContain('with ESMTP'); +}); diff --git a/tests/EmailParserTest.php b/tests/EmailParserTest.php new file mode 100644 index 0000000..6bdce97 --- /dev/null +++ b/tests/EmailParserTest.php @@ -0,0 +1,594 @@ +', + 'To: alice@example.com, Bob ', + 'Subject: =?UTF-8?B?VGVzdCDDpA==?=', + 'Received: by mx1.example.net', + 'Received: by mx2.example.net', + 'X-Trace: one', + "\tcontinued", + '', + 'Body', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->subject)->toBe('Test ä'); + expect($parsed->headers['received'] ?? [])->toHaveCount(2); + expect($parsed->headers['x-trace'][0] ?? null)->toBe('one continued'); + expect($parsed->from->count())->toBe(1); + expect($parsed->to->count())->toBe(2); +}); + +it('preserves duplicate Authentication-Results headers', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Auth headers', + 'Authentication-Results: mx1.example.com; dkim=pass header.d=example.com', + 'Authentication-Results: mx2.example.com; spf=pass smtp.mailfrom=example.com', + '', + 'Body', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->headers['authentication-results'] ?? [])->toHaveCount(2); +}); + +it('parses nested multipart email and extracts attachments', function (): void { + $boundaryMixed = 'mixed-123'; + $boundaryAlt = 'alt-123'; + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Multipart', + sprintf('Content-Type: multipart/mixed; boundary="%s"', $boundaryMixed), + '', + sprintf('--%s', $boundaryMixed), + sprintf('Content-Type: multipart/alternative; boundary="%s"', $boundaryAlt), + '', + sprintf('--%s', $boundaryAlt), + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: quoted-printable', + '', + 'Hello=20plain', + sprintf('--%s', $boundaryAlt), + 'Content-Type: text/html; charset=UTF-8', + 'Content-Transfer-Encoding: quoted-printable', + '', + '

Hello html

', + sprintf('--%s--', $boundaryAlt), + sprintf('--%s', $boundaryMixed), + 'Content-Type: application/pdf; name="report.pdf"', + 'Content-Disposition: attachment; filename="report.pdf"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('PDF-CONTENT'), + sprintf('--%s--', $boundaryMixed), + '', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->textBody)->toBe('Hello plain'); + expect($parsed->htmlBody)->toContain('Hello html'); + expect($parsed->attachments)->toHaveCount(1); + expect($parsed->attachments[0]->filename)->toBe('report.pdf'); + expect($parsed->attachments[0]->contents())->toBe('PDF-CONTENT'); +}); + +it('assigns stable MIME part numbers for nested multipart structures', function (): void { + $boundaryMixed = 'mix-root'; + $boundaryRelated = 'rel-1'; + $boundaryAlt = 'alt-1'; + + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Part numbers', + sprintf('Content-Type: multipart/mixed; boundary="%s"', $boundaryMixed), + '', + sprintf('--%s', $boundaryMixed), + sprintf('Content-Type: multipart/related; boundary="%s"', $boundaryRelated), + '', + sprintf('--%s', $boundaryRelated), + sprintf('Content-Type: multipart/alternative; boundary="%s"', $boundaryAlt), + '', + sprintf('--%s', $boundaryAlt), + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Plain', + sprintf('--%s', $boundaryAlt), + 'Content-Type: text/html; charset=UTF-8', + '', + '

Html

', + sprintf('--%s--', $boundaryAlt), + sprintf('--%s', $boundaryRelated), + 'Content-Type: image/png; name="logo.png"', + 'Content-Disposition: inline; filename="logo.png"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('image'), + sprintf('--%s--', $boundaryRelated), + sprintf('--%s', $boundaryMixed), + 'Content-Type: application/pdf; name="report.pdf"', + 'Content-Disposition: attachment; filename="report.pdf"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('pdf'), + sprintf('--%s--', $boundaryMixed), + '', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + $partNumbers = []; + $collect = function (array $parts) use (&$collect, &$partNumbers): void { + foreach ($parts as $part) { + if ($part->filename !== null) { + $partNumbers[] = $part->partNumber; + } + if ($part->children !== []) { + $collect($part->children); + } + } + }; + $collect($parsed->parts); + + expect($partNumbers)->toContain('1.2'); + expect($partNumbers)->toContain('2'); +}); + +it('supports spool receiver peek and receiveMany with parser-backed output', function (): void { + $directory = getcwd().'/tests/.tmp-spool-parse-'.bin2hex(random_bytes(4)); + mkdir($directory, 0775, true); + + file_put_contents($directory.'/20260101_000001_a.eml', "From: sender@example.com\r\nTo: a@example.com\r\nSubject: A\r\n\r\nBody A"); + file_put_contents($directory.'/20260101_000002_b.eml', "From: sender@example.com\r\nTo: b@example.com\r\nSubject: B\r\n\r\nBody B"); + + $receiver = new SpoolEmailReceiver(new SpoolConfig($directory), deleteAfterRead: true); + + $peeked = $receiver->peek(); + expect($peeked?->subject)->toBe('A'); + + $emails = $receiver->receiveMany(2); + expect($emails)->toHaveCount(2); + expect($emails[0]->subject)->toBe('A'); + expect($emails[1]->subject)->toBe('B'); + expect(glob($directory.'/*.eml') ?: [])->toHaveCount(0); + + rmdir($directory); +}); + +it('moves unreadable spool files to failed directory', function (): void { + $directory = getcwd().'/tests/.tmp-spool-failed-'.bin2hex(random_bytes(4)); + $failed = $directory.'/failed'; + mkdir($directory, 0775, true); + + file_put_contents($directory.'/20260101_000001_bad.eml', ''); + + $receiver = new SpoolEmailReceiver( + new SpoolConfig($directory), + parser: new class implements EmailParser + { + public function parse(string $rawEmail, array $metadata = []): ParsedEmail + { + unset($rawEmail, $metadata); + + throw new RuntimeException('corrupt'); + } + }, + failedDirectory: $failed, + ); + + $email = $receiver->receiveParsed(); + + expect($email)->toBeNull(); + expect(glob($failed.'/*.eml') ?: [])->toHaveCount(1); + expect(glob($failed.'/*.error.txt') ?: [])->toHaveCount(1); + + foreach (glob($failed.'/*') ?: [] as $path) { + unlink($path); + } + rmdir($failed); + rmdir($directory); +}); + +it('decodes quoted printable body and converts charset to utf8', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Charset', + 'Content-Type: text/plain; charset=ISO-8859-1', + 'Content-Transfer-Encoding: quoted-printable', + '', + 'Caf=E9', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->textBody)->toBe('Café'); +}); + +it('parses filename continuation parameters for attachments', function (): void { + $boundary = 'mix-boundary'; + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Filename star', + sprintf('Content-Type: multipart/mixed; boundary="%s"', $boundary), + '', + sprintf('--%s', $boundary), + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Body', + sprintf('--%s', $boundary), + 'Content-Type: application/octet-stream', + "Content-Disposition: attachment; filename*0*=UTF-8''long-%E2%82%AC;", + ' filename*1*=report.pdf', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('bytes'), + sprintf('--%s--', $boundary), + '', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->attachments)->toHaveCount(1); + expect($parsed->attachments[0]->filename)->toBe('long-€report.pdf'); +}); + +it('parses complex address formats and ignores invalid entries', function (): void { + $raw = implode("\r\n", [ + 'From: "Last, First" ', + 'To: Group: a@example.com, b@example.com;, "=?UTF-8?B?Sm9zw6k=?=" , broken-address', + 'Subject: Addresses', + '', + 'Body', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->from->count())->toBe(1); + expect($parsed->to->count())->toBeGreaterThanOrEqual(2); + expect($parsed->to->all()[0]->email)->not->toBeNull(); +}); + +it('parses group, undisclosed recipients, and comments in address headers', function (): void { + $raw = implode("\r\n", [ + 'From: "Sender, Team" ', + 'To: undisclosed-recipients:;, Group: "A, User" , b@example.com; (comment)', + 'Cc: John Doe (Billing) ', + 'Subject: Address edge', + '', + 'Body', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->to->count())->toBeGreaterThanOrEqual(2); + expect(array_any( + $parsed->to->all(), + static fn ($addr): bool => $addr->email === 'a@example.com', + ))->toBeTrue(); + expect(array_any( + $parsed->to->all(), + static fn ($addr): bool => $addr->email === 'b@example.com', + ))->toBeTrue(); + expect($parsed->cc->first()?->email)->toBe('john@example.com'); +}); + +it('handles multipart preamble and epilogue safely', function (): void { + $boundary = 'safe-b'; + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Preamble', + sprintf('Content-Type: multipart/alternative; boundary="%s"', $boundary), + '', + 'This is preamble text', + sprintf('--%s', $boundary), + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Plain body', + sprintf('--%s', $boundary), + 'Content-Type: text/html; charset=UTF-8', + '', + '

Html body

', + sprintf('--%s--', $boundary), + 'This is epilogue text', + '', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->textBody)->toBe('Plain body'); + expect($parsed->htmlBody)->toContain('Html body'); +}); + +it('exposes parsed email convenience helpers', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Mail delivery failed', + 'In-Reply-To: ', + '', + 'Body', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + + expect($parsed->fromEmail())->toBe('sender@example.com'); + expect($parsed->subjectOrEmpty())->toBe('Mail delivery failed'); + expect($parsed->isReply())->toBeTrue(); + expect($parsed->isBounceCandidate())->toBeTrue(); + expect($parsed->headers('to'))->toHaveCount(1); +}); + +it('exposes received attachment helper methods', function (): void { + $boundary = 'mix-img'; + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Attach', + sprintf('Content-Type: multipart/mixed; boundary="%s"', $boundary), + '', + sprintf('--%s', $boundary), + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Body', + sprintf('--%s', $boundary), + 'Content-Type: image/png', + 'Content-Disposition: inline; filename="logo bad @2x.png"', + 'Content-ID: ', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('png-bytes'), + sprintf('--%s--', $boundary), + '', + ]); + + $parsed = (new RawEmailParser)->parse($raw); + $attachment = $parsed->firstAttachment(); + + expect($parsed->hasAttachments())->toBeTrue(); + expect($parsed->attachmentCount())->toBe(1); + expect($attachment)->not->toBeNull(); + expect($attachment?->extension())->toBe('png'); + expect($attachment?->isImage())->toBeTrue(); + expect($attachment?->isInline())->toBeTrue(); + expect($attachment?->safeFilename())->toBe('logo_bad__2x.png'); + expect($attachment?->contentHash())->toBe(hash('sha256', 'png-bytes')); +}); + +it('normalizes unsafe attachment filenames for filesystem use', function (): void { + $boundary = 'mix-safe-name'; + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Unsafe name', + sprintf('Content-Type: multipart/mixed; boundary="%s"', $boundary), + '', + sprintf('--%s', $boundary), + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Body', + sprintf('--%s', $boundary), + 'Content-Type: application/octet-stream', + 'Content-Disposition: attachment; filename="../etc/passwd"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('secret-bytes'), + sprintf('--%s--', $boundary), + ]); + + $parsed = (new RawEmailParser)->parse($raw); + $attachment = $parsed->firstAttachment(); + + expect($attachment)->not->toBeNull(); + expect($attachment?->safeFilename())->toBe('passwd'); +}); + +it('sanitizes unsafe attachment filenames with control and traversal characters', function (): void { + $attachment = new ReceivedAttachment( + filename: "../bad\r\n\0name.txt", + mimeType: 'application/octet-stream', + sizeBytes: 5, + contentId: null, + inline: false, + contentResolver: new InMemoryAttachmentContentResolver('bytes'), + ); + + expect($attachment->safeFilename())->toStartWith('bad'); + expect($attachment->safeFilename())->toEndWith('name.txt'); + expect($attachment->safeFilename())->not->toContain('/'); +}); + +it('supports processing directory flow and metadata on spool receive', function (): void { + $baseDirectory = getcwd().'/tests/.tmp-spool-processing-'.bin2hex(random_bytes(4)); + $processingDirectory = $baseDirectory.'/processing'; + $processedDirectory = $baseDirectory.'/processed'; + mkdir($baseDirectory, 0775, true); + + file_put_contents($baseDirectory.'/20260101_000001_a.eml', "From: sender@example.com\r\nTo: a@example.com\r\nSubject: A\r\n\r\nBody A"); + + $receiver = new SpoolEmailReceiver( + new SpoolConfig($baseDirectory, processingDirectory: $processingDirectory), + deleteAfterRead: false, + moveAfterRead: $processedDirectory, + ); + + $email = $receiver->receiveParsed(); + + expect($email)->not->toBeNull(); + expect($email?->metadata['source'] ?? null)->toBe('spool'); + expect($email?->metadata['original_path'] ?? null)->toContain('.eml'); + expect($email?->metadata['processing_path'] ?? null)->toContain('/processing/'); + expect(glob($baseDirectory.'/*.eml') ?: [])->toHaveCount(0); + expect(glob($processingDirectory.'/*.eml') ?: [])->toHaveCount(0); + expect(glob($processedDirectory.'/*.eml') ?: [])->toHaveCount(1); + + foreach (glob($processedDirectory.'/*') ?: [] as $path) { + unlink($path); + } + rmdir($processedDirectory); + rmdir($processingDirectory); + rmdir($baseDirectory); +}); + +it('supports strict and tolerant header parsing modes', function (): void { + $headerBlock = implode("\r\n", [ + ' malformed-leading-fold', + 'Subject: Hello', + 'X-Test Broken', + ]); + + $tolerant = (new HeaderParser(HeaderParser::MODE_TOLERANT))->parse($headerBlock); + + expect($tolerant->first('Subject'))->toBe('Hello'); + expect($tolerant->asMap()['x-invalid-header'] ?? [])->toHaveCount(2); + + expect(fn () => (new HeaderParser(HeaderParser::MODE_STRICT))->parse($headerBlock)) + ->toThrow(EmailParseException::class); +}); + +it('handles long and multi-encoded header values', function (): void { + $longSegment = str_repeat('x', 240); + $headerBlock = implode("\r\n", [ + 'Subject: =?UTF-8?Q?Hello?= plain =?UTF-8?Q?_World?=', + 'X-Long: '.$longSegment, + '', + ]); + + $parsed = (new HeaderParser)->parse($headerBlock); + + expect($parsed->first('subject'))->toBe('Hello plain World'); + expect($parsed->first('x-long'))->toBe($longSegment); +}); + +it('decodes mixed and fallback encoded words in header values', function (): void { + $headerBlock = "Subject: Hello =?UTF-8?Q?Jos=C3=A9?= =?UTF-8?Q?World?=\r\n"; + + $parsed = (new HeaderParser)->parse($headerBlock); + + expect($parsed->first('subject'))->toBe('Hello JoséWorld'); +}); + +it('assigns imap-compatible part numbers for mixed-related-alternative nesting', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: MIME map', + 'Content-Type: multipart/mixed; boundary="mix"', + '', + '--mix', + 'Content-Type: multipart/related; boundary="rel"', + '', + '--rel', + 'Content-Type: multipart/alternative; boundary="alt"', + '', + '--alt', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Plain body', + '--alt', + 'Content-Type: text/html; charset=UTF-8', + '', + '

HTML body

', + '--alt--', + '--rel', + 'Content-Type: image/png', + 'Content-Disposition: inline; filename="logo.png"', + 'Content-ID: ', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('logo'), + '--rel--', + '--mix', + 'Content-Type: application/pdf', + 'Content-Disposition: attachment; filename="report.pdf"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('pdf'), + '--mix--', + ]); + + $email = (new RawEmailParser)->parse($raw); + + expect($email->textBody)->toContain('Plain body'); + expect($email->htmlBody)->toContain('

HTML body

'); + expect($email->attachments)->toHaveCount(2); + expect($email->attachments[0]->filename)->toBe('logo.png'); + expect($email->attachments[1]->filename)->toBe('report.pdf'); + + $partNumbers = []; + $collect = function (?array $parts) use (&$collect, &$partNumbers): void { + if (! is_array($parts)) { + return; + } + + foreach ($parts as $part) { + if ($part->partNumber !== null) { + $partNumbers[] = $part->partNumber; + } + + if (is_array($part->children) && $part->children !== []) { + $collect($part->children); + } + } + }; + $collect($email->parts); + + expect($partNumbers)->toContain('1', '1.1', '1.1.1', '1.1.2', '1.2', '2'); +}); + +it('keeps attachment parts out of selected text and html body content', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Body selection', + 'Content-Type: multipart/mixed; boundary="mix"', + '', + '--mix', + 'Content-Type: multipart/alternative; boundary="alt"', + '', + '--alt', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Chosen plain text', + '--alt', + 'Content-Type: text/html; charset=UTF-8', + '', + '

Chosen html

', + '--alt--', + '--mix', + 'Content-Type: text/plain; charset=UTF-8; name="notes.txt"', + 'Content-Disposition: attachment; filename="notes.txt"', + '', + 'attachment plain content', + '--mix--', + ]); + + $email = (new RawEmailParser)->parse($raw); + + expect($email->textBody)->toBe('Chosen plain text'); + expect($email->htmlBody)->toBe('

Chosen html

'); + expect($email->attachments)->toHaveCount(1); + expect($email->attachments[0]->filename)->toBe('notes.txt'); +}); diff --git a/tests/GrpcGeneratedStubInvokerTest.php b/tests/GrpcGeneratedStubInvokerTest.php new file mode 100644 index 0000000..a60aa12 --- /dev/null +++ b/tests/GrpcGeneratedStubInvokerTest.php @@ -0,0 +1,220 @@ +> */ + public array $captures = []; + + public function Chat(array $metadata = [], array $options = []): object + { + $this->captures[] = ['method' => 'Chat', 'metadata' => $metadata, 'options' => $options]; + + return new class { + /** @var list */ + private array $written = []; + + public function closeWrite(): void {} + + public function getMetadata(): array + { + return ['x-header' => ['chat']]; + } + + public function getTrailingMetadata(): array + { + return ['x-trailer' => ['chat-end']]; + } + + public function read(): ?array + { + static $messages = [['reply' => 1], ['reply' => 2]]; + if ($messages === []) { + return null; + } + + return array_shift($messages); + } + + public function wait(): array + { + return [['done' => true], ['code' => 0]]; + } + + public function write(mixed $message): void + { + $this->written[] = $message; + } + }; + } + + public function Create(mixed $message, array $metadata = [], array $options = []): object + { + $this->captures[] = [ + 'method' => 'Create', + 'message' => $message, + 'metadata' => $metadata, + 'options' => $options, + ]; + + return new class { + public function getMetadata(): array + { + return ['x-header' => ['create']]; + } + + public function getTrailingMetadata(): array + { + return ['x-trailer' => ['create-end']]; + } + + public function wait(): array + { + return [['id' => 1001], ['code' => 0]]; + } + }; + } + + public function List(mixed $message, array $metadata = [], array $options = []): object + { + $this->captures[] = [ + 'method' => 'List', + 'message' => $message, + 'metadata' => $metadata, + 'options' => $options, + ]; + + return new class { + public function getMetadata(): array + { + return ['x-header' => ['list']]; + } + + public function getTrailingMetadata(): array + { + return ['x-trailer' => ['list-end']]; + } + + public function responses(): array + { + return [['row' => 1], ['row' => 2]]; + } + + public function wait(): array + { + return [null, ['code' => 0]]; + } + }; + } + + public function Upload(array $metadata = [], array $options = []): object + { + $this->captures[] = ['method' => 'Upload', 'metadata' => $metadata, 'options' => $options]; + + return new class { + public function getMetadata(): array + { + return ['x-header' => ['upload']]; + } + + public function getTrailingMetadata(): array + { + return ['x-trailer' => ['upload-end']]; + } + + public function wait(): array + { + return [['uploaded' => true], ['code' => 0]]; + } + + public function writesDone(): void {} + + public function write(mixed $message): void {} + }; + } + }; + + $adapter = new GeneratedStubGrpcInvoker($stub); + $client = GrpcClient::usingNativeStreaming($adapter, $adapter); + + $unary = $client->send(new GrpcRequest( + method: 'Orders/Create', + message: ['name' => 'A'], + headers: (new GrpcMetadata())->withValue('x-request-id', 'req-1'), + deadlineSeconds: 1.5, + )); + + $serverChunks = []; + $server = $client->serverStream( + new GrpcRequest('Orders/List', ['page' => 1], deadlineSeconds: 2.0), + static function (mixed $message) use (&$serverChunks): void { + $serverChunks[] = $message; + }, + ); + + $clientOnly = $client->clientStream( + method: 'Orders/Upload', + messages: [['id' => 1], ['id' => 2]], + headers: (new GrpcMetadata())->withValue('x-upload', 'yes'), + ); + + $bidiChunks = []; + $bidi = $client->bidiStream( + method: 'Orders/Chat', + messages: [['a' => 1], ['a' => 2]], + onMessage: static function (mixed $message) use (&$bidiChunks): void { + $bidiChunks[] = $message; + }, + ); + + expect($unary->successful)->toBeTrue() + ->and($unary->response)->toBeInstanceOf(GrpcResponse::class) + ->and($unary->response->message['id'])->toBe(1001) + ->and($server->successful)->toBeTrue() + ->and($serverChunks)->toHaveCount(2) + ->and($clientOnly->successful)->toBeTrue() + ->and($bidi->successful)->toBeTrue() + ->and($bidiChunks)->toHaveCount(2) + ->and($stub->captures[0]['options']['timeout'])->toBe(1_500_000); +}); + +it('uses method map when grpc method path differs from stub method name', function (): void { + $stub = new class { + /** @var list> */ + public array $captures = []; + + public function CreateOrder(mixed $message, array $metadata = [], array $options = []): object + { + $this->captures[] = [ + 'method' => 'CreateOrder', + 'message' => $message, + 'metadata' => $metadata, + 'options' => $options, + ]; + + return new class { + public function wait(): array + { + return [['ok' => true], ['code' => 0]]; + } + }; + } + }; + + $adapter = new GeneratedStubGrpcInvoker($stub, [ + '/orders.v1.OrderService/Create' => 'CreateOrder', + ]); + + $client = GrpcClient::usingNative($adapter); + $result = $client->send(new GrpcRequest('/orders.v1.OrderService/Create', ['id' => 7])); + + expect($result->successful)->toBeTrue() + ->and($result->response->message['ok'])->toBeTrue(); +}); diff --git a/tests/GrpcRetryPolicyTest.php b/tests/GrpcRetryPolicyTest.php new file mode 100644 index 0000000..5af8057 --- /dev/null +++ b/tests/GrpcRetryPolicyTest.php @@ -0,0 +1,69 @@ + $attempts]); + } + + return new GrpcResponse(GrpcStatus::Ok, ['attempt' => $attempts, 'message' => $request->message]); + })->withGrpcRetry(GrpcRetryPolicy::standard(attempts: 3, baseDelayMs: 0)); + + $result = $client->send(new GrpcRequest('Orders/Create', ['id' => 1])); + + expect($result->successful)->toBeTrue() + ->and($attempts)->toBe(3); +}); + +it('does not retry non-transient grpc statuses', function (): void { + $attempts = 0; + $client = GrpcClient::using(static function () use (&$attempts): GrpcResponse { + $attempts++; + + return new GrpcResponse(GrpcStatus::InvalidArgument, ['error' => 'bad']); + })->withGrpcRetry(GrpcRetryPolicy::standard(attempts: 3, baseDelayMs: 0)); + + $result = $client->send(new GrpcRequest('Orders/Create', ['id' => 1])); + + expect($result->successful)->toBeFalse() + ->and($result->statusCode)->toBe(GrpcStatus::InvalidArgument->value) + ->and($attempts)->toBe(1); +}); + +it('retries grpc transport failures produced by caller exceptions', function (): void { + $attempts = 0; + $client = GrpcClient::using(static function () use (&$attempts): GrpcResponse { + $attempts++; + if ($attempts < 2) { + throw new RuntimeException('temporary network failure'); + } + + return new GrpcResponse(GrpcStatus::Ok, ['ok' => true]); + })->withGrpcRetry(GrpcRetryPolicy::standard(attempts: 2, baseDelayMs: 0)); + + $result = $client->send(new GrpcRequest('Orders/Create', ['id' => 1])); + + expect($result->successful)->toBeTrue() + ->and($attempts)->toBe(2); +}); + +it('supports grpc retry delay cap and optional jitter', function (): void { + $capped = GrpcRetryPolicy::standard(attempts: 3, baseDelayMs: 100, maxDelayMs: 150); + expect($capped->delayMs(1))->toBe(100) + ->and($capped->delayMs(2))->toBe(150) + ->and($capped->delayMs(3))->toBe(150); + + $jittered = GrpcRetryPolicy::standard(attempts: 3, baseDelayMs: 100, maxDelayMs: 150, jitterRatio: 0.25); + $delay = $jittered->delayMs(1); + expect($delay)->toBeGreaterThanOrEqual(75)->toBeLessThanOrEqual(125); +}); diff --git a/tests/GrpcTestingUtilitiesTest.php b/tests/GrpcTestingUtilitiesTest.php new file mode 100644 index 0000000..e72778c --- /dev/null +++ b/tests/GrpcTestingUtilitiesTest.php @@ -0,0 +1,52 @@ +pushOk(['first' => true]) + ->pushStatus(GrpcStatus::Unavailable, ['second' => true]); + + $client = GrpcClient::using($fake); + + $first = $client->send(new GrpcRequest('Billing/Create', ['id' => 1])); + $second = $client->send(new GrpcRequest('Billing/Create', ['id' => 2])); + + $fake->assert()->assertCallCount(2); + $fake->assert()->assertCalledMethod('Billing/Create'); + + expect($first->successful)->toBeTrue() + ->and($second->successful)->toBeFalse() + ->and($second->statusCode)->toBe(GrpcStatus::Unavailable->value); +}); + +it('fails fake grpc caller when response queue is empty', function (): void { + $client = GrpcClient::using(new FakeGrpcCaller()); + $result = $client->send(new GrpcRequest('Billing/Create', ['id' => 1])); + + expect($result->successful)->toBeFalse() + ->and($result->error)->toContain('No fake gRPC response queued.'); +}); + +it('supports richer grpc fake caller assertions', function (): void { + $fake = (new FakeGrpcCaller())->pushOk(['ok' => true]); + $client = GrpcClient::using($fake); + + $client->send(new GrpcRequest( + 'Billing/Create', + ['id' => 7], + headers: (new GrpcMetadata())->withValue('x-request-id', 'req-7'), + )); + + $assert = $fake->assert(); + $assert->assertCalledWithMessage('Billing/Create', ['id' => 7]); + $assert->assertCalledWithMetadata('Billing/Create', 'X-Request-Id', 'req-7'); + expect($assert->firstRequest()?->method)->toBe('Billing/Create') + ->and($assert->lastRequest()?->method)->toBe('Billing/Create'); +}); diff --git a/tests/GrpcTransportTest.php b/tests/GrpcTransportTest.php new file mode 100644 index 0000000..e602c2c --- /dev/null +++ b/tests/GrpcTransportTest.php @@ -0,0 +1,299 @@ + $event, 'payload' => $payload]; + } + }); + + $client = GrpcClient::using( + static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Ok, ['echo' => $request->message]), + ); + + $result = $client->send(new GrpcRequest('Echo/Send', ['ping' => true])); + CommunicationEventBus::listen(null); + + expect($result->successful)->toBeTrue() + ->and($result->statusCode)->toBe(0) + ->and($events)->toHaveCount(2) + ->and($events[0]['event'])->toBe('grpc.request.start') + ->and($events[1]['event'])->toBe('grpc.request.finish'); +}); + +it('maps non-ok grpc response to failure result', function (): void { + $client = GrpcClient::using( + static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Unavailable, $request->message), + ); + + $result = $client->send(new GrpcRequest('Echo/Send', ['ping' => true])); + + expect($result->successful)->toBeFalse() + ->and($result->statusCode)->toBe(GrpcStatus::Unavailable->value) + ->and($result->error)->toContain('Unavailable') + ->and($result->metadata['grpc_status_name'] ?? null)->toBe('Unavailable') + ->and($result->metadata['grpc_error'] ?? null)->toBeInstanceOf(GrpcCallError::class); +}); + +it('maps grpc caller exceptions to failure results and failed event', function (): void { + $events = []; + CommunicationEventBus::listen(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'grpc.request.')) { + $events[] = ['event' => $event, 'payload' => $payload]; + } + }); + + $client = GrpcClient::using(static function (): GrpcResponse { + throw new RuntimeException('network down'); + }); + + $result = $client->send(new GrpcRequest('Echo/Send', ['ping' => true])); + CommunicationEventBus::listen(null); + + expect($result->successful)->toBeFalse() + ->and($result->error)->toContain('network down') + ->and($result->response)->toBeInstanceOf(GrpcCallError::class) + ->and($events[0]['event'])->toBe('grpc.request.start') + ->and($events[1]['event'])->toBe('grpc.request.failed'); +}); + +it('fails when grpc transport receives non-grpc payload', function (): void { + $transport = new GrpcTransport(static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Ok, $request->message)); + $result = $transport->send(new CommunicationRequest('grpc', ['invalid' => true])); + + expect($result->successful)->toBeFalse() + ->and($result->error)->toContain('expects GrpcRequest payload'); +}); + +it('validates grpc request method and deadline', function (): void { + expect(fn() => new GrpcRequest('', ['x' => 1]))->toThrow(InvalidArgumentException::class); + expect(fn() => new GrpcRequest("Svc/Call\nBad", ['x' => 1]))->toThrow(InvalidArgumentException::class); + expect(fn() => new GrpcRequest(' Service/Call ', ['x' => 1]))->toThrow(InvalidArgumentException::class); + expect(fn() => new GrpcRequest('Service', ['x' => 1]))->toThrow(InvalidArgumentException::class); + expect(fn() => new GrpcRequest('Service/', ['x' => 1]))->toThrow(InvalidArgumentException::class); + expect(fn() => new GrpcRequest('Svc/Call', ['x' => 1], deadlineSeconds: 0.0))->toThrow(InvalidArgumentException::class); + + $request = new GrpcRequest('/Orders.Service/Create', ['x' => 1], deadlineSeconds: 1.2); + expect($request->deadlineMicros())->toBe(1_200_000); + expect(GrpcDeadline::secondsToMicros(0.5))->toBe(500_000); +}); + +it('validates grpc metadata names and values and supports helper accessors', function (): void { + $metadata = (new GrpcMetadata()) + ->with('X-Request-Id', ['abc']) + ->withValue('x-request-id', 'def'); + + expect($metadata->values('x-request-id'))->toBe(['abc', 'def']) + ->and($metadata->values('X-REQUEST-ID'))->toBe(['abc', 'def']) + ->and($metadata->first('x-request-id'))->toBe('abc'); + + expect(fn() => new GrpcMetadata(['Bad Header' => ['x']]))->toThrow(InvalidArgumentException::class); + expect(fn() => new GrpcMetadata(['x-token' => ["bad\r\nvalue"]]))->toThrow(InvalidArgumentException::class); + expect(fn() => (new GrpcMetadata())->withValue('trace-bin', 'bytes'))->toThrow(InvalidArgumentException::class); + + $bin = (new GrpcMetadata()) + ->withBinaryValue('trace-bin', "\x01\x02") + ->withBinaryValue('trace-bin', "\x03"); + expect($bin->binaryValues('trace-bin'))->toHaveCount(2) + ->and($bin->firstBinary('trace-bin'))->toBe("\x01\x02") + ->and(fn() => $bin->values('trace-bin'))->toThrow(InvalidArgumentException::class) + ->and(fn() => $metadata->binaryValues('x-request-id'))->toThrow(InvalidArgumentException::class); +}); + +it('maps native grpc invoker contract and propagates deadline and metadata', function (): void { + $invoker = new class implements NativeGrpcInvoker { + /** @var array|null */ + public ?array $captured = null; + + public function invoke( + string $method, + mixed $message, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $this->captured = [ + 'method' => $method, + 'message' => $message, + 'headers' => $headers->headers, + 'deadline' => $deadlineSeconds, + ]; + + return new NativeGrpcResult( + statusCode: GrpcStatus::Ok->value, + message: ['ok' => true], + headers: new GrpcMetadata(['x-response-id' => ['res-1']]), + trailers: new GrpcMetadata(['x-trailer' => ['trail-1']]), + ); + } + }; + + $client = GrpcClient::usingNative($invoker); + $request = (new GrpcRequest( + method: 'Orders/Create', + message: ['id' => 1001], + headers: (new GrpcMetadata())->withValue('x-request-id', 'req-1'), + ))->withDeadlineSeconds(1.25); + + $result = $client->send($request); + + expect($result->successful)->toBeTrue() + ->and($invoker->captured)->toBeArray() + ->and($invoker->captured['method'])->toBe('Orders/Create') + ->and($invoker->captured['headers']['x-request-id'][0])->toBe('req-1') + ->and($invoker->captured['deadline'])->toBe(1.25) + ->and($result->response)->toBeInstanceOf(GrpcResponse::class) + ->and($result->response->trailers->first('x-trailer'))->toBe('trail-1'); +}); + +it('supports native grpc streaming for server/client/bidi calls', function (): void { + $invoker = new class implements NativeGrpcInvoker, NativeGrpcStreamingInvoker { + public function bidiStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $onMessage([ + 'method' => $method, + 'headers' => $headers->headers, + 'deadline' => $deadlineSeconds, + ]); + + foreach ($messages as $message) { + $onMessage(['echo' => $message]); + } + + return new NativeGrpcResult(GrpcStatus::Ok->value, ['done' => true]); + } + + public function clientStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $count = 0; + foreach ($messages as $_message) { + $count++; + } + + return new NativeGrpcResult(GrpcStatus::Ok->value, [ + 'received' => $count, + 'method' => $method, + 'has_header' => $headers->has('x-request-id'), + 'deadline' => $deadlineSeconds, + ]); + } + + public function invoke( + string $method, + mixed $message, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + return new NativeGrpcResult(GrpcStatus::Ok->value, [ + 'ok' => true, + 'method' => $method, + 'message' => $message, + 'headers' => $headers->headers, + 'deadline' => $deadlineSeconds, + ]); + } + + public function serverStream( + string $method, + mixed $message, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $onMessage([ + 'chunk' => 1, + 'method' => $method, + 'deadline' => $deadlineSeconds, + 'request' => $message, + ]); + $onMessage(['chunk' => 2, 'headers' => $headers->first('x-request-id')]); + + return new NativeGrpcResult(GrpcStatus::Ok->value); + } + }; + + $client = GrpcClient::usingNativeStreaming($invoker, $invoker); + + expect($client->supportsStreaming())->toBeTrue(); + + $serverChunks = []; + $serverResult = $client->serverStream( + (new GrpcRequest( + method: 'Orders/Stream', + message: ['cursor' => 1], + headers: (new GrpcMetadata())->withValue('x-request-id', 'req-stream'), + deadlineSeconds: 2.5, + )), + static function (mixed $chunk) use (&$serverChunks): void { + $serverChunks[] = $chunk; + }, + ); + + $clientResult = $client->clientStream( + method: 'Orders/Upload', + messages: [['id' => 1], ['id' => 2]], + ); + + $bidiChunks = []; + $bidiResult = $client->bidiStream( + method: 'Orders/Bidi', + messages: ['a', 'b'], + onMessage: static function (mixed $chunk) use (&$bidiChunks): void { + $bidiChunks[] = $chunk; + }, + ); + + expect($serverResult->successful)->toBeTrue() + ->and($clientResult->successful)->toBeTrue() + ->and($bidiResult->successful)->toBeTrue() + ->and($clientResult->response)->toBeInstanceOf(GrpcResponse::class) + ->and($clientResult->response->message['received'])->toBe(2) + ->and($bidiResult->response->message['done'])->toBeTrue() + ->and($serverChunks)->toHaveCount(2) + ->and($bidiChunks)->toHaveCount(3) + ->and($bidiChunks[0]['method'])->toBe('Orders/Bidi'); +}); + +it('fails streaming calls when native streaming invoker is not configured', function (): void { + $client = GrpcClient::using(static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Ok, $request->message)); + + $result = $client->clientStream('Orders/Upload', [['id' => 1]]); + + expect($client->supportsStreaming())->toBeFalse() + ->and($result->successful)->toBeFalse() + ->and($result->error)->toContain('streaming is unavailable'); +}); + +it('validates grpc stream request method and deadline', function (): void { + expect(fn() => new GrpcStreamRequest('', []))->toThrow(InvalidArgumentException::class); + expect(fn() => new GrpcStreamRequest('Orders/Stream', [], deadlineSeconds: 0.0))->toThrow(InvalidArgumentException::class); + + $request = new GrpcStreamRequest('Orders/Stream', [['id' => 1]], deadlineSeconds: 1.5); + expect($request->deadlineMicros())->toBe(1_500_000); +}); diff --git a/tests/HttpClientFakeTest.php b/tests/HttpClientFakeTest.php new file mode 100644 index 0000000..2075189 --- /dev/null +++ b/tests/HttpClientFakeTest.php @@ -0,0 +1,47 @@ +send(HttpRequest::get('https://api.example.test/users')); + $client->postJson('https://api.example.test/orders', ['id' => 1001]); + + $assert = $client->assert(); + $assert->assertRequestCount(2); + $assert->assertRequested('GET', 'https://api.example.test/users'); + $assert->assertRequestedWhere( + static fn (HttpRequest $request): bool => $request->method->value === 'POST' + && $request->headers->get('Accept') === 'application/json', + 'Expected a JSON POST request.', + ); + expect($assert->lastRequest())->not->toBeNull(); +}); + +it('supports queued fake responses', function (): void { + $fake = new FakeHttpTransport; + $fake->pushJson(['ok' => true], 200); + $fake->pushJson(['error' => true], 500); + + $client = HttpClient::fake($fake); + + $success = $client->get('https://api.example.test/success'); + $failure = $client->get('https://api.example.test/fail'); + + expect($success->successful)->toBeTrue(); + expect($success->response?->json())->toBe(['ok' => true]); + expect($failure->successful)->toBeFalse(); + expect($failure->response?->statusCode)->toBe(500); +}); + +it('throws when assertions are requested for non-fake client', function (): void { + $client = HttpClient::curl(); + + expect(fn () => $client->assert()) + ->toThrow(RuntimeException::class, 'HttpClient assertions are only available for fake transports.'); +}); diff --git a/tests/HttpConcurrentPoolTest.php b/tests/HttpConcurrentPoolTest.php new file mode 100644 index 0000000..6a033c9 --- /dev/null +++ b/tests/HttpConcurrentPoolTest.php @@ -0,0 +1,74 @@ + HttpRequest::get('https://example.com/users')->blockHosts(['example.com']), + 'orders' => HttpRequest::get('https://example.com/orders')->blockHosts(['example.com']), + ]; + + $pool = (new CurlMultiTransport)->sendMany($requests, maxConcurrency: 10, failFast: false); + + expect(array_keys($pool->all()))->toBe(['users', 'orders']); + expect($pool->get('users'))->toBeInstanceOf(CommunicationResult::class); + expect($pool->get('orders'))->toBeInstanceOf(CommunicationResult::class); +}); + +it('supports pool result helper methods', function (): void { + $pool = new PoolResult([ + 'ok' => CommunicationResult::success(statusCode: 200), + 'bad' => CommunicationResult::failure('boom', statusCode: 500), + ]); + + expect($pool->successfulCount())->toBe(1); + expect($pool->failedCount())->toBe(1); + expect(array_keys($pool->successful()))->toBe(['ok']); + expect(array_keys($pool->failed()))->toBe(['bad']); + expect($pool->firstError()?->error)->toBe('boom'); + expect($pool->get('missing'))->toBeNull(); +}); + +it('stops early when fail-fast is enabled in request pool', function (): void { + $poolClient = HttpClient::multi(maxConcurrency: 1)->failFast(); + $requests = [ + 'first' => HttpRequest::get('https://example.com/first')->blockHosts(['example.com']), + 'second' => HttpRequest::get('https://example.com/second')->blockHosts(['example.com']), + 'third' => HttpRequest::get('https://example.com/third')->blockHosts(['example.com']), + ]; + + $result = $poolClient->sendMany($requests); + + expect(array_keys($result->all()))->toBe(['first']); + expect($result->metadata['fail_fast'] ?? null)->toBeTrue(); +}); + +it('uses the same request configuration path in single and multi transports', function (): void { + $path = tempnam(sys_get_temp_dir(), 'tb-upload-'); + expect($path)->toBeString(); + file_put_contents($path, 'payload'); + + $request = HttpRequest::post('https://example.com/upload') + ->uploadFromFile($path) + ->raw('body'); + + $singleResult = (new CurlTransport)->sendRequest($request); + $multiResult = (new CurlMultiTransport)->sendMany(['x' => $request])->get('x'); + + if (is_file($path)) { + unlink($path); + } + + expect($singleResult->successful)->toBeFalse(); + expect($multiResult)->toBeInstanceOf(CommunicationResult::class); + expect($multiResult?->successful)->toBeFalse(); + expect($singleResult->error)->toContain('cannot combine uploadFromFile/uploadFromStream'); + expect($multiResult?->error)->toContain('cannot combine uploadFromFile/uploadFromStream'); +}); diff --git a/tests/HttpCookieJarTest.php b/tests/HttpCookieJarTest.php new file mode 100644 index 0000000..8bd1f5a --- /dev/null +++ b/tests/HttpCookieJarTest.php @@ -0,0 +1,77 @@ +pushJson(['ok' => true], 200, [ + 'Set-Cookie' => [ + 'session=abc123; Path=/; HttpOnly', + 'theme=dark; Path=/', + ], + ]) + ->pushJson(['ok' => true], 200); + + $client = HttpClient::fake($transport)->withCookieJar(new CookieJar); + + $client->get('https://api.example.test/login'); + $client->get('https://api.example.test/orders'); + + $sent = $transport->sentRequests(); + + expect($sent)->toHaveCount(2); + expect((string) $sent[1]->headers->get('Cookie'))->toContain('session=abc123'); + expect((string) $sent[1]->headers->get('Cookie'))->toContain('theme=dark'); +}); + +it('respects secure and path cookie constraints', function (): void { + $transport = (new FakeHttpTransport) + ->pushJson(['ok' => true], 200, [ + 'Set-Cookie' => [ + 'secure_cookie=1; Path=/secure; Secure', + 'public_cookie=1; Path=/', + ], + ]) + ->pushJson(['ok' => true], 200) + ->pushJson(['ok' => true], 200); + + $client = HttpClient::fake($transport)->withCookieJar(new CookieJar); + + $client->get('https://api.example.test/secure/login'); + $client->get('http://api.example.test/secure/orders'); + $client->get('https://api.example.test/public'); + + $sent = $transport->sentRequests(); + + expect((string) $sent[1]->headers->get('Cookie'))->toContain('public_cookie=1'); + expect((string) $sent[1]->headers->get('Cookie'))->not->toContain('secure_cookie=1'); + + expect((string) $sent[2]->headers->get('Cookie'))->toContain('public_cookie=1'); + expect((string) $sent[2]->headers->get('Cookie'))->not->toContain('secure_cookie=1'); +}); + +it('does not override explicit request cookies', function (): void { + $transport = (new FakeHttpTransport) + ->pushJson(['ok' => true], 200, [ + 'Set-Cookie' => 'session=jar-value; Path=/', + ]) + ->pushJson(['ok' => true], 200); + + $client = HttpClient::fake($transport)->withCookieJar(new CookieJar); + + $client->get('https://api.example.test/login'); + $client->send( + HttpRequest::get('https://api.example.test/orders') + ->header('Cookie', 'session=explicit-value'), + ); + + $sent = $transport->sentRequests(); + + expect((string) $sent[1]->headers->get('Cookie'))->toContain('session=explicit-value'); + expect((string) $sent[1]->headers->get('Cookie'))->not->toContain('session=jar-value'); +}); diff --git a/tests/HttpRequestTest.php b/tests/HttpRequestTest.php new file mode 100644 index 0000000..b0bdb00 --- /dev/null +++ b/tests/HttpRequestTest.php @@ -0,0 +1,120 @@ +query('page', 2) + ->json(['order_id' => 1001]) + ->withBearerToken('token123') + ->header('Accept', 'application/json'); + + $resolved = $request->applyAuthenticators(); + + expect($resolved->buildUrl())->toBe('https://example.com/orders?page=2'); + expect($resolved->headers->get('Authorization'))->toBe('Bearer token123'); + expect($resolved->headers->get('Accept'))->toBe('application/json'); +}); + +it('merges existing query string with request query params and normalizes booleans', function (): void { + $request = HttpRequest::get('https://example.com/orders?active=1&sort=asc') + ->queries([ + 'active' => false, + 'page' => 2, + 'debug' => true, + ]) + ->withoutQuery('sort'); + + expect($request->buildUrl())->toBe('https://example.com/orders?active=0&page=2&debug=1'); +}); + +it('builds http client from config defaults', function (): void { + $client = HttpClient::fromConfig(HttpClientConfig::fromArray([ + 'timeoutSeconds' => 15, + 'connectTimeoutSeconds' => 5, + 'followRedirects' => true, + 'maxRedirects' => 3, + 'defaultHeaders' => [ + 'X-App' => 'TalkingBytes', + ], + 'userAgent' => 'TalkingBytes/1.0', + ])); + + $request = HttpRequest::get('https://example.com'); + $method = new ReflectionMethod($client, 'applyDefaults'); + /** @var HttpRequest $defaulted */ + $defaulted = $method->invoke($client, $request); + + expect($defaulted->options->timeoutSeconds)->toBe(15); + expect($defaulted->options->connectTimeoutSeconds)->toBe(5); + expect($defaulted->options->followRedirects)->toBeTrue(); + expect($defaulted->options->maxRedirects)->toBe(3); + expect($defaulted->headers->get('X-App'))->toBe('TalkingBytes'); + expect($defaulted->options->userAgent)->toBe('TalkingBytes/1.0'); +}); + +it('validates http client config values', function (): void { + expect(fn () => HttpClientConfig::fromArray([ + 'timeoutSeconds' => 0, + ]))->toThrow(InvalidArgumentException::class, 'timeoutSeconds must be greater than 0'); + + expect(fn () => HttpClientConfig::fromArray([ + 'connectTimeoutSeconds' => -1, + ]))->toThrow(InvalidArgumentException::class, 'connectTimeoutSeconds must be greater than 0'); + + expect(fn () => HttpClientConfig::fromArray([ + 'maxRedirects' => -1, + ]))->toThrow(InvalidArgumentException::class, 'maxRedirects must be greater than or equal to 0'); + + expect(fn () => HttpClientConfig::fromArray([ + 'defaultHeaders' => ['Bad Header' => 'x'], + ]))->toThrow(InvalidArgumentException::class, 'Invalid HTTP header name'); +}); + +it('curl transport returns failure for invalid payload type', function (): void { + $transport = new CurlTransport; + + $result = $transport->send(new CommunicationRequest('http', ['bad' => 'payload'])); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('expects HttpRequest payload'); +}); + +it('keeps redirects disabled by default', function (): void { + $request = HttpRequest::get('https://example.com'); + + expect($request->options->followRedirects)->toBeFalse(); +}); + +it('validates curl options upfront', function (): void { + expect(fn () => new CurlOptions(timeoutSeconds: 0)) + ->toThrow(InvalidArgumentException::class, 'timeoutSeconds must be greater than 0'); + + expect(fn () => new CurlOptions(connectTimeoutSeconds: 0)) + ->toThrow(InvalidArgumentException::class, 'connectTimeoutSeconds must be greater than 0'); + + expect(fn () => new CurlOptions(maxRedirects: -1)) + ->toThrow(InvalidArgumentException::class, 'maxRedirects must be greater than or equal to 0'); + + expect(fn () => new CurlOptions(maxResponseBytes: 0)) + ->toThrow(InvalidArgumentException::class, 'maxResponseBytes must be greater than 0'); + + expect(fn () => new CurlOptions(proxy: '')) + ->toThrow(InvalidArgumentException::class, 'proxy must not be empty'); + + expect(fn () => new CurlOptions(proxy: '127.0.0.1:8080')) + ->toThrow(InvalidArgumentException::class, 'proxy must include a scheme'); + + expect(fn () => new CurlOptions(caBundle: '/path/that/does/not/exist.pem')) + ->toThrow(InvalidArgumentException::class, 'caBundle must point to a readable file'); + + expect(fn () => new CurlOptions(clientCertificate: '/missing-cert.pem')) + ->toThrow(InvalidArgumentException::class, 'clientCertificate must point to a readable file'); +}); diff --git a/tests/HttpResponseTest.php b/tests/HttpResponseTest.php new file mode 100644 index 0000000..3bd3c60 --- /dev/null +++ b/tests/HttpResponseTest.php @@ -0,0 +1,33 @@ +statusGroup())->toBe(2) + ->and($ok->isSuccessful())->toBeTrue() + ->and($ok->isClientError())->toBeFalse(); + + expect($redirect->statusGroup())->toBe(3) + ->and($redirect->isRedirection())->toBeTrue(); + + expect($clientError->statusGroup())->toBe(4) + ->and($clientError->isClientError())->toBeTrue(); + + expect($serverError->statusGroup())->toBe(5) + ->and($serverError->isServerError())->toBeTrue(); + + expect($informational->statusGroup())->toBe(1) + ->and($informational->isInformational())->toBeTrue(); + + expect($invalid->statusGroup())->toBeNull() + ->and($invalid->isSuccessful())->toBeFalse(); +}); diff --git a/tests/HttpRetryPolicyTest.php b/tests/HttpRetryPolicyTest.php new file mode 100644 index 0000000..14e3c76 --- /dev/null +++ b/tests/HttpRetryPolicyTest.php @@ -0,0 +1,66 @@ +toBe(5); + expect(RetryAfter::parseDelaySeconds('Wed, 21 Oct 2015 07:28:00 GMT', new DateTimeImmutable('Wed, 21 Oct 2015 07:27:55 GMT'))) + ->toBe(5); +}); + +it('uses retry-after header delay for retryable statuses', function (): void { + $policy = HttpRetryPolicy::standard(attempts: 3, baseDelayMs: 100, maxRetryAfterSeconds: 10); + $result = CommunicationResult::failure( + 'too many requests', + statusCode: 429, + response: new HttpResponse(429, '', ['Retry-After' => '7']), + ); + + expect($policy->shouldRetry(1, $result))->toBeTrue(); + expect($policy->delayMs(1))->toBe(7000); +}); + +it('caps retry-after delay and falls back to exponential backoff when invalid', function (): void { + $policy = HttpRetryPolicy::standard(attempts: 3, baseDelayMs: 200, maxRetryAfterSeconds: 3); + $capped = CommunicationResult::failure( + 'service unavailable', + statusCode: 503, + response: new HttpResponse(503, '', ['Retry-After' => '99']), + ); + $fallback = CommunicationResult::failure( + 'service unavailable', + statusCode: 503, + response: new HttpResponse(503, '', ['Retry-After' => 'not-a-value']), + ); + + expect($policy->shouldRetry(1, $capped))->toBeTrue(); + expect($policy->delayMs(1))->toBe(3000); + expect($policy->shouldRetry(2, $fallback))->toBeTrue(); + expect($policy->delayMs(2))->toBe(400); +}); + +it('does not retry non-retryable statuses', function (): void { + $policy = HttpRetryPolicy::standard(); + $result = CommunicationResult::failure('not found', statusCode: 404, response: new HttpResponse(404, '')); + + expect($policy->shouldRetry(1, $result))->toBeFalse(); +}); + +it('adds http retry middleware helper to client defaults', function (): void { + $client = HttpClient::curl()->withHttpRetry(); + $reflection = new ReflectionClass($client); + $middlewares = $reflection->getProperty('middlewares'); + $middlewares->setAccessible(true); + + /** @var list $resolved */ + $resolved = $middlewares->getValue($client); + expect($resolved)->toHaveCount(1); + expect($resolved[0])->toBeInstanceOf(RetryMiddleware::class); +}); diff --git a/tests/HttpSecurityAndEventsTest.php b/tests/HttpSecurityAndEventsTest.php new file mode 100644 index 0000000..c81d9aa --- /dev/null +++ b/tests/HttpSecurityAndEventsTest.php @@ -0,0 +1,138 @@ + 'Bearer super-secret', + 'authorization' => 'Bearer lower', + 'X-API-Key' => 'top-secret', + 'X-Auth-Token' => 'secret-token', + 'Set-Cookie' => 'session=abc', + 'Accept' => 'application/json', + ]); + + expect($redactedHeaders['Authorization'])->toBe('[REDACTED]'); + expect($redactedHeaders['authorization'])->toBe('[REDACTED]'); + expect($redactedHeaders['X-API-Key'])->toBe('[REDACTED]'); + expect($redactedHeaders['X-Auth-Token'])->toBe('[REDACTED]'); + expect($redactedHeaders['Set-Cookie'])->toBe('[REDACTED]'); + expect($redactedHeaders['Accept'])->toBe('application/json'); + + $url = HttpRedactor::redactUrl('https://api.example.test/orders?api_key=abc&token=xyz&client_secret=s3cr3t&signature=sig&refresh_token=rt&page=2'); + expect($url)->toContain('api_key=%5BREDACTED%5D'); + expect($url)->toContain('token=%5BREDACTED%5D'); + expect($url)->toContain('client_secret=%5BREDACTED%5D'); + expect($url)->toContain('signature=%5BREDACTED%5D'); + expect($url)->toContain('refresh_token=%5BREDACTED%5D'); + expect($url)->toContain('page=2'); +}); + +it('enforces host allow and block lists before sending request', function (): void { + $transport = new CurlTransport; + + $blocked = $transport->sendRequest( + HttpRequest::get('https://example.com')->blockHosts(['example.com']), + ); + expect($blocked->successful)->toBeFalse(); + expect($blocked->error)->toContain('host is blocked'); + + $notAllowed = $transport->sendRequest( + HttpRequest::get('https://example.com')->allowHosts(['api.example.com']), + ); + expect($notAllowed->successful)->toBeFalse(); + expect($notAllowed->error)->toContain('host is not allowed'); +}); + +it('blocks private networks when configured', function (): void { + $transport = new CurlTransport; + + $result = $transport->sendRequest( + HttpRequest::get('http://127.0.0.1')->blockPrivateNetworks(), + ); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('private or reserved'); +}); + +it('blocks additional reserved host ranges when private network blocking is enabled', function (): void { + $reservedUrls = [ + 'http://0.0.0.0', + 'http://10.10.10.10', + 'http://172.16.5.4', + 'http://192.168.1.5', + 'http://169.254.1.20', + 'http://[::1]', + 'http://[fc00::1]', + 'http://[fe80::1]', + ]; + + foreach ($reservedUrls as $url) { + expect(fn () => RequestSecurityGuard::assertAllowed(HttpRequest::get($url)->blockPrivateNetworks())) + ->toThrow(InvalidArgumentException::class, 'private or reserved'); + } +}); + +it('applies security guard checks to redirect destinations as well', function (): void { + $request = HttpRequest::get('https://example.com/redirect') + ->blockPrivateNetworks(); + + expect( + fn () => RequestSecurityGuard::assertAllowed($request, 'http://127.0.0.1/internal'), + )->toThrow(InvalidArgumentException::class, 'private or reserved'); +}); + +it('dispatches http pool lifecycle events', function (): void { + $events = []; + CommunicationEventBus::listen(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'http.pool.')) { + $events[] = ['event' => $event, 'payload' => $payload]; + } + }); + + $pool = new CurlMultiTransport; + $result = $pool->sendMany([], 5); + + CommunicationEventBus::listen(null); + + expect($result->results)->toBe([]); + expect($events[0]['event'] ?? null)->toBe('http.pool.start'); + expect($events[1]['event'] ?? null)->toBe('http.pool.finish'); + expect($events[0]['payload']['max_concurrency'] ?? null)->toBe(5); +}); + +it('dispatches http request start and failure events for curl transport', function (): void { + $events = []; + CommunicationEventBus::listen(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'http.request.')) { + $events[] = ['event' => $event, 'payload' => $payload]; + } + }); + + $request = HttpRequest::get('http://127.0.0.1:1?token=secret&client_secret=very-secret') + ->headers([ + 'Authorization' => 'Bearer abc', + 'set-cookie' => 'session=abc', + 'Accept' => 'application/json', + ]) + ->timeout(1) + ->connectTimeout(1); + + $result = (new CurlTransport)->sendRequest($request); + CommunicationEventBus::listen(null); + + expect($result->successful)->toBeFalse(); + expect($events[0]['event'] ?? null)->toBe('http.request.start'); + expect($events[1]['event'] ?? null)->toBe('http.request.failed'); + expect($events[0]['payload']['url'] ?? null)->toContain('token=%5BREDACTED%5D'); + expect($events[0]['payload']['url'] ?? null)->toContain('client_secret=%5BREDACTED%5D'); + expect($events[0]['payload']['headers']['Authorization'] ?? null)->toBe('[REDACTED]'); + expect($events[0]['payload']['headers']['Set-Cookie'] ?? null)->toBe('[REDACTED]'); +}); diff --git a/tests/HttpSigningTest.php b/tests/HttpSigningTest.php new file mode 100644 index 0000000..f149465 --- /dev/null +++ b/tests/HttpSigningTest.php @@ -0,0 +1,100 @@ +json(['order_id' => 1001]) + ->withAuthenticator(new SignedRequestAuth( + $signer, + static fn (): int => 1_700_000_000, + static fn (): string => 'nonce-123', + )); + + $resolved = $request->applyAuthenticators(); + $payloadHash = hash('sha256', '{"order_id":1001}'); + $canonical = implode("\n", [ + 'POST', + '/orders?debug=1', + '1700000000', + 'nonce-123', + $payloadHash, + ]); + + expect($resolved->headers->get('X-TB-Timestamp'))->toBe('1700000000'); + expect($resolved->headers->get('X-TB-Nonce'))->toBe('nonce-123'); + expect($resolved->headers->get('X-TB-Signature'))->toBe($signer->sign($canonical)); +}); + +it('applies signing authenticator from http client helper', function (): void { + $client = HttpClient::curl()->withSigner(new HmacSha256Signer('secret-key')); + $method = new ReflectionMethod($client, 'applyDefaults'); + /** @var HttpRequest $request */ + $request = $method->invoke($client, HttpRequest::get('https://api.example.com/orders')); + $resolved = $request->applyAuthenticators(); + + expect($resolved->headers->get('X-TB-Timestamp'))->toBeString(); + expect($resolved->headers->get('X-TB-Nonce'))->toBeString(); + expect($resolved->headers->get('X-TB-Signature'))->toBeString(); +}); + +it('hashes raw and form payloads and marks multipart as unsigned payload', function (): void { + $signer = new HmacSha256Signer('secret-key'); + $auth = new SignedRequestAuth( + $signer, + static fn (): int => 1_700_000_000, + static fn (): string => 'nonce-abc', + ); + + $rawRequest = HttpRequest::post('https://api.example.com/raw') + ->raw('', 'application/xml') + ->withAuthenticator($auth) + ->applyAuthenticators(); + $rawCanonical = implode("\n", [ + 'POST', + '/raw', + '1700000000', + 'nonce-abc', + hash('sha256', ''), + ]); + expect($rawRequest->headers->get('X-TB-Signature'))->toBe($signer->sign($rawCanonical)); + + $formRequest = HttpRequest::post('https://api.example.com/form') + ->form(['a' => '1', 'b' => 'two']) + ->withAuthenticator($auth) + ->applyAuthenticators(); + $formCanonical = implode("\n", [ + 'POST', + '/form', + '1700000000', + 'nonce-abc', + hash('sha256', 'a=1&b=two'), + ]); + expect($formRequest->headers->get('X-TB-Signature'))->toBe($signer->sign($formCanonical)); + + $multipartRequest = HttpRequest::post('https://api.example.com/files') + ->multipart(HttpClient::multipart()->addField('name', 'report')) + ->withAuthenticator($auth) + ->applyAuthenticators(); + $multipartCanonical = implode("\n", [ + 'POST', + '/files', + '1700000000', + 'nonce-abc', + 'UNSIGNED-PAYLOAD', + ]); + expect($multipartRequest->headers->get('X-TB-Signature'))->toBe($signer->sign($multipartCanonical)); +}); + +it('validates custom signature header names', function (): void { + expect(fn () => new SignedRequestAuth( + new HmacSha256Signer('secret-key'), + signatureHeader: 'Bad Header', + ))->toThrow(InvalidArgumentException::class, 'Invalid HTTP header name'); +}); diff --git a/tests/HttpStreamingTest.php b/tests/HttpStreamingTest.php new file mode 100644 index 0000000..5d20f5e --- /dev/null +++ b/tests/HttpStreamingTest.php @@ -0,0 +1,189 @@ +streamDownloadTo($target); + $collector = new ResponseBodyCollector($request); + + expect($collector->collect('hello '))->toBe(6); + expect($collector->collect('world'))->toBe(5); + expect($collector->finalize())->toBeNull(); + expect($collector->responseBody())->toBe(''); + expect(file_get_contents($target))->toBe('hello world'); + + if (is_file($target)) { + unlink($target); + } +}); + +it('enforces max download bytes during streamed download collection', function (): void { + $target = sys_get_temp_dir().'/tb-http-stream-'.bin2hex(random_bytes(6)).'.txt'; + $request = HttpRequest::get('https://example.com') + ->streamDownloadTo($target) + ->maxDownloadBytes(3); + $collector = new ResponseBodyCollector($request); + + expect($collector->collect('abcd'))->toBe(0); + expect($collector->error())->toContain('max allowed bytes'); + expect($collector->finalize())->toContain('max allowed bytes'); + expect(is_file($target))->toBeFalse(); +}); + +it('allows streamed download when size is exactly the configured max', function (): void { + $target = sys_get_temp_dir().'/tb-http-stream-'.bin2hex(random_bytes(6)).'.txt'; + $request = HttpRequest::get('https://example.com') + ->streamDownloadTo($target) + ->maxDownloadBytes(11); + $collector = new ResponseBodyCollector($request); + + expect($collector->collect('hello world'))->toBe(11); + expect($collector->finalize())->toBeNull(); + expect(file_get_contents($target))->toBe('hello world'); + + if (is_file($target)) { + unlink($target); + } +}); + +it('enforces max response bytes for non-streaming collection', function (): void { + $request = HttpRequest::get('https://example.com')->maxResponseBytes(5); + $collector = new ResponseBodyCollector($request); + + expect($collector->collect('hello'))->toBe(5); + expect($collector->collect('!'))->toBe(0); + expect($collector->error())->toContain('max allowed bytes (5)'); +}); + +it('configures upload from file and stream sources', function (): void { + $path = tempnam(sys_get_temp_dir(), 'tb-upload-'); + expect($path)->toBeString(); + file_put_contents($path, 'payload'); + + $handle = curl_init(); + expect($handle)->toBeInstanceOf(CurlHandle::class); + + $request = HttpRequest::put('https://example.com/upload')->uploadFromFile($path); + $resolved = (new CurlHandleConfigurator)->configure( + $handle, + $request, + new ResponseHeaderCollector, + new ResponseBodyCollector($request), + ); + + expect($resolved->metadata['_upload_opened_by_configurator'] ?? null)->toBeTrue(); + expect(is_resource($resolved->metadata['_upload_handle'] ?? null))->toBeTrue(); + + if (is_resource($resolved->metadata['_upload_handle'])) { + fclose($resolved->metadata['_upload_handle']); + } + curl_close($handle); + if (is_file($path)) { + unlink($path); + } + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'stream-payload'); + fseek($stream, 5); + + $streamHandle = curl_init(); + expect($streamHandle)->toBeInstanceOf(CurlHandle::class); + + $streamRequest = HttpRequest::put('https://example.com/upload')->uploadFromStream($stream, 14); + $resolvedStream = (new CurlHandleConfigurator)->configure( + $streamHandle, + $streamRequest, + new ResponseHeaderCollector, + new ResponseBodyCollector($streamRequest), + ); + + expect($resolvedStream->metadata['_upload_opened_by_configurator'] ?? null)->toBeFalse(); + expect($resolvedStream->metadata['_upload_handle'] ?? null)->toBe($stream); + expect(ftell($stream))->toBe(0); + + curl_close($streamHandle); + fclose($stream); +}); + +it('rejects combining upload source with regular request body', function (): void { + $path = tempnam(sys_get_temp_dir(), 'tb-upload-'); + expect($path)->toBeString(); + file_put_contents($path, 'payload'); + + $request = HttpRequest::post('https://example.com/upload') + ->uploadFromFile($path) + ->raw('body'); + + $handle = curl_init(); + expect($handle)->toBeInstanceOf(CurlHandle::class); + + expect( + fn (): HttpRequest => (new CurlHandleConfigurator)->configure( + $handle, + $request, + new ResponseHeaderCollector, + new ResponseBodyCollector($request), + ), + )->toThrow(InvalidArgumentException::class, 'cannot combine uploadFromFile/uploadFromStream'); + + curl_close($handle); + if (is_file($path)) { + unlink($path); + } +}); + +it('enforces max upload bytes for file and stream uploads', function (): void { + $path = tempnam(sys_get_temp_dir(), 'tb-upload-'); + expect($path)->toBeString(); + file_put_contents($path, str_repeat('a', 8)); + + $handle = curl_init(); + expect($handle)->toBeInstanceOf(CurlHandle::class); + + $request = HttpRequest::put('https://example.com/upload') + ->uploadFromFile($path) + ->maxUploadBytes(4); + + expect( + fn (): HttpRequest => (new CurlHandleConfigurator)->configure( + $handle, + $request, + new ResponseHeaderCollector, + new ResponseBodyCollector($request), + ), + )->toThrow(InvalidArgumentException::class, 'max upload bytes'); + + curl_close($handle); + if (is_file($path)) { + unlink($path); + } + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'stream-data'); + rewind($stream); + + $streamHandle = curl_init(); + expect($streamHandle)->toBeInstanceOf(CurlHandle::class); + + $streamRequest = HttpRequest::put('https://example.com/upload') + ->uploadFromStream($stream, 10) + ->maxUploadBytes(4); + + expect( + fn (): HttpRequest => (new CurlHandleConfigurator)->configure( + $streamHandle, + $streamRequest, + new ResponseHeaderCollector, + new ResponseBodyCollector($streamRequest), + ), + )->toThrow(InvalidArgumentException::class, 'max upload bytes'); + + curl_close($streamHandle); + fclose($stream); +}); diff --git a/tests/HttpTestingTransportsTest.php b/tests/HttpTestingTransportsTest.php new file mode 100644 index 0000000..4aa2939 --- /dev/null +++ b/tests/HttpTestingTransportsTest.php @@ -0,0 +1,39 @@ +get('https://api.example.test/one'); + $second = $client->get('https://api.example.test/two'); + + expect($first->successful)->toBeTrue(); + expect($second->successful)->toBeFalse(); + expect($transport->sentRequests())->toHaveCount(2); +}); + +it('supports spy http transport request recording with delegated send', function (): void { + $sequence = new SequenceHttpTransport([ + CommunicationResult::success(200, new HttpResponse(200, '{"ok":true}')), + ]); + $spy = new SpyHttpTransport($sequence); + + $client = HttpClient::using($spy); + $result = $client->postRaw('https://api.example.test/logs', 'hello', 'text/plain'); + + expect($result->successful)->toBeTrue(); + expect($spy->sentRequests())->toHaveCount(1); + expect($spy->sentRequests()[0]->buildUrl())->toBe('https://api.example.test/logs'); +}); diff --git a/tests/ImapResponseParserTest.php b/tests/ImapResponseParserTest.php new file mode 100644 index 0000000..fdef36b --- /dev/null +++ b/tests/ImapResponseParserTest.php @@ -0,0 +1,216 @@ +folderDetails($response); + + expect($details)->toHaveCount(2); + expect($details[0]->path)->toBe('INBOX'); + expect($details[0]->delimiter)->toBe('/'); + expect($details[1]->path)->toBe('Projects/日本語'); + expect($details[1]->delimiter)->toBeNull(); + expect($details[1]->hasChildren())->toBeTrue(); +}); + +it('parses imap list attributes, escaped quotes, and folder names with spaces', function (): void { + $response = new ImapResponse( + tag: 'A0010', + status: 'OK', + lines: [ + '* LIST (\\Noselect \\HasChildren) "/" "Projects"', + '* LIST (\\NoInferiors \\HasNoChildren) "/" "Client \\"A\\" Reports"', + '* LIST (\\HasNoChildren) NIL "Archive 2026"', + ], + literals: [], + ); + + $parser = new ImapResponseParser; + $details = $parser->folderDetails($response); + + expect($details)->toHaveCount(3); + expect($details[0]->isSelectable())->toBeFalse(); + expect($details[1]->noInferiors())->toBeTrue(); + expect($details[1]->path)->toBe('Client "A" Reports'); + expect($details[2]->path)->toBe('Archive 2026'); + expect($details[2]->delimiter)->toBeNull(); +}); + +it('parses mailbox status including uid validity and uid next', function (): void { + $response = new ImapResponse( + tag: 'A0002', + status: 'OK', + lines: ['* STATUS "INBOX" (MESSAGES 5 RECENT 1 UNSEEN 3 UIDVALIDITY 42 UIDNEXT 99)'], + literals: [], + ); + + $parser = new ImapResponseParser; + $status = $parser->status($response); + + expect($status->messages)->toBe(5); + expect($status->recent)->toBe(1); + expect($status->unseen)->toBe(3); + expect($status->uidValidity)->toBe(42); + expect($status->uidNext)->toBe(99); +}); + +it('tolerates missing and unknown fields in mailbox status response', function (): void { + $response = new ImapResponse( + tag: 'A0002', + status: 'OK', + lines: ['* STATUS "INBOX" (MESSAGES 0 UNKNOWN 9 UIDVALIDITY 7)'], + literals: [], + ); + + $status = (new ImapResponseParser)->status($response); + + expect($status->messages)->toBe(0); + expect($status->recent)->toBe(0); + expect($status->unseen)->toBe(0); + expect($status->uidValidity)->toBe(7); + expect($status->uidNext)->toBeNull(); +}); + +it('parses envelope summary fields from fetch response', function (): void { + $response = new ImapResponse( + tag: 'A0003', + status: 'OK', + lines: [ + '* 1 FETCH (UID 10 FLAGS (\\Seen \\Flagged) ENVELOPE ("Fri, 24 May 2026 12:00:00 +0000" "Envelope Subject" (("Sender Name" NIL "sender" "example.com")) NIL NIL NIL NIL NIL NIL ""))', + ], + literals: [], + ); + + $parser = new ImapResponseParser; + $summary = $parser->parseEnvelopeSummary($response, 10); + + expect($summary->uid)->toBe(10); + expect($summary->subject)->toBe('Envelope Subject'); + expect($summary->from)->toBe('Sender Name '); + expect($summary->messageId)->toBe(''); + expect($summary->date?->format('Y-m-d'))->toBe('2026-05-29'); + expect($summary->sequence)->toBe(1); + expect($summary->flags)->toBe(['\\Seen', '\\Flagged']); +}); + +it('parses envelope summary tolerantly with nil and invalid fields', function (): void { + $response = new ImapResponse( + tag: 'A0003', + status: 'OK', + lines: [ + '* 2 FETCH (UID 77 ENVELOPE (NIL NIL NIL NIL NIL NIL NIL NIL NIL NIL) FLAGS ())', + ], + literals: [], + ); + + $summary = (new ImapResponseParser)->parseEnvelopeSummary($response, 77); + + expect($summary->uid)->toBe(77); + expect($summary->subject)->toBeNull(); + expect($summary->from)->toBeNull(); + expect($summary->messageId)->toBeNull(); +}); + +it('parses envelope summary with multiple from addresses and invalid date safely', function (): void { + $response = new ImapResponse( + tag: 'A0007', + status: 'OK', + lines: [ + '* 3 FETCH (UID 90 ENVELOPE ("invalid-date" "=?UTF-8?B?U3ViamVjdA==?=" (("Primary Sender" NIL "primary" "example.com")("Second Sender" NIL "second" "example.com")) NIL NIL NIL NIL NIL NIL NIL) FLAGS (\\Seen))', + ], + literals: [], + ); + + $summary = (new ImapResponseParser)->parseEnvelopeSummary($response, 90); + + expect($summary->uid)->toBe(90); + expect($summary->subject)->toBe('=?UTF-8?B?U3ViamVjdA==?='); + expect($summary->from)->toContain('primary@example.com'); + expect($summary->date)->toBeNull(); + expect($summary->flags)->toBe(['\\Seen']); +}); + +it('maps fetch raw message from first fetch-associated literal with additional literals present', function (): void { + $response = new ImapResponse( + tag: 'A0004', + status: 'OK', + lines: [ + '* 1 FETCH (BODY.PEEK[HEADER] {17}', + ' BODY[TEXT] {5}', + ')', + 'A0004 OK done', + ], + literals: [ + "Subject: A\r\n\r\n", + 'HELLO', + ], + ); + + $parser = new ImapResponseParser; + $raw = $parser->fetchRawMessage($response); + + expect($raw)->toBe("Subject: A\r\n\r\n"); +}); + +it('resolves section-specific literals from multi-literal fetch responses', function (): void { + $response = new ImapResponse( + tag: 'A0005', + status: 'OK', + lines: [ + '* 1 FETCH (BODY.PEEK[HEADER] {17}', + ' BODY.PEEK[TEXT] {5}', + ' BODY.PEEK[1.2] {4}', + ')', + 'A0005 OK done', + ], + literals: [ + "Subject: A\r\n\r\n", + 'HELLO', + 'QQ==', + ], + ); + + $parser = new ImapResponseParser; + + expect($parser->fetchSectionLiteral($response, 'BODY.PEEK[HEADER]'))->toBe("Subject: A\r\n\r\n"); + expect($parser->fetchSectionLiteral($response, 'BODY.PEEK[TEXT]'))->toBe('HELLO'); + expect($parser->fetchSectionLiteral($response, 'BODY.PEEK[1.2]'))->toBe('QQ=='); +}); + +it('prefers RFC822 literal for raw message extraction when multiple literals are present', function (): void { + $response = new ImapResponse( + tag: 'A0006', + status: 'OK', + lines: [ + '* 1 FETCH (BODY.PEEK[HEADER] {17}', + ' RFC822 {11}', + ')', + 'A0006 OK done', + ], + literals: [ + "Subject: A\r\n\r\n", + "Body\r\nLine\r\n", + ], + ); + + $parser = new ImapResponseParser; + + expect($parser->fetchRawMessage($response))->toBe("Body\r\nLine\r\n"); +}); diff --git a/tests/ImapTransportLiteralReadTest.php b/tests/ImapTransportLiteralReadTest.php new file mode 100644 index 0000000..9d577f2 --- /dev/null +++ b/tests/ImapTransportLiteralReadTest.php @@ -0,0 +1,191 @@ +setValue($transport, $stream); + + return $stream; +} + +/** + * @param resource $stream + */ +function teardownImapTransportConnection(ImapSocketTransport $transport, mixed $stream): void +{ + if (is_resource($stream)) { + fclose($stream); + } + + $property = new ReflectionProperty($transport, 'connection'); + $property->setValue($transport, null); +} + +function invokeReadTaggedResponse(ImapSocketTransport $transport, string $tag): ImapResponse +{ + $method = new ReflectionMethod($transport, 'readTaggedResponse'); + + /** @var ImapResponse */ + return $method->invoke($transport, $tag); +} + +it('reads multiple imap literals from a single tagged response', function (): void { + $headerLiteral = "Subject: A\r\n\r\n"; + $bodyLiteral = 'HELLO'; + + $wire = implode('', [ + '* 1 FETCH (BODY[HEADER] {'.strlen($headerLiteral)."}\r\n", + $headerLiteral, + ' BODY[TEXT] {'.strlen($bodyLiteral)."}\r\n", + $bodyLiteral, + ")\r\n", + "A0001 OK done\r\n", + ]); + + $transport = new ImapSocketTransport(new ImapConfig( + host: 'imap.example.com', + port: 143, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + timeoutSeconds: 1, + )); + + $stream = setImapTransportConnection($transport, $wire); + $response = invokeReadTaggedResponse($transport, 'A0001'); + teardownImapTransportConnection($transport, $stream); + + expect($response->status)->toBe('OK'); + expect($response->literals)->toHaveCount(2); + expect($response->literals[0])->toBe($headerLiteral); + expect($response->literals[1])->toBe($bodyLiteral); +}); + +it('supports empty imap literal bodies', function (): void { + $wire = implode('', [ + "* 1 FETCH (BODY[] {0}\r\n", + ")\r\n", + "A0001 OK done\r\n", + ]); + + $transport = new ImapSocketTransport(new ImapConfig( + host: 'imap.example.com', + port: 143, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + timeoutSeconds: 1, + )); + + $stream = setImapTransportConnection($transport, $wire); + $response = invokeReadTaggedResponse($transport, 'A0001'); + teardownImapTransportConnection($transport, $stream); + + expect($response->literals)->toHaveCount(1); + expect($response->literals[0])->toBe(''); +}); + +it('fails when imap server closes during literal read', function (): void { + $wire = implode('', [ + "* 1 FETCH (RFC822 {10}\r\n", + 'short', + ]); + + $transport = new ImapSocketTransport(new ImapConfig( + host: 'imap.example.com', + port: 143, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + timeoutSeconds: 1, + )); + + $stream = setImapTransportConnection($transport, $wire); + + expect(fn () => invokeReadTaggedResponse($transport, 'A0001')) + ->toThrow(MailboxProtocolException::class, 'expected 10 bytes, received 5 bytes'); + + teardownImapTransportConnection($transport, $stream); +}); + +it('reads literals across multiple FETCH lines in one tagged response', function (): void { + $firstLiteral = "Subject: A\r\n\r\n"; + $secondLiteral = 'Body-A'; + $thirdLiteral = 'Body-B'; + + $wire = implode('', [ + '* 1 FETCH (BODY.PEEK[HEADER] {'.strlen($firstLiteral)."}\r\n", + $firstLiteral, + ' BODY.PEEK[TEXT] {'.strlen($secondLiteral)."}\r\n", + $secondLiteral, + "\r\n)\r\n", + '* 2 FETCH (BODY.PEEK[1.2] {'.strlen($thirdLiteral)."}\r\n", + $thirdLiteral, + "\r\n) trailing\r\n", + "A0001 OK done\r\n", + ]); + + $transport = new ImapSocketTransport(new ImapConfig( + host: 'imap.example.com', + port: 143, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + timeoutSeconds: 1, + )); + + $stream = setImapTransportConnection($transport, $wire); + $response = invokeReadTaggedResponse($transport, 'A0001'); + teardownImapTransportConnection($transport, $stream); + + expect($response->status)->toBe('OK'); + expect($response->literals)->toBe([$firstLiteral, $secondLiteral, $thirdLiteral]); +}); + +it('fails with timeout-specific error while reading literal bytes', function (): void { + $transport = new ImapSocketTransport(new ImapConfig( + host: 'imap.example.com', + port: 143, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + timeoutSeconds: 1, + )); + + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); + if (! is_array($pair) || ! is_resource($pair[0]) || ! is_resource($pair[1])) { + throw new RuntimeException('Unable to allocate socket pair for timeout test.'); + } + [$stream, $peer] = $pair; + + $property = new ReflectionProperty($transport, 'connection'); + $property->setValue($transport, $stream); + stream_set_timeout($stream, 0, 50_000); + + $readExact = new ReflectionMethod($transport, 'readExact'); + + expect(fn () => $readExact->invoke($transport, 3)) + ->toThrow(MailboxConnectionException::class); + + fclose($peer); + fclose($stream); + $property->setValue($transport, null); +}); diff --git a/tests/InboundAuthSecurityTest.php b/tests/InboundAuthSecurityTest.php new file mode 100644 index 0000000..1bf1559 --- /dev/null +++ b/tests/InboundAuthSecurityTest.php @@ -0,0 +1,196 @@ + OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 1024, + 'config' => $configPath, + ]); + + if ($resource === false) { + unlink($configPath); + + throw new RuntimeException('Unable to generate inbound DKIM key pair.'); + } + + $privateKey = ''; + if (! openssl_pkey_export($resource, $privateKey, null, ['config' => $configPath])) { + unlink($configPath); + + throw new RuntimeException('Unable to export inbound DKIM private key.'); + } + + $details = openssl_pkey_get_details($resource); + if (! is_array($details) || ! is_string($details['key'] ?? null)) { + unlink($configPath); + + throw new RuntimeException('Unable to export inbound DKIM public key.'); + } + + unlink($configPath); + + return [$privateKey, $details['key']]; +} + +function inboundAuthPublicKeyRecordFromPem(string $publicKeyPem): string +{ + $value = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicKeyPem); + + return 'v=DKIM1; k=rsa; p='.$value; +} + +it('parses authentication-results and exposes trust helpers', function (): void { + $header = 'mx.example.com; spf=pass smtp.mailfrom=sender.example; dkim=pass header.d=example.com header.s=s1; dmarc=pass header.from=example.com; arc=none'; + + $results = (new AuthenticationResultsParser)->parse($header); + + expect($results->authservId)->toBe('mx.example.com'); + expect($results->passedSpf())->toBeTrue(); + expect($results->passedDkim())->toBeTrue(); + expect($results->passedDmarc())->toBeTrue(); + expect($results->passedArc())->toBeFalse(); + expect($results->isAuthenticated())->toBeTrue(); + + $dkim = $results->check('dkim'); + expect($dkim)->not->toBeNull(); + expect($dkim?->domain)->toBe('example.com'); + expect($dkim?->selector)->toBe('s1'); +}); + +it('verifies inbound dkim signatures using resolver abstraction', function (): void { + [$privateKey, $publicKey] = inboundAuthBuildKeyPair(); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.net') + ->subject('Inbound verify') + ->text('Hello DKIM'); + + $raw = (new RawEmailBuilder)->build($message); + $config = new DkimConfig('example.com', 's1', $privateKey); + $signatureLine = (new DkimSigner)->buildSignatureHeader($raw->headers, $raw->body, $config); + + $signedRaw = $signatureLine."\r\n".$raw->headers."\r\n\r\n".$raw->body; + $parsed = (new RawEmailParser)->parse($signedRaw); + + $resolver = new StaticDkimPublicKeyResolver([ + 's1._domainkey.example.com' => inboundAuthPublicKeyRecordFromPem($publicKey), + ]); + + $result = (new DkimVerifier($resolver))->verify($parsed); + + expect($result->valid)->toBeTrue(); + expect($result->domain)->toBe('example.com'); + expect($result->selector)->toBe('s1'); +}); + +it('fails dkim verification when key resolver returns wrong public key', function (): void { + [$privateKey] = inboundAuthBuildKeyPair(); + [, $otherPublicKey] = inboundAuthBuildKeyPair(); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.net') + ->subject('Inbound verify fail') + ->text('Hello DKIM fail'); + + $raw = (new RawEmailBuilder)->build($message); + $config = new DkimConfig('example.com', 's1', $privateKey); + $signatureLine = (new DkimSigner)->buildSignatureHeader($raw->headers, $raw->body, $config); + + $signedRaw = $signatureLine."\r\n".$raw->headers."\r\n\r\n".$raw->body; + $parsed = (new RawEmailParser)->parse($signedRaw); + + $resolver = new StaticDkimPublicKeyResolver([ + 's1._domainkey.example.com' => inboundAuthPublicKeyRecordFromPem($otherPublicKey), + ]); + + $result = (new DkimVerifier($resolver))->verify($parsed); + + expect($result->valid)->toBeFalse(); + expect($result->reason)->toContain('verification failed'); +}); + +it('parses complex authentication-results segments and malformed tokens tolerantly', function (): void { + $header = 'mx.example.com; dkim=pass header.d=example.com header.s=s1 reason="ok"; spf=pass smtp.mailfrom=sender@example.com; dmarc=pass header.from=example.com; arc=pass; malformed-segment'; + + $results = (new AuthenticationResultsParser)->parse($header); + + expect($results->authservId)->toBe('mx.example.com'); + expect($results->passedDkim())->toBeTrue(); + expect($results->passedSpf())->toBeTrue(); + expect($results->passedDmarc())->toBeTrue(); + expect($results->passedArc())->toBeTrue(); + expect($results->isAuthenticated())->toBeTrue(); + expect($results->resultFor('dkim')?->properties['reason'] ?? null)->toBe('ok'); + expect($results->check('spf')?->scope)->toBe('sender@example.com'); +}); + +it('verifies only the last dkim-signature header and fails on invalid trailing signature', function (): void { + [$privateKey, $publicKey] = inboundAuthBuildKeyPair(); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.net') + ->subject('DKIM multi') + ->text('Body'); + + $raw = (new RawEmailBuilder)->build($message); + $signatureLine = (new DkimSigner)->buildSignatureHeader($raw->headers, $raw->body, new DkimConfig('example.com', 's1', $privateKey)); + + $invalidLine = preg_replace('/\bb=[^;]*/', 'b=not-base64-***', $signatureLine, 1); + expect($invalidLine)->toBeString(); + + $signedRaw = $signatureLine."\r\n".(string) $invalidLine."\r\n".$raw->headers."\r\n\r\n".$raw->body; + $parsed = (new RawEmailParser)->parse($signedRaw); + $resolver = new StaticDkimPublicKeyResolver([ + 's1._domainkey.example.com' => inboundAuthPublicKeyRecordFromPem($publicKey), + ]); + + $result = (new DkimVerifier($resolver))->verify($parsed); + + expect($result->valid)->toBeFalse(); + expect($result->reason)->toContain('verification failed'); +}); + +it('fails dkim verification when resolver returns revoked key', function (): void { + [$privateKey] = inboundAuthBuildKeyPair(); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.net') + ->subject('DKIM revoked key') + ->text("Line 1\nLine 2"); + + $raw = (new RawEmailBuilder)->build($message); + $signatureLine = (new DkimSigner)->buildSignatureHeader($raw->headers, $raw->body, new DkimConfig('example.com', 's1', $privateKey)); + $signedRaw = str_replace("\r\n", "\n", $signatureLine."\r\n".$raw->headers."\r\n\r\n".$raw->body); + + $parsed = (new RawEmailParser)->parse($signedRaw); + $resolver = new StaticDkimPublicKeyResolver([ + 's1._domainkey.example.com' => 'v=DKIM1; p=', + ]); + + $result = (new DkimVerifier($resolver))->verify($parsed); + + expect($result->valid)->toBeFalse(); + expect($result->reason)->toContain('public key record is invalid'); +}); diff --git a/tests/MailboxCommandRedactorTest.php b/tests/MailboxCommandRedactorTest.php new file mode 100644 index 0000000..e2a5af9 --- /dev/null +++ b/tests/MailboxCommandRedactorTest.php @@ -0,0 +1,95 @@ +toBe('LOGIN "user" [REDACTED]'); + + expect(MailboxCommandRedactor::redact('imap', 'AUTHENTICATE PLAIN dXNlcgB1c2VyAHNlY3JldA==')) + ->toBe('AUTHENTICATE [REDACTED]'); + + expect(MailboxCommandRedactor::redact('imap', 'SELECT "INBOX"')) + ->toBe('SELECT "INBOX"'); +}); + +it('redacts sensitive pop3 pass command', function (): void { + expect(MailboxCommandRedactor::redact('pop3', 'PASS secret-password')) + ->toBe('PASS [REDACTED]'); + + expect(MailboxCommandRedactor::redact('pop3', 'APOP user deadbeef123')) + ->toBe('APOP user [REDACTED]'); + + expect(MailboxCommandRedactor::redact('pop3', 'AUTH PLAIN dGVzdA==')) + ->toBe('AUTH [REDACTED]'); + + expect(MailboxCommandRedactor::redact('pop3', 'USER test')) + ->toBe('USER test'); +}); + +it('dispatches redacted IMAP command payloads in start and finish events', function (): void { + $events = []; + EmailEventBus::listen(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'mailbox.command.')) { + $events[] = ['event' => $event, 'command' => (string) ($payload['command'] ?? '')]; + } + }); + $redacted = MailboxCommandRedactor::redact('imap', 'LOGIN "user" "password"'); + EmailEventBus::dispatch('mailbox.command.start', ['command' => $redacted]); + EmailEventBus::dispatch('mailbox.command.finish', ['command' => $redacted]); + EmailEventBus::listen(null); + + expect($events[0]['event'] ?? null)->toBe('mailbox.command.start'); + expect($events[1]['event'] ?? null)->toBe('mailbox.command.finish'); + expect($events[0]['command'] ?? '')->toBe('LOGIN "user" [REDACTED]'); + expect($events[1]['command'] ?? '')->toBe('LOGIN "user" [REDACTED]'); +}); + +it('dispatches redacted IMAP AUTHENTICATE payloads in mailbox events', function (): void { + $events = []; + EmailEventBus::listen(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'mailbox.command.')) { + $events[] = ['event' => $event, 'command' => (string) ($payload['command'] ?? '')]; + } + }); + + $redacted = MailboxCommandRedactor::redact('imap', 'AUTHENTICATE PLAIN dXNlcgB1c2VyAHNlY3JldA=='); + EmailEventBus::dispatch('mailbox.command.start', ['command' => $redacted]); + EmailEventBus::dispatch('mailbox.command.finish', ['command' => $redacted]); + EmailEventBus::listen(null); + + expect($events[0]['command'] ?? '')->toBe('AUTHENTICATE [REDACTED]'); + expect($events[1]['command'] ?? '')->toBe('AUTHENTICATE [REDACTED]'); +}); + +it('dispatches redacted POP3 command payloads in start and finish events', function (): void { + $events = []; + EmailEventBus::listen(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'mailbox.command.')) { + $events[] = ['event' => $event, 'command' => (string) ($payload['command'] ?? '')]; + } + }); + + $commands = [ + MailboxCommandRedactor::redact('pop3', 'PASS super-secret'), + MailboxCommandRedactor::redact('pop3', 'APOP user deadbeef'), + MailboxCommandRedactor::redact('pop3', 'AUTH PLAIN dGVzdA=='), + ]; + foreach ($commands as $command) { + EmailEventBus::dispatch('mailbox.command.start', ['command' => $command]); + EmailEventBus::dispatch('mailbox.command.finish', ['command' => $command]); + } + EmailEventBus::listen(null); + + expect($events[0]['event'] ?? null)->toBe('mailbox.command.start'); + expect($events[1]['event'] ?? null)->toBe('mailbox.command.finish'); + expect($events[0]['command'] ?? '')->toBe('PASS [REDACTED]'); + expect($events[1]['command'] ?? '')->toBe('PASS [REDACTED]'); + expect($events[2]['command'] ?? '')->toBe('APOP user [REDACTED]'); + expect($events[3]['command'] ?? '')->toBe('APOP user [REDACTED]'); + expect($events[4]['command'] ?? '')->toBe('AUTH [REDACTED]'); + expect($events[5]['command'] ?? '')->toBe('AUTH [REDACTED]'); +}); diff --git a/tests/MailboxImapTest.php b/tests/MailboxImapTest.php new file mode 100644 index 0000000..e8595ac --- /dev/null +++ b/tests/MailboxImapTest.php @@ -0,0 +1,918 @@ + $pipes + */ + private function __construct( + private mixed $process, + private array $pipes, + private string $workDir, + public int $port, + ) {} + + /** + * @param array $scenario + */ + public static function start(array $scenario): self + { + $workDir = sys_get_temp_dir().'/talkingbytes-imap-'.bin2hex(random_bytes(6)); + mkdir($workDir, 0775, true); + + $scriptPath = $workDir.'/server.php'; + $scenarioPath = $workDir.'/scenario.json'; + $readyPath = $workDir.'/ready.json'; + file_put_contents($scriptPath, self::script()); + file_put_contents($scenarioPath, json_encode($scenario, JSON_THROW_ON_ERROR)); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open([PHP_BINARY, $scriptPath, $scenarioPath, $readyPath], $descriptors, $pipes); + if (! is_resource($process)) { + self::cleanupDirectory($workDir); + throw new RuntimeException('Unable to start fake IMAP server process.'); + } + + if (is_resource($pipes[0] ?? null)) { + fclose($pipes[0]); + } + + $port = self::waitForReadyPort($readyPath, $process, $pipes); + + return new self($process, $pipes, $workDir, $port); + } + + /** + * @return array{commands:list,mismatches:list} + */ + public function transcript(): array + { + $reportPath = $this->workDir.'/report.json'; + $deadline = microtime(true) + 2.0; + + while (! is_file($reportPath) && microtime(true) < $deadline) { + usleep(10000); + } + + if (! is_file($reportPath)) { + return ['commands' => [], 'mismatches' => ['report not found']]; + } + + $decoded = null; + while (microtime(true) < $deadline) { + $decoded = json_decode((string) file_get_contents($reportPath), true); + if (is_array($decoded)) { + break; + } + + usleep(10000); + } + + if (! is_array($decoded)) { + return ['commands' => [], 'mismatches' => ['invalid report']]; + } + + /** @var list $commands */ + $commands = is_array($decoded['commands'] ?? null) ? array_values($decoded['commands']) : []; + /** @var list $mismatches */ + $mismatches = is_array($decoded['mismatches'] ?? null) ? array_values($decoded['mismatches']) : []; + + return ['commands' => $commands, 'mismatches' => $mismatches]; + } + + public function stop(): void + { + foreach ([1, 2] as $index) { + if (! is_resource($this->pipes[$index] ?? null)) { + continue; + } + + fclose($this->pipes[$index]); + $this->pipes[$index] = null; + } + + if (is_resource($this->process)) { + $status = proc_get_status($this->process); + if (($status['running'] ?? false) === true) { + proc_terminate($this->process); + } + + proc_close($this->process); + } + + self::cleanupDirectory($this->workDir); + } + + public function __destruct() + { + $this->stop(); + } + + private static function cleanupDirectory(string $directory): void + { + foreach (glob($directory.'/*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + + if (is_dir($directory)) { + rmdir($directory); + } + } + + /** + * @param array $pipes + */ + private static function waitForReadyPort(string $readyPath, mixed $process, array $pipes): int + { + $deadline = microtime(true) + 5.0; + + while (! is_file($readyPath) && microtime(true) < $deadline) { + $status = proc_get_status($process); + if (($status['running'] ?? false) !== true) { + $stderr = is_resource($pipes[2] ?? null) ? (string) stream_get_contents($pipes[2]) : ''; + $reportPath = dirname($readyPath).'/report.json'; + if (is_file($reportPath)) { + $report = json_decode((string) file_get_contents($reportPath), true); + $mismatch = is_array($report['mismatches'] ?? null) ? ($report['mismatches'][0] ?? '') : ''; + if (is_string($mismatch) && str_contains($mismatch, 'bind failed')) { + throw new SkippedWithMessageException('TCP socket bind is unavailable in this environment.'); + } + } + + throw new RuntimeException('Fake IMAP server exited early: '.trim($stderr)); + } + + usleep(10000); + } + + if (! is_file($readyPath)) { + throw new RuntimeException('Fake IMAP server did not become ready in time.'); + } + + $ready = json_decode((string) file_get_contents($readyPath), true, flags: JSON_THROW_ON_ERROR); + $port = (int) ($ready['port'] ?? 0); + if ($port < 1) { + throw new RuntimeException('Fake IMAP server reported an invalid port.'); + } + + return $port; + } + + private static function script(): string + { + return <<<'PHP' + [], 'mismatches' => ['invalid scenario']]); + exit(1); +} + +$server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); +if ($server === false) { + $writeJson($reportPath, ['commands' => [], 'mismatches' => [sprintf('bind failed: %s (%d)', $errstr, $errno)]]); + exit(1); +} + +$name = stream_socket_get_name($server, false); +$port = (int) substr((string) strrchr((string) $name, ':'), 1); +file_put_contents($readyPath, json_encode(['port' => $port])); + +$client = @stream_socket_accept($server, 15); +$transcript = ['commands' => [], 'mismatches' => []]; + +if ($client === false) { + $transcript['mismatches'][] = 'client did not connect'; + $writeJson($reportPath, $transcript); + fclose($server); + exit(1); +} + +stream_set_timeout($client, 5); +fwrite($client, "* OK fake-imap ready\r\n"); + +$expect = is_array($scenario['expect'] ?? null) ? $scenario['expect'] : []; + +foreach ($expect as $entry) { + if (!is_array($entry)) { + continue; + } + + $line = fgets($client, 8192); + if ($line === false) { + $transcript['mismatches'][] = 'expected command, got stream close'; + break; + } + + $command = rtrim($line, "\r\n"); + $transcript['commands'][] = $command; + + if (isset($entry['regex']) && preg_match((string) $entry['regex'], $command) !== 1) { + $transcript['mismatches'][] = sprintf('regex mismatch "%s" for "%s"', $entry['regex'], $command); + } + + $tag = explode(' ', $command, 2)[0] ?? 'A0000'; + + foreach (($entry['untagged'] ?? []) as $untagged) { + fwrite($client, $untagged . "\r\n"); + } + + if (isset($entry['literal'])) { + $literal = (string) $entry['literal']; + fwrite($client, sprintf("* 1 FETCH (RFC822 {%d}\r\n", strlen($literal))); + fwrite($client, $literal); + fwrite($client, "\r\n)\r\n"); + } + + $status = strtoupper((string) ($entry['status'] ?? 'OK')); + $text = (string) ($entry['text'] ?? 'done'); + fwrite($client, sprintf("%s %s %s\r\n", $tag, $status, $text)); +} + +fclose($client); +fclose($server); +$writeJson($reportPath, $transcript); +PHP; + } +} + +it('fetches folders, status, search and parsed message over imap socket transport', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Inbox message', + '', + 'Hello mailbox', + ]); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS MOVE STARTTLS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ LIST "" \*$/', 'untagged' => ['* LIST (\\HasNoChildren) "/" "INBOX"']], + ['regex' => '/^A\\d+ STATUS "INBOX" \(MESSAGES RECENT UNSEEN UIDVALIDITY UIDNEXT\)$/', 'untagged' => ['* STATUS "INBOX" (MESSAGES 3 RECENT 0 UNSEEN 2 UIDVALIDITY 8 UIDNEXT 12)']], + ['regex' => '/^A\\d+ SELECT "INBOX"$/', 'untagged' => ['* 3 EXISTS']], + ['regex' => '/^A\\d+ UID SEARCH UNSEEN$/', 'untagged' => ['* SEARCH 10 11']], + ['regex' => '/^A\\d+ UID FETCH 10 \(RFC822\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $mailbox = Mailbox::usingImap(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $folders = $mailbox->folders(); + $status = $mailbox->status('INBOX'); + $messages = $mailbox->folder('INBOX')->query(MailboxSearch::new()->unseen()); + $parsed = $mailbox->folder('INBOX')->fetchParsed(10); + + $mailbox->transport()->logout(); + $transcript = $server->transcript(); + $server->stop(); + + expect($folders)->toContain('INBOX'); + expect($status->messages)->toBe(3); + expect($status->unseen)->toBe(2); + expect($status->uidValidity)->toBe(8); + expect($status->uidNext)->toBe(12); + expect($messages)->toHaveCount(2); + expect($parsed->subject)->toBe('Inbox message'); + expect($parsed->textBody)->toBe('Hello mailbox'); + expect($transcript['mismatches'])->toBe([]); +}); + +it('fails when starttls is required and server does not advertise capability', function (): void { + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $transport = new ImapSocketTransport(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::StartTlsRequired, + username: 'user', + password: 'pass', + )); + + expect(fn () => $transport->folders())->toThrow(MailboxConnectionException::class); + + $transport->logout(); + $server->stop(); +}); + +it('attempts STARTTLS before LOGIN when server advertises capability', function (): void { + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 STARTTLS UIDPLUS']], + ['regex' => '/^A\\d+ STARTTLS$/', 'responses' => ['A0002 OK begin tls']], + ], + ]); + + $transport = new ImapSocketTransport(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::StartTlsRequired, + username: 'user', + password: 'pass', + )); + + expect(fn () => $transport->folders())->toThrow(MailboxConnectionException::class); + + $transport->logout(); + $transcript = $server->transcript(); + $server->stop(); + + expect(array_any( + $transcript['commands'], + static fn (string $command): bool => str_contains($command, 'STARTTLS'), + ))->toBeTrue(); + expect(array_any( + $transcript['commands'], + static fn (string $command): bool => str_contains($command, 'LOGIN "user" "pass"'), + ))->toBeFalse(); +}); + +it('supports fake mailbox operations and parsing workflow', function (): void { + $fake = FakeMailbox::new(); + + $rawA = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: A\r\n\r\nBody A"; + $rawB = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: B\r\n\r\nBody B"; + + $transport = $fake->transport + ->withMessage('INBOX', 1, $rawA) + ->withMessage('INBOX', 2, $rawB, seen: true); + + $mailbox = new Mailbox($transport); + + $uids = $mailbox->folder('INBOX')->query(MailboxSearch::new()->unseen()); + $parsed = $mailbox->folder('INBOX')->fetchParsed(1); + $summary = $mailbox->folder('INBOX')->fetchSummary(1); + $headers = $mailbox->folder('INBOX')->fetchHeaders(1); + $attachments = $mailbox->folder('INBOX')->fetchAttachments(1); + $bodyStructure = $mailbox->folder('INBOX')->fetchBodyStructure(1); + + expect($uids)->toHaveCount(1); + expect($uids[0]->uid)->toBe(1); + expect($parsed->subject)->toBe('A'); + expect($summary->subject)->toBe('A'); + expect($headers['subject'][0] ?? null)->toBe('A'); + expect($attachments)->toBeArray(); + expect($bodyStructure)->toContain('CONTENT-TYPE'); + + $mailbox->folder('INBOX')->copyMany([1], 'Archive'); + $mailbox->folder('INBOX')->moveMany([2], 'Archive'); + + $mailbox->createFolder('Drafts'); + $mailbox->renameFolder('Drafts', 'Draft'); + $mailbox->subscribeFolder('Archive'); + $mailbox->unsubscribeFolder('Archive'); + $mailbox->noop(); + $mailbox->folder('INBOX')->addFlag(1, 'Flagged'); + $mailbox->folder('INBOX')->removeFlag(1, 'Flagged'); + $mailbox->archive('INBOX', 1, 'Archive'); + $events = []; + $done = false; + $mailbox->watch( + 'Archive', + static function (string $event) use (&$events, &$done): void { + $events[] = $event; + $done = true; + }, + timeoutSeconds: 1, + shouldStop: static fn (): bool => $done, + ); + + $sorted = $mailbox->folder('Archive')->query(MailboxSearch::new()->all()->newestFirst()); + $dateSorted = $mailbox->folder('Archive')->query(MailboxSearch::new()->all()->sortByDateDesc()); + + expect($mailbox->folder('Archive')->status()->messages)->toBe(2); + expect($mailbox->folderExists('Draft'))->toBeTrue(); + expect($mailbox->folderExists('Drafts'))->toBeFalse(); + expect($mailbox->folderDetails())->toHaveCount(3); + expect($sorted[0]->uid)->toBe(2); + expect($dateSorted)->toHaveCount(2); + expect($events)->not->toBeEmpty(); +}); + +it('falls back to in-memory attachment resolver when MIME part number is missing', function (): void { + $fake = FakeMailbox::new(); + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Single attachment', + 'Content-Type: application/octet-stream; name="single.bin"', + 'Content-Disposition: attachment; filename="single.bin"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode('single-content'), + '', + ]); + + $transport = $fake->transport->withMessage('INBOX', 10, $raw); + $mailbox = new Mailbox($transport); + + $attachments = $mailbox->folder('INBOX')->fetchAttachments(10); + + expect($attachments)->toHaveCount(1); + expect($attachments[0]->filename)->toBe('single.bin'); + expect($attachments[0]->contents())->toBe('single-content'); +}); + +it('rejects invalid mailbox uid values before issuing transport operations', function (): void { + $fake = FakeMailbox::new(); + $transport = $fake->transport->withMessage('INBOX', 1, "Subject: A\r\n\r\nB"); + $mailbox = new Mailbox($transport); + + expect(fn () => $mailbox->folder('INBOX')->fetchRaw(0))->toThrow(InvalidArgumentException::class); + expect(fn () => $mailbox->folder('INBOX')->markSeen(-1))->toThrow(InvalidArgumentException::class); +}); + +it('validates folder names at mailbox and folder entry points', function (): void { + $mailbox = FakeMailbox::new()->mailbox; + + expect(fn () => $mailbox->folder(''))->toThrow(InvalidArgumentException::class); + expect(fn () => $mailbox->status("INB\r\nOX"))->toThrow(InvalidArgumentException::class); + expect(fn () => $mailbox->watch("INB\0OX", static function (string $_event): void {}, 1))->toThrow(InvalidArgumentException::class); + expect(fn () => $mailbox->createFolder(str_repeat('A', 300)))->toThrow(InvalidArgumentException::class); + expect(fn () => $mailbox->folder('INBOX')->copy(1, "Arc\r\nhive"))->toThrow(InvalidArgumentException::class); + expect(fn () => $mailbox->archive('INBOX', 1, "Arc\0hive"))->toThrow(InvalidArgumentException::class); +}); + +it('fetches imap attachment content lazily via BODY.PEEK part fetch', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Attachment message', + 'Content-Type: multipart/mixed; boundary="b1"', + '', + '--b1', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Hello', + '--b1', + 'Content-Type: application/octet-stream; name="a.txt"', + 'Content-Disposition: attachment; filename="a.txt"', + 'Content-Transfer-Encoding: base64', + '', + 'QQ==', + '--b1--', + '', + ]); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ SELECT "INBOX"$/', 'untagged' => ['* 1 EXISTS']], + ['regex' => '/^A\\d+ UID FETCH 10 \\(BODYSTRUCTURE\\)$/', 'untagged' => ['* 1 FETCH (BODYSTRUCTURE ("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" 5 1)("APPLICATION" "OCTET-STREAM" ("NAME" "a.txt") NIL NIL "BASE64" 4 NIL ("ATTACHMENT" ("FILENAME" "a.txt")) NIL) "MIXED"))']], + ['regex' => '/^A\\d+ UID FETCH 10 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ UID FETCH 10 \\(BODY\\.PEEK\\[2\\]\\)$/', 'literal' => 'QQ=='], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $mailbox = Mailbox::usingImap(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $attachments = $mailbox->folder('INBOX')->fetchAttachments(10); + expect($attachments)->toHaveCount(1); + expect($attachments[0]->filename)->toBe('a.txt'); + expect($attachments[0]->contents())->toBe('A'); + + $mailbox->transport()->logout(); + $transcript = $server->transcript(); + $server->stop(); + + expect($transcript['mismatches'])->toBe([]); + expect(array_any( + $transcript['commands'], + static fn (string $command): bool => preg_match('/UID FETCH 10 \(BODY\.PEEK\[2\]\)$/', $command) === 1, + ))->toBeTrue(); +}); + +it('maps nested multipart attachment part numbers to correct lazy BODY.PEEK fetches', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Nested lazy attachments', + 'Content-Type: multipart/mixed; boundary="mix"', + '', + '--mix', + 'Content-Type: multipart/related; boundary="rel"', + '', + '--rel', + 'Content-Type: multipart/alternative; boundary="alt"', + '', + '--alt', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Plain body', + '--alt', + 'Content-Type: text/html; charset=UTF-8', + '', + '

Html body

', + '--alt--', + '--rel', + 'Content-Type: image/png; name="logo.png"', + 'Content-Disposition: inline; filename="logo.png"', + 'Content-ID: ', + 'Content-Transfer-Encoding: base64', + '', + 'TE9HTw==', + '--rel--', + '--mix', + 'Content-Type: application/pdf; name="report.pdf"', + 'Content-Disposition: attachment; filename="report.pdf"', + 'Content-Transfer-Encoding: base64', + '', + 'UERG', + '--mix--', + '', + ]); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ SELECT "INBOX"$/', 'untagged' => ['* 1 EXISTS']], + ['regex' => '/^A\\d+ UID FETCH 20 \\(BODYSTRUCTURE\\)$/', 'untagged' => ['* 1 FETCH (BODYSTRUCTURE ((("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" 10 1)("TEXT" "HTML" ("CHARSET" "UTF-8") NIL NIL "7BIT" 16 1) "ALTERNATIVE")("IMAGE" "PNG" ("NAME" "logo.png") NIL "" "BASE64" 8 NIL ("INLINE" ("FILENAME" "logo.png")) NIL) "RELATED")("APPLICATION" "PDF" ("NAME" "report.pdf") NIL NIL "BASE64" 4 NIL ("ATTACHMENT" ("FILENAME" "report.pdf")) NIL) "MIXED"))']], + ['regex' => '/^A\\d+ UID FETCH 20 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ UID FETCH 20 \\(BODY\\.PEEK\\[1\\.2\\]\\)$/', 'literal' => 'TE9HTw=='], + ['regex' => '/^A\\d+ UID FETCH 20 \\(BODY\\.PEEK\\[2\\]\\)$/', 'literal' => 'UERG'], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $mailbox = Mailbox::usingImap(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $attachments = $mailbox->folder('INBOX')->fetchAttachments(20); + expect($attachments)->toHaveCount(2); + expect($attachments[0]->contents())->toBe('LOGO'); + expect($attachments[1]->contents())->toBe('PDF'); + + $mailbox->transport()->logout(); + $transcript = $server->transcript(); + $server->stop(); + + expect(array_any( + $transcript['commands'], + static fn (string $command): bool => preg_match('/BODY\.PEEK\[1\.2\]/', $command) === 1, + ))->toBeTrue(); + expect(array_any( + $transcript['commands'], + static fn (string $command): bool => preg_match('/BODY\.PEEK\[2\]/', $command) === 1, + ))->toBeTrue(); +}); + +it('fetches summary over IMAP ENVELOPE command path', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Envelope summary', + 'Message-ID: ', + '', + 'Hello summary', + ]); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ SELECT "INBOX"$/', 'untagged' => ['* 1 EXISTS']], + ['regex' => '/^A\\d+ UID FETCH 10 \\(ENVELOPE BODY\\.PEEK\\[HEADER\\]\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $mailbox = Mailbox::usingImap(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $summary = $mailbox->folder('INBOX')->fetchSummary(10); + $mailbox->transport()->logout(); + $transcript = $server->transcript(); + $server->stop(); + + expect($summary->uid)->toBe(10); + expect($summary->subject)->toBe('Envelope summary'); + expect($transcript['mismatches'])->toBe([]); +}); + +it('redacts IMAP LOGIN password in mailbox command events', function (): void { + $events = []; + Email::events(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'mailbox.command.')) { + $events[] = $payload; + } + }); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ LIST "" \*$/', 'untagged' => ['* LIST (\\HasNoChildren) "/" "INBOX"']], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $mailbox = Mailbox::usingImap(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $mailbox->folders(); + $mailbox->transport()->logout(); + $server->stop(); + Email::events(null); + + expect(array_any( + $events, + static fn (array $payload): bool => ($payload['command'] ?? null) === 'LOGIN "user" [REDACTED]', + ))->toBeTrue(); + expect(array_any( + $events, + static fn (array $payload): bool => is_string($payload['command'] ?? null) && str_contains($payload['command'], 'LOGIN "user" "pass"'), + ))->toBeFalse(); +}); + +it('reuses selected imap folder between operations and clears state on logout', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Keep selected folder', + '', + 'hello', + ]); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ SELECT "INBOX"$/', 'untagged' => ['* 2 EXISTS']], + ['regex' => '/^A\\d+ UID FETCH 10 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ UID FETCH 11 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $transport = new ImapSocketTransport(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + $mailbox = new Mailbox($transport); + + $mailbox->folder('INBOX')->fetchRaw(10); + $mailbox->folder('INBOX')->fetchRaw(11); + $mailbox->transport()->logout(); + + $selectedFolder = new ReflectionProperty($transport, 'selectedFolder'); + $selectedFolder->setAccessible(true); + expect($selectedFolder->getValue($transport))->toBeNull(); + + $transcript = $server->transcript(); + $server->stop(); + + expect($transcript['mismatches'])->toBe([]); + expect(array_values(array_filter( + $transcript['commands'], + static fn (string $command): bool => preg_match('/SELECT "INBOX"$/', $command) === 1, + )))->toHaveCount(1); +}); + +it('switches selected folder when mailbox operations target different folders', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Folder switching', + '', + 'hello', + ]); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ SELECT "INBOX"$/', 'untagged' => ['* 1 EXISTS']], + ['regex' => '/^A\\d+ UID FETCH 10 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ SELECT "Archive"$/', 'untagged' => ['* 1 EXISTS']], + ['regex' => '/^A\\d+ UID FETCH 11 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $mailbox = Mailbox::usingImap(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $mailbox->folder('INBOX')->fetchRaw(10); + $mailbox->folder('Archive')->fetchRaw(11); + $mailbox->transport()->logout(); + + $transcript = $server->transcript(); + $server->stop(); + + expect($transcript['mismatches'])->toBe([]); +}); + +it('keeps selected folder state after failed command', function (): void { + $raw = implode("\r\n", [ + 'From: sender@example.com', + 'To: alice@example.com', + 'Subject: Folder state after failure', + '', + 'hello', + ]); + + $server = FakeImapServerProcess::start([ + 'expect' => [ + ['regex' => '/^A\\d+ CAPABILITY$/', 'untagged' => ['* CAPABILITY IMAP4rev1 UIDPLUS']], + ['regex' => '/^A\\d+ LOGIN "user" "pass"$/'], + ['regex' => '/^A\\d+ SELECT "INBOX"$/', 'untagged' => ['* 2 EXISTS']], + ['regex' => '/^A\\d+ UID FETCH 10 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ UID FETCH 11 \\(RFC822\\)$/', 'status' => 'NO', 'text' => 'missing message'], + ['regex' => '/^A\\d+ UID FETCH 10 \\(RFC822\\)$/', 'literal' => $raw], + ['regex' => '/^A\\d+ LOGOUT$/', 'untagged' => ['* BYE Logging out']], + ], + ]); + + $mailbox = Mailbox::usingImap(new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $mailbox->folder('INBOX')->fetchRaw(10); + expect(fn () => $mailbox->folder('INBOX')->fetchRaw(11))->toThrow(MailboxProtocolException::class); + $mailbox->folder('INBOX')->fetchRaw(10); + $mailbox->transport()->logout(); + + $transcript = $server->transcript(); + $server->stop(); + + expect($transcript['mismatches'])->toBe([]); + expect(array_values(array_filter( + $transcript['commands'], + static fn (string $command): bool => preg_match('/SELECT "INBOX"$/', $command) === 1, + )))->toHaveCount(1); +}); + +it('enforces mailbox query cost controls for expensive searches', function (): void { + $fake = FakeMailbox::new(); + $rawA = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: A\r\nDate: Tue, 07 Jan 2025 10:00:00 +0000\r\n\r\nBody A"; + $rawB = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: B\r\nDate: Tue, 08 Jan 2025 10:00:00 +0000\r\n\r\nBody B"; + $rawC = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: C\r\nDate: Tue, 09 Jan 2025 10:00:00 +0000\r\n\r\nBody C"; + + $transport = $fake->transport + ->withMessage('INBOX', 1, $rawA) + ->withMessage('INBOX', 2, $rawB) + ->withMessage('INBOX', 3, $rawC); + + $mailbox = new Mailbox($transport); + + expect(fn () => $mailbox->folder('INBOX')->query( + MailboxSearch::new()->all()->sortByDateDesc()->maxSummaryFetches(2), + ))->toThrow(MailboxProtocolException::class); + + expect(fn () => $mailbox->folder('INBOX')->query( + MailboxSearch::new()->hasAttachment()->requireExplicitLimitForExpensiveSearch(), + ))->toThrow(MailboxProtocolException::class); + + expect(fn () => $mailbox->folder('INBOX')->query( + MailboxSearch::new()->all()->maxClientSideFilterFetches(2), + ))->toThrow(MailboxProtocolException::class); +}); + +it('archives into default folder by creating it when missing', function (): void { + $fake = FakeMailbox::new(); + $raw = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: A\r\n\r\nBody"; + $mailbox = new Mailbox($fake->transport->withMessage('INBOX', 1, $raw)); + + $mailbox->archive('INBOX', 1); + + expect($mailbox->folderExists('Archive'))->toBeTrue(); + expect($mailbox->folder('INBOX')->status()->messages)->toBe(0); + expect($mailbox->folder('Archive')->status()->messages)->toBe(1); +}); + +it('archives using provider strategy all_mail target', function (): void { + $fake = FakeMailbox::new(); + $raw = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: A\r\n\r\nBody"; + $mailbox = new Mailbox($fake->transport->withMessage('INBOX', 1, $raw)); + + $mailbox->archiveWithProviderStrategy('INBOX', 1, ['all_mail' => 'All Mail']); + + expect($mailbox->folderExists('All Mail'))->toBeTrue(); + expect($mailbox->folder('All Mail')->status()->messages)->toBe(1); +}); + +it('surfaces archive move failures', function (): void { + $mailbox = FakeMailbox::new()->mailbox; + + expect(fn () => $mailbox->archive('INBOX', 999, 'Archive')) + ->toThrow(MailboxProtocolException::class); +}); + +it('applies batch mailbox operations and stops on first failure', function (): void { + $fake = FakeMailbox::new(); + $rawA = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: A\r\n\r\nBody A"; + $rawB = "From: sender@example.com\r\nTo: user@example.com\r\nSubject: B\r\n\r\nBody B"; + $mailbox = new Mailbox( + $fake->transport + ->withMessage('INBOX', 1, $rawA) + ->withMessage('INBOX', 2, $rawB), + ); + + $folder = $mailbox->folder('INBOX'); + + $folder->markSeenMany([1, 2]); + expect($folder->query(MailboxSearch::new()->seen()))->toHaveCount(2); + + $folder->markUnreadMany([1, 2]); + expect($folder->query(MailboxSearch::new()->unseen()))->toHaveCount(2); + + expect(fn () => $folder->markSeenMany([1, 999, 2]))->toThrow(MailboxProtocolException::class); + $unseenAfterFailure = $mailbox->folder('INBOX')->query(MailboxSearch::new()->unseen()); + expect(array_any( + $unseenAfterFailure, + static fn (MailboxMessageRef $ref): bool => $ref->uid === 2, + ))->toBeTrue(); + + $folder->addFlagMany([1, 2], 'custom-flag'); + $folder->removeFlagMany([1, 2], 'custom-flag'); + $folder->copyMany([1], 'Archive'); + $folder->moveMany([2], 'Archive'); + expect($mailbox->folder('Archive')->status()->messages)->toBe(2); +}); diff --git a/tests/MimeStructureTest.php b/tests/MimeStructureTest.php new file mode 100644 index 0000000..07f3070 --- /dev/null +++ b/tests/MimeStructureTest.php @@ -0,0 +1,284 @@ +]+>/', 'Message-ID: ', $raw); + + $boundaries = []; + $matchCount = preg_match_all('/boundary="([^"]+)"/', $normalized, $matches); + if (is_int($matchCount) && $matchCount > 0 && is_array($matches[1] ?? null)) { + /** @var list $values */ + $values = array_values(array_unique($matches[1])); + foreach ($values as $index => $value) { + $boundaries[$value] = sprintf('normalized-boundary-%d', $index + 1); + } + } + + foreach ($boundaries as $from => $to) { + $normalized = str_replace($from, $to, $normalized); + } + + return $normalized; +} + +it('builds text-only MIME payload', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Text') + ->text("Hello\nWorld"); + + $mime = (new MimeMessageBuilder)->build($message); + + expect($mime->contentType)->toBe('text/plain; charset=UTF-8'); + expect($mime->contentTransferEncoding)->toBe(ContentTransferEncoding::QuotedPrintable); + expect($mime->body)->toContain('Hello'); +}); + +it('builds html-only MIME payload', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Html') + ->html('

Hello World

'); + + $mime = (new MimeMessageBuilder)->build($message); + + expect($mime->contentType)->toContain('multipart/alternative'); + expect($mime->contentTransferEncoding)->toBeNull(); + expect($mime->body)->toContain('Content-Type: text/plain; charset=UTF-8'); + expect($mime->body)->toContain('Content-Type: text/html; charset=UTF-8'); + expect($mime->body)->toContain('

Hello'); +}); + +it('builds multipart alternative for text and html bodies', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Alt') + ->text('Plain text') + ->html('

HTML

'); + + $mime = (new MimeMessageBuilder)->build($message); + + expect($mime->contentType)->toContain('multipart/alternative'); + expect($mime->body)->toContain('Content-Type: text/plain; charset=UTF-8'); + expect($mime->body)->toContain('Content-Type: text/html; charset=UTF-8'); +}); + +it('wraps inline and regular attachments as related inside mixed', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Attachments') + ->html('') + ->attachInlineData('logo-bytes', 'logo.png', 'logo', 'image/png') + ->attachData('invoice-content', 'invoice.pdf', 'application/pdf'); + + $mime = (new MimeMessageBuilder)->build($message); + + expect($mime->contentType)->toContain('multipart/mixed'); + expect($mime->body)->toContain('Content-Type: multipart/related'); + expect($mime->body)->toContain('Content-ID: '); + expect($mime->body)->toContain('Content-Disposition: attachment;'); +}); + +it('encodes attachment filename using filename* parameter', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Filename') + ->text('Body') + ->attachData('binary', 'statement résumé.pdf', 'application/pdf'); + + $mime = (new MimeMessageBuilder)->build($message); + + expect($mime->body)->toContain("filename*=UTF-8''"); + expect($mime->body)->toContain('filename="'); +}); + +it('does not expose return path as a header in raw email', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->returnPath('bounce@example.com') + ->to('alice@example.com') + ->subject('Envelope') + ->text('Body'); + + $raw = (new RawEmailBuilder)->build($message, includeSubject: true); + + expect($raw->headers)->not->toContain('Return-Path:'); +}); + +it('adds read receipt and one-click unsubscribe headers', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Headers') + ->text('Body') + ->oneClickUnsubscribe('https://example.com/unsub') + ->readReceiptTo('ops@example.com'); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->toContain('List-Unsubscribe: '); + expect($headers)->toContain('List-Unsubscribe-Post: List-Unsubscribe=One-Click'); + expect($headers)->toContain('Disposition-Notification-To: ops@example.com'); +}); + +it('folds long recipient headers safely', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->subject('Fold') + ->text('Body'); + + for ($i = 0; $i < 8; $i++) { + $message = $message->to(sprintf('recipient%02d.long.address@example.com', $i)); + } + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->toContain('To: recipient00.long.address@example.com'); + expect($headers)->toContain("\r\n "); +}); + +it('streams attachment encoding and keeps external streams open', function (): void { + $stream = fopen('php://temp', 'w+b'); + expect($stream)->not->toBeFalse(); + fwrite($stream, str_repeat('A', 1024)); + rewind($stream); + + $attachment = EmailAttachment::fromStream( + $stream, + 'payload.bin', + 'application/octet-stream', + ); + + $encoder = new AttachmentEncoder; + $body = ''; + $encoder->encodeToStream($attachment, static function (string $chunk) use (&$body): void { + $body .= $chunk; + }); + + expect($body)->toContain('Content-Transfer-Encoding: base64'); + expect($body)->toContain("\r\n\r\n"); + expect(is_resource($stream))->toBeTrue(); + + fclose($stream); +}); + +it('builds raw email through stream writer', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Streamed') + ->text('Body') + ->messageDetails('streamed@example.com'); + + $builder = new RawEmailBuilder; + $raw = $builder->build($message, includeSubject: true); + + $output = ''; + $size = $builder->buildToStream( + $message, + static function (string $chunk) use (&$output): void { + $output .= $chunk; + }, + includeSubject: true, + ); + + expect($output)->toBe($raw->raw); + expect($size)->toBe($raw->sizeBytes); +}); + +it('folds long references header and keeps encoded subject', function (): void { + $refs = []; + for ($i = 0; $i < 12; $i++) { + $refs[] = sprintf('', $i); + } + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Résumé update') + ->text('Body') + ->messageDetails(references: $refs); + + $mime = (new MimeMessageBuilder)->build($message); + $headers = (new EmailHeaderBuilder)->build($message, $mime, includeSubject: true); + + expect($headers)->toContain('Subject: =?UTF-8?B?'); + expect($headers)->toContain('References:'); + expect($headers)->toContain("\r\n "); +}); + +it('renders mixed related alternative nesting with non-ascii filenames', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Nested') + ->text('plain') + ->html('') + ->attachInlineData('img', 'logo 画像.png', 'logo', 'image/png') + ->attachData('pdf', 'statement résumé.pdf', 'application/pdf'); + + $mime = (new MimeMessageBuilder)->build($message); + + expect($mime->contentType)->toContain('multipart/mixed'); + expect($mime->body)->toContain('multipart/related'); + expect($mime->body)->toContain('multipart/alternative'); + expect($mime->body)->toContain("filename*=UTF-8''"); +}); + +it('keeps build and buildToStream output equivalent for inline and regular attachments', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('Streaming equivalence') + ->text("Line 1\r\nLine 2") + ->html('

Line 1

') + ->attachInlineData('logo-bytes', 'logo.png', 'logo', 'image/png') + ->attachData('pdf-bytes', 'report.pdf', 'application/pdf'); + + $builder = new RawEmailBuilder; + $built = $builder->build($message, includeSubject: true); + + $streamed = ''; + $streamSize = $builder->buildToStream( + $message, + static function (string $chunk) use (&$streamed): void { + $streamed .= $chunk; + }, + includeSubject: true, + maxBytes: $built->sizeBytes + 16, + ); + + expect($streamSize)->toBe(strlen($streamed)); + expect(normalizeDynamicMimeOutput($streamed))->toContain('multipart/mixed'); + expect(normalizeDynamicMimeOutput($streamed))->toContain('multipart/related'); + expect(normalizeDynamicMimeOutput($streamed))->toContain('multipart/alternative'); + expect(normalizeDynamicMimeOutput($streamed))->toContain('Content-ID: '); + expect(normalizeDynamicMimeOutput($streamed))->toContain('filename*=UTF-8\'\'report.pdf'); + expect(normalizeDynamicMimeOutput($streamed))->toContain('bG9nby1ieXRlcw=='); + expect(normalizeDynamicMimeOutput($streamed))->toContain('cGRmLWJ5dGVz'); + + expect(normalizeDynamicMimeOutput($built->raw))->toContain('multipart/mixed'); + expect(normalizeDynamicMimeOutput($built->raw))->toContain('multipart/related'); + expect(normalizeDynamicMimeOutput($built->raw))->toContain('multipart/alternative'); + expect($streamed)->toContain("\r\n"); + expect($streamed)->toContain('multipart/mixed'); + expect($streamed)->toContain('multipart/related'); + expect($streamed)->toContain('multipart/alternative'); +}); diff --git a/tests/Pop3MailboxTest.php b/tests/Pop3MailboxTest.php new file mode 100644 index 0000000..7c567dc --- /dev/null +++ b/tests/Pop3MailboxTest.php @@ -0,0 +1,589 @@ + $pipes + */ + private function __construct( + private mixed $process, + private array $pipes, + private string $workDir, + public int $port, + ) {} + + /** + * @param array $scenario + */ + public static function start(array $scenario): self + { + $workDir = sys_get_temp_dir().'/talkingbytes-pop3-'.bin2hex(random_bytes(6)); + mkdir($workDir, 0775, true); + + $scriptPath = $workDir.'/server.php'; + $scenarioPath = $workDir.'/scenario.json'; + $readyPath = $workDir.'/ready.json'; + file_put_contents($scriptPath, self::script()); + file_put_contents($scenarioPath, json_encode($scenario, JSON_THROW_ON_ERROR)); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open([PHP_BINARY, $scriptPath, $scenarioPath, $readyPath], $descriptors, $pipes); + if (! is_resource($process)) { + self::cleanupDirectory($workDir); + throw new RuntimeException('Unable to start fake POP3 server process.'); + } + + if (is_resource($pipes[0] ?? null)) { + fclose($pipes[0]); + } + + $port = self::waitForReadyPort($readyPath, $process, $pipes); + + return new self($process, $pipes, $workDir, $port); + } + + /** + * @return array{commands:list,mismatches:list} + */ + public function transcript(): array + { + $reportPath = $this->workDir.'/report.json'; + $deadline = microtime(true) + 1.0; + + while (! is_file($reportPath) && microtime(true) < $deadline) { + usleep(10000); + } + + if (! is_file($reportPath)) { + return ['commands' => [], 'mismatches' => ['report not found']]; + } + + $decoded = json_decode((string) file_get_contents($reportPath), true); + if (! is_array($decoded)) { + return ['commands' => [], 'mismatches' => ['invalid report']]; + } + + /** @var list $commands */ + $commands = is_array($decoded['commands'] ?? null) ? array_values($decoded['commands']) : []; + /** @var list $mismatches */ + $mismatches = is_array($decoded['mismatches'] ?? null) ? array_values($decoded['mismatches']) : []; + + return ['commands' => $commands, 'mismatches' => $mismatches]; + } + + public function stop(): void + { + foreach ([1, 2] as $index) { + if (! is_resource($this->pipes[$index] ?? null)) { + continue; + } + + fclose($this->pipes[$index]); + $this->pipes[$index] = null; + } + + if (is_resource($this->process)) { + $status = proc_get_status($this->process); + if (($status['running'] ?? false) === true) { + proc_terminate($this->process); + } + + proc_close($this->process); + } + + self::cleanupDirectory($this->workDir); + } + + public function __destruct() + { + $this->stop(); + } + + private static function cleanupDirectory(string $directory): void + { + foreach (glob($directory.'/*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + + if (is_dir($directory)) { + rmdir($directory); + } + } + + /** + * @param array $pipes + */ + private static function waitForReadyPort(string $readyPath, mixed $process, array $pipes): int + { + $deadline = microtime(true) + 5.0; + + while (! is_file($readyPath) && microtime(true) < $deadline) { + $status = proc_get_status($process); + if (($status['running'] ?? false) !== true) { + $stderr = is_resource($pipes[2] ?? null) ? (string) stream_get_contents($pipes[2]) : ''; + $reportPath = dirname($readyPath).'/report.json'; + if (is_file($reportPath)) { + $report = json_decode((string) file_get_contents($reportPath), true); + $mismatch = is_array($report['mismatches'] ?? null) ? ($report['mismatches'][0] ?? '') : ''; + if (is_string($mismatch) && str_contains($mismatch, 'bind failed')) { + throw new SkippedWithMessageException('TCP socket bind is unavailable in this environment.'); + } + } + + throw new RuntimeException('Fake POP3 server exited early: '.trim($stderr)); + } + + usleep(10000); + } + + if (! is_file($readyPath)) { + throw new RuntimeException('Fake POP3 server did not become ready in time.'); + } + + $ready = json_decode((string) file_get_contents($readyPath), true, flags: JSON_THROW_ON_ERROR); + $port = (int) ($ready['port'] ?? 0); + if ($port < 1) { + throw new RuntimeException('Fake POP3 server reported an invalid port.'); + } + + return $port; + } + + private static function script(): string + { + return <<<'PHP' + [], 'mismatches' => ['invalid scenario']])); + exit(1); +} + +$server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); +if ($server === false) { + file_put_contents($reportPath, json_encode(['commands' => [], 'mismatches' => [sprintf('bind failed: %s (%d)', $errstr, $errno)]])); + exit(1); +} + +$name = stream_socket_get_name($server, false); +$port = (int) substr((string) strrchr((string) $name, ':'), 1); +file_put_contents($readyPath, json_encode(['port' => $port])); + +$client = @stream_socket_accept($server, 15); +$transcript = ['commands' => [], 'mismatches' => []]; + +if ($client === false) { + $transcript['mismatches'][] = 'client did not connect'; + file_put_contents($reportPath, json_encode($transcript)); + fclose($server); + exit(1); +} + +stream_set_timeout($client, 5); +fwrite($client, "+OK fake-pop3 ready\r\n"); + +$expect = is_array($scenario['expect'] ?? null) ? $scenario['expect'] : []; +foreach ($expect as $entry) { + if (!is_array($entry)) { + continue; + } + + $line = fgets($client, 8192); + if ($line === false) { + $transcript['mismatches'][] = 'expected command, got stream close'; + break; + } + + $command = rtrim($line, "\r\n"); + $transcript['commands'][] = $command; + + if (isset($entry['regex']) && preg_match((string) $entry['regex'], $command) !== 1) { + $transcript['mismatches'][] = sprintf('regex mismatch "%s" for "%s"', $entry['regex'], $command); + } + + $status = (string) ($entry['status'] ?? '+OK'); + $text = (string) ($entry['text'] ?? 'done'); + fwrite($client, sprintf("%s %s\r\n", $status, $text)); + + if (is_array($entry['multiline'] ?? null)) { + foreach ($entry['multiline'] as $responseLine) { + fwrite($client, (string) $responseLine . "\r\n"); + } + + if (!($entry['omit_terminator'] ?? false)) { + fwrite($client, ".\r\n"); + } + + $sleepMs = (int) ($entry['sleep_ms'] ?? 0); + if ($sleepMs > 0) { + usleep($sleepMs * 1000); + } + } +} + +fclose($client); +fclose($server); +file_put_contents($reportPath, json_encode($transcript)); +PHP; + } +} + +it('fetches status, list and parsed message over pop3 socket transport', function (): void { + $rawLines = [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: POP3 hello', + '', + 'Hello over POP3', + ]; + + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['PIPELINING', 'UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^STAT$/', 'text' => '2 456'], + ['regex' => '/^LIST$/', 'multiline' => ['1 123', '2 333']], + ['regex' => '/^UIDL$/', 'multiline' => ['1 uidl-1', '2 uidl-2']], + ['regex' => '/^RETR 1$/', 'multiline' => $rawLines], + ['regex' => '/^DELE 2$/'], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $status = $mailbox->status(); + $refs = $mailbox->listMessageRefs(); + $parsed = $mailbox->fetchParsed(1); + $mailbox->delete(2); + $mailbox->logout(); + + $transcript = $server->transcript(); + $server->stop(); + + expect($status->messages)->toBe(2); + expect($refs)->toHaveCount(2); + expect($refs[0]->uid)->toBe(1); + expect($refs[0]->externalId)->toBe('uidl-1'); + expect($parsed->subject)->toBe('POP3 hello'); + expect($parsed->textBody)->toBe('Hello over POP3'); + expect($transcript['mismatches'])->toBe([]); +}); + +it('fails when pop3 starttls is required and stls capability is missing', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::StartTlsRequired, + username: 'user', + password: 'pass', + )); + + expect(fn () => $mailbox->status())->toThrow(MailboxConnectionException::class); + + $mailbox->logout(); + $server->stop(); +}); + +it('supports pop3 rset to clear pending deletions before quit', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^DELE 1$/'], + ['regex' => '/^RSET$/'], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $mailbox->delete(1); + $mailbox->reset(); + $mailbox->logout(); + + $transcript = $server->transcript(); + $server->stop(); + + expect($transcript['mismatches'])->toBe([]); +}); + +it('fails pop3 authentication when server rejects password', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS wrong$/', 'status' => '-ERR', 'text' => 'invalid login'], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'wrong', + )); + + expect(fn () => $mailbox->status())->toThrow(MailboxAuthenticationException::class); + + $mailbox->logout(); + $server->stop(); +}); + +it('rejects unsupported pop3 folder operations and non-all searches', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + expect(fn () => $mailbox->transport()->search(MailboxSearch::new()->unseen()))->toThrow(MailboxProtocolException::class); + + $mailbox->logout(); + $server->stop(); +}); + +it('handles pop3 multiline dot-unescaping for retr', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^RETR 1$/', 'multiline' => [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Dot lines', + '', + '..literal dot line', + '..', + ]], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $parsed = $mailbox->fetchParsed(1); + $mailbox->logout(); + $server->stop(); + + expect($parsed->textBody)->toContain('.literal dot line'); +}); + +it('fails pop3 retr when multiline terminator is missing', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^RETR 1$/', 'multiline' => [ + 'From: sender@example.com', + 'To: user@example.com', + 'Subject: Broken', + '', + 'no terminator', + ], 'omit_terminator' => true, 'sleep_ms' => 1500], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + timeoutSeconds: 1, + )); + + expect(fn () => $mailbox->fetchParsed(1))->toThrow(MailboxConnectionException::class); + + $mailbox->logout(); + $server->stop(); +}); + +it('supports pop3 empty message body retrieval', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^RETR 1$/', 'multiline' => []], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $raw = $mailbox->fetchRaw(1); + $mailbox->logout(); + $server->stop(); + + expect($raw)->toBe(''); +}); + +it('redacts POP3 PASS value in mailbox command events', function (): void { + $events = []; + Email::events(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'mailbox.command.')) { + $events[] = $payload; + } + }); + + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^STAT$/', 'text' => '0 0'], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $mailbox->status(); + $mailbox->logout(); + $server->stop(); + Email::events(null); + + expect(array_any( + $events, + static fn (array $payload): bool => ($payload['command'] ?? null) === 'PASS [REDACTED]', + ))->toBeTrue(); + expect(array_any( + $events, + static fn (array $payload): bool => is_string($payload['command'] ?? null) && str_contains($payload['command'], 'PASS pass'), + ))->toBeFalse(); +}); + +it('rejects invalid pop3 message numbers before issuing commands', function (): void { + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: 110, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + expect(fn () => $mailbox->fetchRaw(0))->toThrow(InvalidArgumentException::class); + expect(fn () => $mailbox->delete(-1))->toThrow(InvalidArgumentException::class); +}); + +it('exposes dedicated pop3 transport contract without foldered mailbox operations', function (): void { + $transport = new ReflectionClass(Pop3Transport::class); + + expect($transport->hasMethod('rawMessage'))->toBeTrue(); + expect($transport->hasMethod('uidl'))->toBeTrue(); + expect($transport->hasMethod('list'))->toBeTrue(); + expect($transport->hasMethod('createFolder'))->toBeFalse(); + expect($transport->hasMethod('move'))->toBeFalse(); + expect($transport->hasMethod('copy'))->toBeFalse(); +}); + +it('lists message refs without uidl when server does not advertise capability', function (): void { + $server = FakePop3ServerProcess::start([ + 'expect' => [ + ['regex' => '/^CAPA$/', 'multiline' => ['PIPELINING']], + ['regex' => '/^USER user$/'], + ['regex' => '/^PASS pass$/'], + ['regex' => '/^LIST$/', 'multiline' => ['1 123']], + ['regex' => '/^QUIT$/'], + ], + ]); + + $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $refs = $mailbox->listMessageRefs(); + $mailbox->logout(); + $server->stop(); + + expect($refs)->toHaveCount(1); + expect($refs[0]->uid)->toBe(1); + expect($refs[0]->externalId)->toBeNull(); +}); diff --git a/tests/Pop3MultilineReadTest.php b/tests/Pop3MultilineReadTest.php new file mode 100644 index 0000000..d684b99 --- /dev/null +++ b/tests/Pop3MultilineReadTest.php @@ -0,0 +1,127 @@ +setValue($transport, $stream); + + return $stream; +} + +/** + * @param resource $stream + */ +function teardownPop3TransportConnection(Pop3SocketTransport $transport, mixed $stream): void +{ + if (is_resource($stream)) { + fclose($stream); + } + + $property = new ReflectionProperty($transport, 'connection'); + $property->setValue($transport, null); +} + +/** + * @return list + */ +function invokeReadPop3Multiline(Pop3SocketTransport $transport): array +{ + $method = new ReflectionMethod($transport, 'readMultilineResponse'); + + /** @var list */ + return $method->invoke($transport); +} + +it('parses POP3 multiline response with dot terminator and unescapes dot-stuffed lines', function (): void { + $transport = new Pop3SocketTransport(new Pop3Config( + host: 'pop3.example.com', + port: 110, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $stream = setPop3TransportConnection($transport, implode("\r\n", [ + 'first line', + '..second line', + '...single-dot-line', + '.', + '', + ])); + + $lines = invokeReadPop3Multiline($transport); + teardownPop3TransportConnection($transport, $stream); + + expect($lines)->toBe([ + 'first line', + '.second line', + '..single-dot-line', + ]); +}); + +it('supports POP3 empty multiline body', function (): void { + $transport = new Pop3SocketTransport(new Pop3Config( + host: 'pop3.example.com', + port: 110, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $stream = setPop3TransportConnection($transport, ".\r\n"); + + $lines = invokeReadPop3Multiline($transport); + teardownPop3TransportConnection($transport, $stream); + + expect($lines)->toBe([]); +}); + +it('fails POP3 multiline read when terminator is missing', function (): void { + $transport = new Pop3SocketTransport(new Pop3Config( + host: 'pop3.example.com', + port: 110, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $stream = setPop3TransportConnection($transport, "line-without-terminator\r\n"); + + expect(fn () => invokeReadPop3Multiline($transport)) + ->toThrow(MailboxConnectionException::class); + + teardownPop3TransportConnection($transport, $stream); +}); + +it('fails POP3 multiline read when server closes stream mid-response', function (): void { + $transport = new Pop3SocketTransport(new Pop3Config( + host: 'pop3.example.com', + port: 110, + security: Pop3Security::None, + username: 'user', + password: 'pass', + )); + + $stream = setPop3TransportConnection($transport, ''); + + expect(fn () => invokeReadPop3Multiline($transport)) + ->toThrow(MailboxConnectionException::class); + + teardownPop3TransportConnection($transport, $stream); +}); diff --git a/tests/ReadmeSmokeTest.php b/tests/ReadmeSmokeTest.php new file mode 100644 index 0000000..5f3b06b --- /dev/null +++ b/tests/ReadmeSmokeTest.php @@ -0,0 +1,94 @@ +toBeString(); + expect($readme)->toContain('# TalkingBytes'); + expect($readme)->toContain('## Quick Start'); + expect($readme)->toContain('### HTTP'); + expect($readme)->toContain('### Email'); + expect($readme)->toContain('### Webhook'); + expect($readme)->toContain('### gRPC'); + expect($readme)->toContain('## Full Documentation'); + expect($readme)->toContain('docs/http/index.rst'); + expect($readme)->toContain('docs/email/index.rst'); + expect($readme)->toContain('docs/webhook/index.rst'); + expect($readme)->toContain('docs/grpc/index.rst'); + expect($readme)->toContain('docs/security.rst'); + expect($readme)->toContain('docs/release-checklist.rst'); +}); + +it('keeps release-level README examples syntactically valid in fake-safe mode', function (): void { + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('user@example.com') + ->subject('README smoke') + ->text('body'); + + $smtp = Emailer::usingSmtp(new SmtpConfig('smtp.example.com')); + $mail = Emailer::usingMailFunction(); + $null = Emailer::usingNull(); + $imap = Mailbox::usingImap(new ImapConfig('imap.example.com', username: 'u', password: 'p')); + $pop3 = Pop3Mailbox::usingConfig(new Pop3Config('pop.example.com', username: 'u', password: 'p')); + $search = MailboxSearch::new()->unseen()->limit(10); + $auth = (new AuthenticationResultsParser())->parse('mx.example.com; dkim=pass header.d=example.com'); + $parsed = (new RawEmailParser())->parse("From: a@example.com\r\nTo: b@example.com\r\nSubject: S\r\n\r\nBody"); + $bounce = (new BounceParser())->parse($parsed); + $http = HttpClient::fake()->withCookieJar(new CookieJar())->withHttpRetry(new HttpRetryPolicy(baseDelayMs: 0)); + $pool = HttpClient::multi(2); + $webhookSender = WebhookSender::usingHttpWithRetryProfile(HttpClient::fake(), attempts: 2, baseDelayMs: 0); + $httpRequest = HttpRequest::get('https://api.example.com/users')->query('page', 1); + $grpcFake = (new FakeGrpcCaller())->pushOk(['ok' => true]); + $grpcClient = GrpcClient::using( + static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Ok, $request->message), + )->withGrpcRetry(GrpcRetryPolicy::standard(attempts: 2, baseDelayMs: 0)); + $grpcResult = $grpcClient->send(new GrpcRequest('Orders/Create', ['order_id' => 1001])); + $grpcFakeClient = GrpcClient::using($grpcFake); + $grpcFakeClient->send(new GrpcRequest('Orders/Create', ['order_id' => 1002])); + + expect($message->headersData()->subject)->toBe('README smoke'); + expect($smtp)->toBeInstanceOf(Emailer::class); + expect($mail)->toBeInstanceOf(Emailer::class); + expect($null)->toBeInstanceOf(Emailer::class); + expect($imap)->toBeInstanceOf(Mailbox::class); + expect($pop3)->toBeInstanceOf(Pop3Mailbox::class); + expect($search->limit)->toBe(10); + expect($auth->passedDkim())->toBeTrue(); + expect($bounce)->toBeNull(); + expect($http)->toBeInstanceOf(HttpClient::class); + expect($pool->maxConcurrency(2))->toBeInstanceOf(RequestPool::class); + expect($webhookSender)->toBeInstanceOf(WebhookSender::class); + expect($httpRequest->buildUrl())->toContain('page=1'); + expect($grpcResult->successful)->toBeTrue(); + $grpcFake->assert()->assertCallCount(1); + expect(Email::mailbox()->usingPop3(new Pop3Config('pop.example.com', username: 'u', password: 'p'))) + ->toBeInstanceOf(Pop3Mailbox::class); +}); diff --git a/tests/SafetyValidationTest.php b/tests/SafetyValidationTest.php new file mode 100644 index 0000000..2eee0b3 --- /dev/null +++ b/tests/SafetyValidationTest.php @@ -0,0 +1,267 @@ + new FixedDelayRetryPolicy(0, 1))->toThrow(InvalidArgumentException::class); + expect(fn () => new FixedDelayRetryPolicy(1, -1))->toThrow(InvalidArgumentException::class); + expect(fn () => new ExponentialBackoffRetryPolicy(0, 1))->toThrow(InvalidArgumentException::class); + expect(fn () => new ExponentialBackoffRetryPolicy(1, -1))->toThrow(InvalidArgumentException::class); + expect(fn () => new JitterBackoffRetryPolicy(1, 0))->toThrow(InvalidArgumentException::class); +}); + +it('validates http urls', function (): void { + expect(fn () => HttpRequest::get(''))->toThrow(InvalidArgumentException::class); + expect(fn () => HttpRequest::get('not-a-url'))->toThrow(InvalidArgumentException::class); + expect(fn () => HttpRequest::get('ftp://example.com'))->toThrow(InvalidArgumentException::class); + + $request = HttpRequest::get('https://example.com'); + + expect($request->url)->toBe('https://example.com'); +}); + +it('validates http header names and values', function (): void { + expect(fn () => new HeaderBag(['Bad Header' => 'value']))->toThrow(InvalidArgumentException::class); + expect(fn () => new HeaderBag(['X-Test' => "evil\r\nnext: bad"]))->toThrow(InvalidArgumentException::class); + + $request = HttpRequest::get('https://example.com')->header('X-Test', 'ok'); + + expect($request->headers->get('X-Test'))->toBe('ok'); +}); + +it('validates auth inputs', function (): void { + expect(fn () => new BasicAuth('', 'pw'))->toThrow(InvalidArgumentException::class); + expect(fn () => new BasicAuth('user', ''))->toThrow(InvalidArgumentException::class); + expect(fn () => new BearerTokenAuth(''))->toThrow(InvalidArgumentException::class); + expect(fn () => new ApiKeyAuth('', 'value'))->toThrow(InvalidArgumentException::class); + expect(fn () => new ApiKeyAuth('X-Api-Key', ''))->toThrow(InvalidArgumentException::class); + expect(fn () => new HeaderAuth('', 'value'))->toThrow(InvalidArgumentException::class); + expect(fn () => new QueryAuth('', 'value'))->toThrow(InvalidArgumentException::class); +}); + +it('fails when download path cannot be written', function (): void { + $request = HttpRequest::get('https://example.com')->downloadTo('/path/does/not/exist/out.txt'); + + $result = CurlResultFactory::fromExecution( + $request, + 'curl', + 'hello', + 0, + '', + ['http_code' => 200], + [], + ); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('Download directory does not exist'); +}); + +it('grpc client supports middleware extension', function (): void { + $headerSeen = null; + + $client = GrpcClient::using( + static fn (GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Ok, $request->message), + ) + ->withMiddleware(new HeaderMiddleware(['X-Trace' => '1'])) + ->withMiddleware(new class($headerSeen) implements MiddlewareInterface + { + public function __construct(private ?string &$headerSeen) {} + + public function handle(CommunicationRequest $request, Closure $next): CommunicationResult + { + $header = $request->headers['X-Trace'] ?? null; + $this->headerSeen = is_string($header) ? $header : null; + + return $next($request); + } + }); + + $result = $client->send(new GrpcRequest('Service/Method', ['ping' => true])); + + expect($result->successful)->toBeTrue(); + expect($headerSeen)->toBe('1'); +}); + +it('webhook verifier rejects malformed timestamp and signature values', function (): void { + $verifier = new WebhookVerifier('secret'); + + expect($verifier->verify('{"x":1}', 't=abc,v1=abcdef'))->toBeFalse(); + expect($verifier->verify('{"x":1}', 't=1,v1=nothex'))->toBeFalse(); +}); + +it('validates resilience constructor arguments', function (): void { + expect(fn () => new RateLimiter(0, 60))->toThrow(InvalidArgumentException::class); + expect(fn () => new RateLimiter(1, 0))->toThrow(InvalidArgumentException::class); + expect(fn () => new CircuitBreaker(failureThreshold: 0, coolDownSeconds: 60))->toThrow(InvalidArgumentException::class); + expect(fn () => new CircuitBreaker(failureThreshold: 1, coolDownSeconds: 0))->toThrow(InvalidArgumentException::class); +}); + +it('validates sendmail argument control characters', function (): void { + expect(fn () => new SendmailConfig('/usr/sbin/sendmail', ["-t\r\n"], 10)) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => new SendmailConfig('/usr/sbin/sendmail', ['-X /tmp/sendmail.log'], 10)) + ->toThrow(InvalidArgumentException::class, 'must not contain whitespace'); +}); + +it('validates outbound max message byte limits in config objects', function (): void { + expect(fn () => new SmtpConfig(host: 'smtp.example.com', maxMessageBytes: 0)) + ->toThrow(InvalidArgumentException::class); + expect(fn () => new SendmailConfig('/usr/sbin/sendmail', ['-t', '-i'], 10, 0)) + ->toThrow(InvalidArgumentException::class); + expect(fn () => new SpoolConfig(directory: sys_get_temp_dir(), maxMessageBytes: 0)) + ->toThrow(InvalidArgumentException::class); +}); + +it('validates explicit smtp auth mechanism against advertised capabilities', function (): void { + $config = new SmtpConfig( + host: 'smtp.example.com', + credentials: new SmtpCredentials('user', 'pass'), + authMechanism: SmtpAuthMechanism::Login, + ); + + $transport = new SmtpTransport($config); + $capabilities = (new SmtpCapabilityParser)->parse([ + '250-mail.example.com', + '250 AUTH PLAIN', + ]); + + $reflection = new ReflectionMethod($transport, 'resolveAuthMechanism'); + + expect(fn () => $reflection->invoke($transport, $capabilities)) + ->toThrow(RuntimeException::class, 'does not advertise AUTH LOGIN'); +}); + +it('formats mail() envelope sender with spaced -f parameter', function (): void { + $transport = new MailFunctionTransport; + $message = EmailMessage::new() + ->from('sender@example.com') + ->returnPath('bounce@example.com'); + + $reflection = new ReflectionMethod($transport, 'envelopeSenderParameter'); + $value = $reflection->invoke($transport, $message); + + expect($value)->toStartWith('-f '); + expect($value)->toContain('bounce@example.com'); +}); + +it('validates mail function max message byte limit argument', function (): void { + expect(fn () => new MailFunctionTransport(maxMessageBytes: 0)) + ->toThrow(InvalidArgumentException::class); +}); + +it('validates pop3 config values', function (): void { + expect(fn () => new Pop3Config('', 110, Pop3Security::None, 'user', 'pass')) + ->toThrow(InvalidArgumentException::class); + expect(fn () => new Pop3Config('mail.example.com', 0, Pop3Security::None, 'user', 'pass')) + ->toThrow(InvalidArgumentException::class); + expect(fn () => new Pop3Config('mail.example.com', 110, Pop3Security::None, '', 'pass')) + ->toThrow(InvalidArgumentException::class); + expect(fn () => new Pop3Config('mail.example.com', 110, Pop3Security::None, 'user', '')) + ->toThrow(InvalidArgumentException::class); + expect(fn () => new Pop3Config('mail.example.com', 110, Pop3Security::None, 'user', 'pass', 0)) + ->toThrow(InvalidArgumentException::class); +}); + +it('validates mailbox folder names', function (): void { + expect(fn () => MailboxFolderNameGuard::assertValid(''))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFolderNameGuard::assertValid("INB\r\nOX"))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFolderNameGuard::assertValid("INB\0OX"))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFolderNameGuard::assertValid(str_repeat('A', 256)))->toThrow(InvalidArgumentException::class); + + MailboxFolderNameGuard::assertValid('Archive/2026'); + + expect(true)->toBeTrue(); +}); + +it('validates mailbox uid and pop3 message number guards', function (): void { + expect(fn () => MailboxUidGuard::assertValid(0))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxUidGuard::assertValid(-1))->toThrow(InvalidArgumentException::class); + expect(fn () => Pop3MessageNumberGuard::assertValid(0))->toThrow(InvalidArgumentException::class); + + MailboxUidGuard::assertValid(1); + Pop3MessageNumberGuard::assertValid(1); + + expect(true)->toBeTrue(); +}); + +it('validates imap part number guard', function (): void { + ImapPartNumberGuard::assertValid('1'); + ImapPartNumberGuard::assertValid('1.2'); + ImapPartNumberGuard::assertValid('10.3.2'); + ImapPartNumberGuard::assertValid('HEADER'); + ImapPartNumberGuard::assertValid('TEXT'); + + expect(fn () => ImapPartNumberGuard::assertValid('1] BODY[]'))->toThrow(InvalidArgumentException::class); + expect(fn () => ImapPartNumberGuard::assertValid('1)'))->toThrow(InvalidArgumentException::class); + expect(fn () => ImapPartNumberGuard::assertValid('1 "bad"'))->toThrow(InvalidArgumentException::class); + expect(fn () => ImapPartNumberGuard::assertValid('1 2'))->toThrow(InvalidArgumentException::class); +}); + +it('validates mailbox flags and allows system/custom forms', function (): void { + MailboxFlagGuard::assertValid('\\Seen'); + MailboxFlagGuard::assertValid('custom-flag_1'); + + expect(fn () => MailboxFlagGuard::assertValid(''))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFlagGuard::assertValid('bad flag'))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFlagGuard::assertValid("bad\r\nflag"))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFlagGuard::assertValid("\0bad"))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFlagGuard::assertValid('!bad'))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFlagGuard::assertValid(' bad'))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFlagGuard::assertValid('bad '))->toThrow(InvalidArgumentException::class); + expect(fn () => MailboxFlagGuard::assertValid(str_repeat('a', 65)))->toThrow(InvalidArgumentException::class); + + $transport = new ImapSocketTransport(new ImapConfig( + host: 'imap.example.com', + port: 143, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + )); + + $normalize = new ReflectionMethod($transport, 'normalizeFlag'); + expect($normalize->invoke($transport, '\\Seen'))->toBe('\\Seen'); + expect($normalize->invoke($transport, 'custom-flag_1'))->toBe('custom-flag_1'); +}); diff --git a/tests/SmtpTransportTest.php b/tests/SmtpTransportTest.php new file mode 100644 index 0000000..0c2c275 --- /dev/null +++ b/tests/SmtpTransportTest.php @@ -0,0 +1,764 @@ + $pipes + */ + private function __construct( + private mixed $process, + private array $pipes, + private string $workDir, + public int $port, + ) {} + + public function __destruct() + { + $this->stop(); + } + + /** + * @param array $scenario + */ + public static function start(array $scenario): self + { + $workDir = sys_get_temp_dir().'/talkingbytes-smtp-'.bin2hex(random_bytes(6)); + mkdir($workDir, 0775, true); + + $scriptPath = $workDir.'/server.php'; + $scenarioPath = $workDir.'/scenario.json'; + $readyPath = $workDir.'/ready.json'; + file_put_contents($scriptPath, self::script()); + file_put_contents($scenarioPath, json_encode($scenario, JSON_THROW_ON_ERROR)); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open([PHP_BINARY, $scriptPath, $scenarioPath, $readyPath], $descriptors, $pipes); + if (! is_resource($process)) { + self::cleanupDirectory($workDir); + + throw new RuntimeException('Unable to start fake SMTP server process.'); + } + + if (is_resource($pipes[0] ?? null)) { + fclose($pipes[0]); + } + + $port = self::waitForReadyPort($readyPath, $process, $pipes); + + return new self($process, $pipes, $workDir, $port); + } + + public function stop(): void + { + foreach ([1, 2] as $index) { + if (! is_resource($this->pipes[$index] ?? null)) { + continue; + } + + fclose($this->pipes[$index]); + $this->pipes[$index] = null; + } + + if (is_resource($this->process)) { + $status = proc_get_status($this->process); + if (($status['running'] ?? false) === true) { + proc_terminate($this->process); + } + + proc_close($this->process); + } + + self::cleanupDirectory($this->workDir); + } + + /** + * @return array{commands:list,data:list,mismatches:list} + */ + public function transcript(): array + { + $reportPath = $this->workDir.'/report.json'; + $deadline = microtime(true) + 1.0; + + while (! is_file($reportPath) && microtime(true) < $deadline) { + usleep(10000); + } + + if (! is_file($reportPath)) { + return ['commands' => [], 'data' => [], 'mismatches' => ['report not found']]; + } + + $decoded = null; + while (microtime(true) < $deadline) { + $decoded = json_decode((string) file_get_contents($reportPath), true); + if (is_array($decoded)) { + break; + } + + usleep(10000); + } + + if (! is_array($decoded)) { + return ['commands' => [], 'data' => [], 'mismatches' => ['invalid report']]; + } + + /** @var list $commands */ + $commands = is_array($decoded['commands'] ?? null) ? array_values($decoded['commands']) : []; + /** @var list $data */ + $data = is_array($decoded['data'] ?? null) ? array_values($decoded['data']) : []; + /** @var list $mismatches */ + $mismatches = is_array($decoded['mismatches'] ?? null) ? array_values($decoded['mismatches']) : []; + + return ['commands' => $commands, 'data' => $data, 'mismatches' => $mismatches]; + } + + private static function cleanupDirectory(string $directory): void + { + foreach (glob($directory.'/*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + + if (is_dir($directory)) { + rmdir($directory); + } + } + + private static function script(): string + { + return <<<'PHP' + [], 'data' => [], 'mismatches' => ['invalid scenario']])); + exit(1); +} + +$server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); +if ($server === false) { + file_put_contents($reportPath, json_encode(['commands' => [], 'data' => [], 'mismatches' => [sprintf('bind failed: %s (%d)', $errstr, $errno)]])); + exit(1); +} + +$name = stream_socket_get_name($server, false); +$port = (int) substr((string) strrchr((string) $name, ':'), 1); +file_put_contents($readyPath, json_encode(['port' => $port])); + +$client = @stream_socket_accept($server, 15); +$transcript = ['commands' => [], 'data' => [], 'mismatches' => []]; + +if ($client === false) { + $transcript['mismatches'][] = 'client did not connect'; + file_put_contents($reportPath, json_encode($transcript)); + fclose($server); + exit(1); +} + +stream_set_timeout($client, 5); + +$greetingDelayMs = (int) ($scenario['greeting_delay_ms'] ?? 0); +if ($greetingDelayMs > 0) { + usleep($greetingDelayMs * 1000); +} + +if (($scenario['skip_greeting'] ?? false) !== true) { + foreach (($scenario['greeting'] ?? ['220 fake-smtp.local ESMTP ready']) as $line) { + fwrite($client, $line . "\r\n"); + } +} + +$expect = is_array($scenario['expect'] ?? null) ? $scenario['expect'] : []; +$deferredResponses = []; +$deferUntilCommands = 0; + +foreach ($expect as $entry) { + if (!is_array($entry)) { + continue; + } + + $type = $entry['type'] ?? 'command'; + + if ($type === 'data') { + $lines = []; + while (true) { + $line = fgets($client, 8192); + if ($line === false) { + $transcript['mismatches'][] = 'expected DATA payload, got stream close'; + break 2; + } + + $trimmed = rtrim($line, "\r\n"); + if ($trimmed === '.') { + break; + } + + $lines[] = $trimmed; + } + + $transcript['data'][] = implode("\n", $lines); + } else { + $line = fgets($client, 8192); + if ($line === false) { + $transcript['mismatches'][] = 'expected command, got stream close'; + break; + } + + $command = rtrim($line, "\r\n"); + $transcript['commands'][] = $command; + + if (isset($entry['equals']) && $command !== $entry['equals']) { + $transcript['mismatches'][] = sprintf('expected "%s", got "%s"', $entry['equals'], $command); + } + + if (isset($entry['regex']) && preg_match((string) $entry['regex'], $command) !== 1) { + $transcript['mismatches'][] = sprintf('regex mismatch "%s" for "%s"', $entry['regex'], $command); + } + } + + $timeoutMs = (int) ($entry['timeout_ms'] ?? 0); + if ($timeoutMs > 0) { + usleep($timeoutMs * 1000); + break; + } + + $entryResponses = is_array($entry['responses'] ?? null) ? $entry['responses'] : []; + $deferUntil = (int) ($entry['defer_responses_until_commands'] ?? 0); + if ($deferUntil > 0) { + $deferUntilCommands = max($deferUntilCommands, $deferUntil); + } + + if ($deferUntilCommands > 0 && count($transcript['commands']) < $deferUntilCommands) { + foreach ($entryResponses as $response) { + $deferredResponses[] = $response; + } + + continue; + } + + foreach ($deferredResponses as $response) { + fwrite($client, $response . "\r\n"); + } + $deferredResponses = []; + $deferUntilCommands = 0; + + foreach ($entryResponses as $response) { + fwrite($client, $response . "\r\n"); + } +} + +foreach ($deferredResponses as $response) { + fwrite($client, $response . "\r\n"); +} + +fclose($client); +fclose($server); +file_put_contents($reportPath, json_encode($transcript)); +PHP; + } + + /** + * @param array $pipes + */ + private static function waitForReadyPort(string $readyPath, mixed $process, array $pipes): int + { + $deadline = microtime(true) + 5.0; + + while (! is_file($readyPath) && microtime(true) < $deadline) { + $status = proc_get_status($process); + if (($status['running'] ?? false) !== true) { + $stderr = is_resource($pipes[2] ?? null) ? (string) stream_get_contents($pipes[2]) : ''; + $reportPath = dirname($readyPath).'/report.json'; + if (is_file($reportPath)) { + $report = json_decode((string) file_get_contents($reportPath), true); + $mismatch = is_array($report['mismatches'] ?? null) ? ($report['mismatches'][0] ?? '') : ''; + if (is_string($mismatch) && str_contains($mismatch, 'bind failed')) { + throw new SkippedWithMessageException('TCP socket bind is unavailable in this environment.'); + } + } + + throw new RuntimeException('Fake SMTP server exited early: '.trim($stderr)); + } + + usleep(10000); + } + + if (! is_file($readyPath)) { + throw new RuntimeException('Fake SMTP server did not become ready in time.'); + } + + $ready = json_decode((string) file_get_contents($readyPath), true, flags: JSON_THROW_ON_ERROR); + $port = (int) ($ready['port'] ?? 0); + if ($port < 1) { + throw new RuntimeException('Fake SMTP server reported an invalid port.'); + } + + return $port; + } +} + +function smtpMessage(string $text = 'Body'): EmailMessage +{ + return EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->subject('SMTP Test') + ->text($text); +} + +function smtpTransportFor( + FakeSmtpServerProcess $server, + SmtpSecurity $security = SmtpSecurity::None, + ?SmtpCredentials $credentials = null, + SmtpAuthMechanism $authMechanism = SmtpAuthMechanism::Auto, + int $timeoutSeconds = 2, + SmtpUtf8Policy $utf8Policy = SmtpUtf8Policy::Auto, + bool $captureTranscript = false, + ?int $maxMessageBytes = null, +): SmtpTransport { + return new SmtpTransport( + new SmtpConfig( + host: '127.0.0.1', + port: $server->port, + security: $security, + credentials: $credentials, + timeoutSeconds: $timeoutSeconds, + localDomain: 'localhost', + authMechanism: $authMechanism, + utf8Policy: $utf8Policy, + captureTranscript: $captureTranscript, + maxMessageBytes: $maxMessageBytes, + ), + ); +} + +it('fails when STARTTLS is required but not advertised', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 SIZE 4096']], + ['equals' => 'RSET', 'responses' => ['250 Reset']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server, SmtpSecurity::StartTlsRequired)->send(smtpMessage()); + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('does not advertise STARTTLS'); + expect($transcript['mismatches'])->toBe([]); +}); + +it('continues when STARTTLS is optional and not advertised', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 PIPELINING']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data with .']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server, SmtpSecurity::StartTlsOptional)->send(smtpMessage()); + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($transcript['commands'])->toContain('QUIT'); + expect($transcript['mismatches'])->toBe([]); +}); + +it('pipelines MAIL FROM and RCPT commands when PIPELINING is advertised', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 PIPELINING']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK'], 'defer_responses_until_commands' => 3], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'RCPT TO:', 'responses' => ['550 No such user']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->cc('bob@example.com') + ->subject('Pipelined') + ->text('Body'); + + $result = smtpTransportFor($server)->send($message); + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->metadata['accepted_count'] ?? null)->toBe(1); + expect($result->metadata['rejected_count'] ?? null)->toBe(1); + expect($transcript['commands'])->toContain('DATA'); + expect($transcript['mismatches'])->toBe([]); +}); + +it('authenticates with AUTH PLAIN when available', function (): void { + $credentials = new SmtpCredentials('user', 'pass'); + $plainPayload = base64_encode("\0user\0pass"); + + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 AUTH PLAIN LOGIN']], + ['equals' => 'AUTH PLAIN '.$plainPayload, 'responses' => ['235 Auth successful']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server, credentials: $credentials)->send(smtpMessage()); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($result->metadata['auth_mechanism'] ?? null)->toBe('plain'); +}); + +it('authenticates with AUTH LOGIN when plain is unavailable', function (): void { + $credentials = new SmtpCredentials('user', 'pass'); + + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 AUTH LOGIN']], + ['equals' => 'AUTH LOGIN', 'responses' => ['334 VXNlcm5hbWU6']], + ['equals' => base64_encode('user'), 'responses' => ['334 UGFzc3dvcmQ6']], + ['equals' => base64_encode('pass'), 'responses' => ['235 Auth successful']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server, credentials: $credentials)->send(smtpMessage()); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($result->metadata['auth_mechanism'] ?? null)->toBe('login'); +}); + +it('fails when explicit auth mechanism is not advertised', function (): void { + $credentials = new SmtpCredentials('user', 'pass'); + + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 AUTH LOGIN']], + ['equals' => 'RSET', 'responses' => ['250 Reset']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server, credentials: $credentials, authMechanism: SmtpAuthMechanism::Plain)->send(smtpMessage()); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('does not advertise AUTH PLAIN'); +}); + +it('fails when message exceeds SMTP SIZE limit', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 SIZE 128']], + ['equals' => 'RSET', 'responses' => ['250 Reset']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server)->send(smtpMessage(str_repeat('x', 512))); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('exceeds SMTP SIZE limit'); +}); + +it('fails when message exceeds configured SMTP max message bytes', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [], + ]); + + $result = smtpTransportFor($server, maxMessageBytes: 64)->send(smtpMessage(str_repeat('x', 512))); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('configured SMTP max message size'); +}); + +it('uses return-path as envelope sender and includes DSN arguments', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 DSN']], + ['regex' => '/^MAIL FROM: RET=HDRS ENVID=[a-f0-9]{16}$/', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO: NOTIFY=SUCCESS,FAILURE,DELAY', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $message = smtpMessage() + ->returnPath('bounce@example.com') + ->deliveryNotification(success: true, failure: true, delay: true, returnFull: false); + + $result = smtpTransportFor($server)->send($message); + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($transcript['mismatches'])->toBe([]); +}); + +it('reports partial success per recipient and preserves metadata', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 OK']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'RCPT TO:', 'responses' => ['550 No such user']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->cc('bob@example.com') + ->subject('Partial') + ->text('Body'); + + $result = smtpTransportFor($server)->send($message); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->response)->toBeInstanceOf(EmailDeliveryReport::class); + expect($result->response->acceptedRecipients)->toBe(['alice@example.com']); + expect($result->response->rejectedRecipients)->toHaveKey('bob@example.com'); + expect($result->metadata['partial_success'] ?? null)->toBeTrue(); + expect($result->metadata['accepted_count'] ?? null)->toBe(1); + expect($result->metadata['rejected_count'] ?? null)->toBe(1); +}); + +it('fails when all recipients are rejected and skips DATA', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 OK']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['550 No such user']], + ['equals' => 'RCPT TO:', 'responses' => ['550 No such user']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('alice@example.com') + ->cc('bob@example.com') + ->subject('Rejected') + ->text('Body'); + + $result = smtpTransportFor($server)->send($message); + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('SMTP rejected all recipients'); + expect($transcript['commands'])->not->toContain('DATA'); +}); + +it('dot-stuffs DATA payload lines that start with a dot', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 OK']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $message = smtpMessage(".first\n..second\nthird"); + $result = smtpTransportFor($server)->send($message); + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($transcript['data'])->toHaveCount(1); + expect($transcript['data'][0])->toContain('..first'); + expect($transcript['data'][0])->toContain('..second'); +}); + +it('times out when server does not send greeting in time', function (): void { + $server = FakeSmtpServerProcess::start([ + 'greeting_delay_ms' => 1500, + ]); + + $result = smtpTransportFor($server, timeoutSeconds: 1)->send(smtpMessage()); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('timed out'); +}); + +it('adds SMTPUTF8 to MAIL FROM when unicode mailbox is used and server supports it', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 SMTPUTF8']], + ['equals' => 'MAIL FROM: SMTPUTF8', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $message = EmailMessage::new() + ->from('séndér@example.com') + ->to('alice@example.com') + ->subject('SMTPUTF8') + ->text('Body'); + + $result = smtpTransportFor($server)->send($message); + $server->stop(); + + expect($result->successful)->toBeTrue(); +}); + +it('fails when utf8 policy rejects unicode mailbox addresses', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 SMTPUTF8']], + ['equals' => 'RSET', 'responses' => ['250 Reset']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $message = EmailMessage::new() + ->from('séndér@example.com') + ->to('alice@example.com') + ->subject('SMTPUTF8') + ->text('Body'); + + $result = smtpTransportFor($server, utf8Policy: SmtpUtf8Policy::Reject)->send($message); + $server->stop(); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('not allowed by current policy'); +}); + +it('captures redacted SMTP transcript metadata when enabled', function (): void { + $credentials = new SmtpCredentials('user', 'pass'); + + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 AUTH LOGIN']], + ['equals' => 'AUTH LOGIN', 'responses' => ['334 VXNlcm5hbWU6']], + ['equals' => base64_encode('user'), 'responses' => ['334 UGFzc3dvcmQ6']], + ['equals' => base64_encode('pass'), 'responses' => ['235 Auth successful']], + ['equals' => 'MAIL FROM:', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server, credentials: $credentials, captureTranscript: true)->send(smtpMessage()); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($result->metadata['smtp_transcript'] ?? null)->toBeArray(); + expect(implode("\n", $result->metadata['smtp_transcript'] ?? []))->toContain('C: [REDACTED]'); +}); + +it('uses sequential MAIL and RCPT flow when PIPELINING is not advertised', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 SIZE 4096']], + ['regex' => '/^MAIL FROM:(?: SIZE=\d+)?$/', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $result = smtpTransportFor($server)->send(smtpMessage()); + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($transcript['commands'][0] ?? null)->toBe('EHLO localhost'); + expect($transcript['commands'][1] ?? '')->toStartWith('MAIL FROM:'); + expect($transcript['commands'][2] ?? null)->toBe('RCPT TO:'); + expect($transcript['commands'][3] ?? null)->toBe('DATA'); + expect($transcript['commands'][4] ?? null)->toBe('QUIT'); +}); + +it('streams large attachment payload through smtp DATA flow', function (): void { + $server = FakeSmtpServerProcess::start([ + 'expect' => [ + ['equals' => 'EHLO localhost', 'responses' => ['250-localhost', '250 SIZE 20971520']], + ['regex' => '/^MAIL FROM: SIZE=\d+$/', 'responses' => ['250 Sender OK']], + ['equals' => 'RCPT TO:', 'responses' => ['250 Recipient OK']], + ['equals' => 'DATA', 'responses' => ['354 End data']], + ['type' => 'data', 'responses' => ['250 Queued']], + ['equals' => 'QUIT', 'responses' => ['221 Bye']], + ], + ]); + + $stream = fopen('php://temp', 'w+b'); + expect($stream)->not->toBeFalse(); + fwrite($stream, str_repeat('S', 1024 * 1024)); + rewind($stream); + + $message = smtpMessage() + ->attachStream($stream, 'large.bin', 'application/octet-stream'); + + $result = smtpTransportFor($server)->send($message); + fclose($stream); + + $transcript = $server->transcript(); + $server->stop(); + + expect($result->successful)->toBeTrue(); + expect($transcript['mismatches'])->toBe([]); + expect($transcript['data'])->toHaveCount(1); + expect(strlen($transcript['data'][0]))->toBeGreaterThan(1024 * 1024); +}); diff --git a/tests/TransportMatrixTest.php b/tests/TransportMatrixTest.php new file mode 100644 index 0000000..12059e8 --- /dev/null +++ b/tests/TransportMatrixTest.php @@ -0,0 +1,196 @@ +from('sender@example.com') + ->to('alice@example.com') + ->subject('Transport') + ->text('Body'); +} + +it('returns success metadata for null email transport', function (): void { + $result = (new NullEmailTransport)->send(baselineEmail()); + + expect($result->successful)->toBeTrue(); + expect($result->metadata['transport'] ?? null)->toBe('null-email'); + expect($result->metadata['recipient_count'] ?? null)->toBe(1); +}); + +it('writes log email payload to configured directory', function (): void { + $directory = getcwd().'/tests/.tmp-log-'.bin2hex(random_bytes(4)); + $transport = new LogEmailTransport(new LogEmailConfig($directory, filenamePrefix: 'mail', dailyFiles: false)); + + $result = $transport->send(baselineEmail()); + $files = glob($directory.'/*') ?: []; + + expect($result->successful)->toBeTrue(); + expect($files)->toHaveCount(1); + expect((string) file_get_contents($files[0]))->toContain('Subject: Transport'); + + foreach ($files as $file) { + unlink($file); + } + rmdir($directory); +}); + +it('fails log transport when configured max message size is exceeded', function (): void { + $directory = getcwd().'/tests/.tmp-log-limit-'.bin2hex(random_bytes(4)); + $transport = new LogEmailTransport(new LogEmailConfig($directory, dailyFiles: false, maxMessageBytes: 32)); + + $result = $transport->send(baselineEmail()->text(str_repeat('x', 256))); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('exceeds configured log max message size'); + + if (is_dir($directory)) { + foreach (glob($directory.'/*') ?: [] as $file) { + unlink($file); + } + rmdir($directory); + } +}); + +it('writes spool eml and metadata sidecar when enabled', function (): void { + $directory = getcwd().'/tests/.tmp-spool-'.bin2hex(random_bytes(4)); + $transport = new SpoolEmailTransport(new SpoolConfig($directory, writeMetadata: true)); + + $result = $transport->send(baselineEmail()->tag('batch', 'alpha')); + $files = glob($directory.'/*') ?: []; + + expect($result->successful)->toBeTrue(); + expect($files)->toHaveCount(2); + expect((bool) preg_grep('/\.eml$/', $files))->toBeTrue(); + expect((bool) preg_grep('/\.json$/', $files))->toBeTrue(); + + foreach ($files as $file) { + unlink($file); + } + rmdir($directory); +}); + +it('fails spool transport when configured max message size is exceeded', function (): void { + $directory = getcwd().'/tests/.tmp-spool-limit-'.bin2hex(random_bytes(4)); + $transport = new SpoolEmailTransport(new SpoolConfig($directory, writeMetadata: false, maxMessageBytes: 32)); + + $result = $transport->send(baselineEmail()->text(str_repeat('x', 256))); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('exceeds configured limit'); + + if (is_dir($directory)) { + foreach (glob($directory.'/*') ?: [] as $file) { + unlink($file); + } + rmdir($directory); + } +}); + +it('builds sendmail command with envelope sender argument', function (): void { + $transport = new SendmailTransport(new SendmailConfig(PHP_BINARY, ['-v'], 2)); + $reflection = new ReflectionMethod($transport, 'buildCommand'); + $command = $reflection->invoke($transport, baselineEmail()->returnPath('bounce@example.com')); + + expect($command[0])->toBe(PHP_BINARY); + expect($command[1])->toBe('-v'); + expect($command[2])->toBe('-fbounce@example.com'); +}); + +it('fails clearly when sendmail binary is not executable', function (): void { + $transport = new SendmailTransport(new SendmailConfig(getcwd().'/tests/not-a-binary', ['-t'], 1)); + $result = $transport->send(baselineEmail()); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('not executable'); +}); + +it('retries failed transport result and succeeds on later attempt', function (): void { + $attempts = 0; + + $inner = new class($attempts) implements EmailTransport + { + public function __construct(private int &$attempts) {} + + public function send(EmailMessage $message): CommunicationResult + { + unset($message); + + $this->attempts++; + if ($this->attempts < 2) { + return CommunicationResult::failure('temporary'); + } + + return CommunicationResult::success(metadata: ['attempt' => $this->attempts]); + } + }; + + $transport = new RetryEmailTransport($inner, new FixedDelayRetryPolicy(3, 0)); + $result = $transport->send(baselineEmail()); + + expect($result->successful)->toBeTrue(); + expect($attempts)->toBe(2); +}); + +it('uses fallback transport and records attempted transports metadata', function (): void { + $primary = new class implements EmailTransport + { + public function send(EmailMessage $message): CommunicationResult + { + unset($message); + + return CommunicationResult::failure('primary failed'); + } + }; + + $fallback = new class implements EmailTransport + { + public function send(EmailMessage $message): CommunicationResult + { + unset($message); + + return CommunicationResult::success(metadata: ['transport' => 'fallback']); + } + }; + + $result = (new FallbackEmailTransport($primary, [$fallback]))->send(baselineEmail()); + + expect($result->successful)->toBeTrue(); + expect($result->metadata['fallback_used'] ?? null)->toBeTrue(); + expect($result->metadata['attempted_transports'] ?? [])->toHaveCount(2); +}); + +it('blocks when rate limited transport exceeds quota', function (): void { + $inner = new class implements EmailTransport + { + public function send(EmailMessage $message): CommunicationResult + { + unset($message); + + return CommunicationResult::success(); + } + }; + + $transport = new RateLimitedEmailTransport($inner, new RateLimiter(1, 60)); + $first = $transport->send(baselineEmail()); + + expect($first->successful)->toBeTrue(); + expect(fn () => $transport->send(baselineEmail()))->toThrow(RuntimeException::class, 'Rate limit exceeded'); +}); diff --git a/tests/TransportProcessCoverageTest.php b/tests/TransportProcessCoverageTest.php new file mode 100644 index 0000000..374978b --- /dev/null +++ b/tests/TransportProcessCoverageTest.php @@ -0,0 +1,192 @@ +send(testMessage()); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('Sendmail exited with code 7'); + expect($result->error)->toContain('simulated failure'); +}); + +it('fails sendmail transport on timeout', function (): void { + $script = createSendmailTestScript(); + $transport = new SendmailTransport(new SendmailConfig($script, ['sleep', '2'], 1)); + + $result = $transport->send(testMessage()); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('timed out'); +}); + +it('fails sendmail transport when configured max message size is exceeded', function (): void { + $script = createSendmailTestScript(); + $transport = new SendmailTransport(new SendmailConfig($script, ['ok'], 2, 32)); + + $result = $transport->send(testMessage()->text(str_repeat('x', 256))); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('exceeds configured limit'); +}); + +it('streams large attachment payload to sendmail stdin path', function (): void { + $script = createSendmailTestScript(); + $transport = new SendmailTransport(new SendmailConfig($script, ['ok'], 2)); + + $stream = fopen('php://temp', 'w+b'); + expect($stream)->not->toBeFalse(); + fwrite($stream, str_repeat('A', 1024 * 1024)); + rewind($stream); + + $message = testMessage()->attachStream($stream, 'large.bin', 'application/octet-stream'); + $result = $transport->send($message); + fclose($stream); + + expect($result->successful)->toBeTrue(); + expect(($result->metadata['size_bytes'] ?? 0))->toBeGreaterThan(1024 * 1024); +}); + +it('streams large attachment payload to spool temp file path', function (): void { + $directory = sys_get_temp_dir().'/tb-spool-stream-'.bin2hex(random_bytes(6)); + mkdir($directory, 0775, true); + + $transport = new SpoolEmailTransport(new SpoolConfig($directory)); + + $stream = fopen('php://temp', 'w+b'); + expect($stream)->not->toBeFalse(); + fwrite($stream, str_repeat('B', 1024 * 1024)); + rewind($stream); + + $message = testMessage()->attachStream($stream, 'large.bin', 'application/octet-stream'); + $result = $transport->send($message); + fclose($stream); + + expect($result->successful)->toBeTrue(); + expect(($result->metadata['size_bytes'] ?? 0))->toBeGreaterThan(1024 * 1024); + + foreach (glob($directory.'/*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + rmdir($directory); +}); + +it('mail function transport returns recipient-aware result and stable metadata', function (): void { + $transport = new MailFunctionTransport; + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('to@example.com') + ->cc('cc@example.com') + ->bcc('bcc@example.com') + ->subject('mail transport') + ->text('body'); + + $result = $transport->send($message); + expect($result->response)->toBeInstanceOf(EmailSendResult::class); + + /** @var EmailSendResult $sendResult */ + $sendResult = $result->response; + $allRecipients = [...$sendResult->acceptedRecipients, ...array_keys($sendResult->rejectedRecipients)]; + sort($allRecipients); + + expect($allRecipients)->toBe(['bcc@example.com', 'cc@example.com', 'to@example.com']); + expect($sendResult->transport)->toBe('mail-function'); + expect($result->metadata['transport'] ?? null)->toBe('mail-function'); + expect($result->metadata['size_bytes'] ?? 0)->toBeGreaterThan(0); + + if ($result->successful) { + expect($result->error)->toBeNull(); + expect($sendResult->acceptedRecipients)->not->toBe([]); + } else { + expect($result->error)->toContain('mail() transport failed'); + expect($sendResult->rejectedRecipients)->not->toBe([]); + } +}); + +it('fails mail function transport when configured max message size is exceeded', function (): void { + $transport = new MailFunctionTransport(maxMessageBytes: 32); + $message = EmailMessage::new() + ->from('sender@example.com') + ->to('to@example.com') + ->subject('mail limit') + ->text(str_repeat('x', 256)); + + $result = $transport->send($message); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('configured mail() max message size'); +}); + +function createSendmailTestScript(): string +{ + $dir = sys_get_temp_dir().'/talkingbytes-sendmail-'.bin2hex(random_bytes(6)); + mkdir($dir, 0775, true); + register_shutdown_function(static function () use ($dir): void { + foreach (glob($dir.'/*') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + + if (is_dir($dir)) { + rmdir($dir); + } + }); + + $script = $dir.'/sendmail-fixture'; + + file_put_contents($script, <<<'PHP' +#!/usr/bin/env php +from('sender@example.com') + ->to('to@example.com') + ->subject('process coverage') + ->text('message body'); +} diff --git a/tests/WebhookEventRedactionTest.php b/tests/WebhookEventRedactionTest.php new file mode 100644 index 0000000..ec5e5a1 --- /dev/null +++ b/tests/WebhookEventRedactionTest.php @@ -0,0 +1,56 @@ + $event, 'payload' => $payload]; + } + }); + + $secret = 'whsec_super_secret'; + $rawPayload = '{"order_id":1001,"token":"payload-secret"}'; + + $transport = new FakeHttpTransport(); + $sender = Webhook::sender(HttpClient::using($transport))->withSecret($secret); + + $delivery = $sender->send( + WebhookMessage::new('order.created') + ->url('https://hooks.example.test/orders?token=url-secret') + ->rawJsonPayload($rawPayload), + ); + + $sent = $transport->sentRequests(); + expect($sent)->toHaveCount(1); + + /** @var string $signatureHeader */ + $signatureHeader = (string) $sent[0]->headers->get(WebhookHeaders::SIGNATURE); + + // Trigger verifier events as well. + Webhook::verifier($secret)->verifyResult($rawPayload, $signatureHeader); + Webhook::verifier($secret)->verifyResult($rawPayload, 't=1,v1=not-a-real-signature'); + + CommunicationEventBus::listen(null); + + expect($delivery->result->successful)->toBeTrue() + ->and($events)->not->toBeEmpty(); + + foreach ($events as $captured) { + $encoded = json_encode($captured['payload'], JSON_THROW_ON_ERROR); + + expect($encoded)->not->toContain($secret) + ->not->toContain($rawPayload) + ->not->toContain($signatureHeader) + ->not->toContain('payload-secret') + ->not->toContain('url-secret'); + } +}); diff --git a/tests/WebhookFakeSenderTest.php b/tests/WebhookFakeSenderTest.php new file mode 100644 index 0000000..6764059 --- /dev/null +++ b/tests/WebhookFakeSenderTest.php @@ -0,0 +1,23 @@ +send( + WebhookMessage::event('order.created') + ->url('https://hooks.example.test/order') + ->payload(['id' => 1]), + ); + + $assert = $fake->assert(); + $assert->assertSentCount(1); + $assert->assertSentTo('https://hooks.example.test/order'); + $assert->assertEvent('order.created'); + $assert->assertPayloadWhere(static fn (mixed $payload): bool => is_array($payload) && $payload['id'] === 1); + expect($assert->last())->not->toBeNull(); +}); diff --git a/tests/WebhookReceiverTest.php b/tests/WebhookReceiverTest.php new file mode 100644 index 0000000..363b3f1 --- /dev/null +++ b/tests/WebhookReceiverTest.php @@ -0,0 +1,84 @@ + 1], + ); + + $event = Webhook::receiver('whsec_test')->receive($payload, $headers); + + expect($event->event)->toBe('order.created'); + expect($event->payload['id'])->toBe(1); + expect($event->deliveryId)->toBe($headers['X-TB-Delivery']); +}); + +it('rejects invalid payload json and invalid signatures', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson( + secret: 'whsec_test', + event: 'order.created', + payload: ['id' => 1], + ); + + $receiver = Webhook::receiver('whsec_test'); + + $badHeaders = $headers; + $badHeaders['X-TB-Signature'] = 't=1,v1=bad'; + + expect(fn() => $receiver->receive($payload, $badHeaders)) + ->toThrow(RuntimeException::class, 'Webhook verification failed'); + + $invalidJson = '{'; + $invalidJsonHeaders = $headers; + $invalidJsonHeaders['X-TB-Signature'] = (new WebhookSignature('whsec_test')) + ->buildHeader($invalidJson, (int) $headers['X-TB-Timestamp']); + + expect(fn() => $receiver->receive($invalidJson, $invalidJsonHeaders)) + ->toThrow(InvalidArgumentException::class, 'Webhook payload must be valid JSON.'); +}); + +it('supports replay store duplicate detection', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson( + secret: 'whsec_test', + event: 'order.created', + payload: ['id' => 1], + deliveryId: 'evt_123', + ); + + $receiver = (new WebhookReceiver(Webhook::verifier('whsec_test'))) + ->withReplayStore(new InMemoryWebhookReplayStore(), 3600); + + $receiver->receive($payload, $headers); + + expect(fn() => $receiver->receive($payload, $headers)) + ->toThrow(RuntimeException::class, 'already been processed'); +}); + +it('validates event and delivery header values using name guard', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson( + secret: 'whsec_test', + event: 'order.created', + payload: ['id' => 1], + ); + + $receiver = Webhook::receiver('whsec_test'); + + $badEvent = $headers; + $badEvent['X-TB-Event'] = "order.created\r\nbad"; + expect(fn() => $receiver->receive($payload, $badEvent)) + ->toThrow(InvalidArgumentException::class, 'Webhook event must not contain control characters.'); + + $badDelivery = $headers; + $badDelivery['X-TB-Delivery'] = ''; + expect(fn() => $receiver->receive($payload, $badDelivery)) + ->toThrow(InvalidArgumentException::class, 'Webhook delivery ID must not be empty.'); +}); diff --git a/tests/WebhookSenderTest.php b/tests/WebhookSenderTest.php new file mode 100644 index 0000000..fa06ca1 --- /dev/null +++ b/tests/WebhookSenderTest.php @@ -0,0 +1,125 @@ +send( + WebhookMessage::new('order.created')->payload(['order_id' => 1001])->url('https://hooks.example.test/orders'), + ); + + expect($delivery->result->successful)->toBeTrue(); + expect($transport->sentRequests())->toHaveCount(2); +}); + +it('does not retry non-transient webhook client errors in retry profile', function (): void { + $transport = new SequenceHttpTransport([ + CommunicationResult::failure( + 'HTTP request failed with status code 400.', + 400, + new HttpResponse(400, '{"error":true}'), + ), + ]); + + $sender = WebhookSender::usingHttpWithRetryProfile(HttpClient::using($transport), attempts: 3, baseDelayMs: 0); + + $delivery = $sender->send( + WebhookMessage::new('order.created')->payload(['order_id' => 1001])->url('https://hooks.example.test/orders'), + ); + + expect($delivery->result->successful)->toBeFalse(); + expect($transport->sentRequests())->toHaveCount(1); +}); + +it('retries transport errors in webhook retry profile', function (): void { + $transport = new SequenceHttpTransport([ + CommunicationResult::failure('Connection refused'), + CommunicationResult::success( + 200, + new HttpResponse(200, '{"ok":true}'), + ), + ]); + + $sender = WebhookSender::usingHttpWithRetryProfile(HttpClient::using($transport), attempts: 2, baseDelayMs: 0); + + $delivery = $sender->send( + WebhookMessage::new('order.created')->payload(['order_id' => 1001])->url('https://hooks.example.test/orders'), + ); + + expect($delivery->result->successful)->toBeTrue(); + expect($transport->sentRequests())->toHaveCount(2); +}); + +it('tracks webhook retry attempts and emits redacted retry events', function (): void { + $events = []; + CommunicationEventBus::listen(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'webhook.')) { + $events[] = ['event' => $event, 'payload' => $payload]; + } + }); + + $transport = new SequenceHttpTransport([ + CommunicationResult::failure('HTTP request failed with status code 500.', 500, new HttpResponse(500, '{"error":true}')), + CommunicationResult::success(200, new HttpResponse(200, '{"ok":true}')), + ]); + + $sender = WebhookSender::usingHttpWithRetryProfile(HttpClient::using($transport), attempts: 2, baseDelayMs: 0) + ->withSecret('whsec_test'); + + $delivery = $sender->send( + WebhookMessage::new('order.created') + ->url('https://hooks.example.test/orders?token=secret-value') + ->payload(['order_id' => 1001]), + ); + + CommunicationEventBus::listen(null); + + $requests = $transport->sentRequests(); + expect($delivery->delivery?->attempts)->toBe(2) + ->and($requests)->toHaveCount(2) + ->and($requests[0]->headers->get(WebhookHeaders::ATTEMPT))->toBe('1') + ->and($requests[1]->headers->get(WebhookHeaders::ATTEMPT))->toBe('2') + ->and($requests[0]->headers->get(WebhookHeaders::DELIVERY)) + ->toBe($requests[1]->headers->get(WebhookHeaders::DELIVERY)) + ->and($events[1]['event'])->toBe('webhook.retry') + ->and($events[1]['payload']['url'])->toContain('token=%5BREDACTED%5D') + ->and((string) ($events[1]['payload']['signature'] ?? ''))->toBe(''); +}); + +it('rejects overriding reserved webhook headers', function (): void { + expect(fn() => WebhookMessage::new('order.created')->header('X-TB-Event', 'override')) + ->toThrow(InvalidArgumentException::class); + + expect(fn() => WebhookMessage::new('order.created')->headers(['Content-Type' => 'text/plain'])) + ->toThrow(InvalidArgumentException::class); +}); + +it('accepts only valid json in raw payload helper', function (): void { + $message = WebhookMessage::new('order.created')->rawJsonPayload('{"ok":true}'); + + expect($message->payload)->toBe('{"ok":true}'); + expect(fn() => WebhookMessage::new('order.created')->rawPayload('{')) + ->toThrow(JsonException::class); +}); diff --git a/tests/WebhookVerifierTest.php b/tests/WebhookVerifierTest.php new file mode 100644 index 0000000..95cf9fe --- /dev/null +++ b/tests/WebhookVerifierTest.php @@ -0,0 +1,42 @@ +buildHeader($payload, $timestamp); + + $verifier = new WebhookVerifier('secret'); + $result = $verifier->verifyResult($payload, $signature); + + expect($result->valid)->toBeTrue(); + expect($result->signaturePresent)->toBeTrue(); + expect($result->signaturePrefix)->toHaveLength(8); + expect($verifier->verify($payload, $signature))->toBeTrue(); +}); + +it('rejects malformed signature and timestamp values with explicit reasons', function (): void { + $verifier = new WebhookVerifier('secret'); + + expect($verifier->verifyResult('{"x":1}', '')->reason)->toBe('missing_signature_header'); + expect($verifier->verifyResult('{"x":1}', 't=abc,v1=abcdef')->reason)->toBe('malformed_signature'); + expect($verifier->verifyResult('{"x":1}', 't=1,v1=nothex')->reason)->toBe('malformed_signature'); + expect($verifier->verifyResult('{"x":1}', 'v1=abcd', 'bad')->reason)->toBe('invalid_timestamp'); +}); + +it('rejects expired and mismatched signatures', function (): void { + $payload = '{"id":1}'; + $oldTimestamp = time() - 1000; + + $oldSignature = (new WebhookSignature('secret'))->buildHeader($payload, $oldTimestamp); + $verifier = new WebhookVerifier('secret', 300); + + expect($verifier->verifyResult($payload, $oldSignature)->reason)->toBe('expired_timestamp'); + + $wrong = (new WebhookSignature('wrong'))->buildHeader($payload, time()); + expect($verifier->verifyResult($payload, $wrong)->reason)->toBe('signature_mismatch'); +});