phpdot / outbox
SMTP proxy server for PHP. Accept emails, store them, deliver via AWS SES or any provider.
v1.0.0
2026-03-27 21:18 UTC
Requires
- php: >=8.2
- ext-mbstring: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
README
SMTP proxy server for PHP. Accept emails via SMTP, store them, deliver via AWS SES or any provider.
Install
composer require phpdot/outbox
Requires PHP 8.2+. Zero runtime dependencies.
Quick Start
use PHPdot\Mail\Outbox\SmtpHandler; use PHPdot\Mail\Outbox\Server\StreamServer; use PHPdot\Mail\Outbox\Server\Event\AuthEvent; use PHPdot\Mail\Outbox\Server\Event\MessageEvent; $handler = new SmtpHandler(); $handler->onAuth(function (AuthEvent $event, $ctx): void { if ($event->credentials->username === 'api' && $event->credentials->password === 'secret') { $event->accept(); } else { $event->reject('Invalid credentials'); } }); $handler->onMessage(function (MessageEvent $event, $ctx): void { file_put_contents("/var/mail/queue/{$event->id}.eml", $event->message); $event->accept(); }); $server = new StreamServer($handler, port: 2525); $server->start();
Server
The library ships with StreamServer — a concurrent TCP server using stream_select(). No extensions needed.
$server = new StreamServer( handler: $handler, host: '0.0.0.0', port: 2525, hostname: 'mail.example.com', tlsRequired: false, authRequired: true, ); // Optional: enable STARTTLS $server->enableTls('/path/to/cert.pem', '/path/to/key.pem'); $server->start();
For Swoole, Workerman, or ReactPHP — implement ServerInterface or use ServerConnection directly:
use PHPdot\Mail\Outbox\Connection\ServerConnection; // On each TCP connection: $conn = new ServerConnection($handler, hostname: 'mail.example.com'); $greeting = $conn->greeting(); // send to client $responses = $conn->onData($bytes); // feed client bytes, get SMTP responses $conn->needsTlsUpgrade(); // check if TLS upgrade needed $conn->tlsUpgraded(); // notify TLS complete $conn->isClosing(); // check if QUIT received
Handlers
Every handler receives a typed event and the connection context. Call $event->accept() or $event->reject() to control the SMTP response.
use PHPdot\Mail\Outbox\Server\Event\ConnectEvent; use PHPdot\Mail\Outbox\Server\Event\AuthEvent; use PHPdot\Mail\Outbox\Server\Event\MailFromEvent; use PHPdot\Mail\Outbox\Server\Event\RcptToEvent; use PHPdot\Mail\Outbox\Server\Event\MessageEvent; $handler->onConnect(function (ConnectEvent $event, $ctx): void { // $event->clientIp $event->accept(); }); $handler->onAuth(function (AuthEvent $event, $ctx): void { // $event->credentials->username // $event->credentials->password // $event->credentials->mechanism (PLAIN, LOGIN, XOAUTH2) $event->accept(); }); $handler->onMailFrom(function (MailFromEvent $event, $ctx): void { // $event->sender // $event->size // $event->bodyType $event->accept(); }); $handler->onRcptTo(function (RcptToEvent $event, $ctx): void { // $event->recipient $event->accept(); }); $handler->onMessage(function (MessageEvent $event, $ctx): void { // $event->id // $event->sender // $event->recipients // $event->message (raw .eml) // $event->size $event->accept(); }); $handler->onQueued(function (MessageEvent $event, $ctx): void { // Fire-and-forget after message is accepted });
Storage
use PHPdot\Mail\Outbox\Storage\FilesystemStorage; use PHPdot\Mail\Outbox\DataType\DTO\MessageEnvelope; use PHPdot\Mail\Outbox\DataType\Enum\QueueState; $storage = new FilesystemStorage('/var/mail/outbox'); $handler->onMessage(function (MessageEvent $event, $ctx) use ($storage): void { $envelope = new MessageEnvelope( id: $event->id, sender: $event->sender, recipients: $event->recipients, size: $event->size, state: QueueState::Pending, authUser: $ctx->user() ?? '', clientIp: $ctx->clientIp, receivedAt: new \DateTimeImmutable(), ); $storage->store($envelope, $event->message); $event->accept(); });
For S3, Redis, or database storage — implement StorageInterface.
Delivery
use PHPdot\Mail\Outbox\Delivery\DeliveryWorker; use PHPdot\Mail\Outbox\Delivery\SesTransport; $transport = new SesTransport($yourSesClient); $worker = new DeliveryWorker($storage, $transport); $worker->onDelivered(function (string $id, string $providerMsgId, string $transport): void { echo "Delivered $id via $transport ($providerMsgId)\n"; }); $worker->onFailed(function (string $id, string $error, int $attempts): void { echo "Failed $id after $attempts attempts: $error\n"; }); // Process a batch $worker->processBatch(10); // Or run continuously $worker->run();
SMTP relay transport
use PHPdot\Mail\Outbox\Delivery\SmtpRelayTransport; $transport = new SmtpRelayTransport( host: 'smtp.postmarkapp.com', port: 587, encryption: 'tls', username: 'your-api-token', password: 'your-api-token', ); $worker = new DeliveryWorker($storage, $transport); $worker->run();
What's Covered
SMTP Server (RFC 5321)
- EHLO / HELO handshake
- STARTTLS upgrade (RFC 3207)
- AUTH PLAIN, LOGIN, XOAUTH2 (RFC 4954, RFC 7628)
- MAIL FROM with SIZE check (RFC 1870)
- RCPT TO with multiple recipients
- DATA with dot-stuffing
- RSET, NOOP, QUIT
- PIPELINING support (RFC 2920)
- 8BITMIME support (RFC 6152)
Security
- Require TLS before AUTH
- Require AUTH before sending
- Max message size enforcement
- Max recipients per message
- Max bad commands before disconnect
- Configurable: TLS optional, AUTH optional
Storage
- Pluggable storage interface
- Filesystem storage (works out of the box)
- Queue states: pending → sending → sent / failed
- Retry tracking with metadata
Delivery
- Pluggable transport interface
- AWS SES transport (via SesClientInterface)
- SMTP relay transport (any SMTP provider)
- Exponential backoff retry (30s → 2h)
- Stuck message recovery
- Event hooks: onDelivered, onFailed, onRetry
License
MIT