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
-
-
## 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