zenomailer / zenomailer
ZenoMailer - Modern PHP 8.2+ email library with decoupled architecture, middleware pipeline, and multiple transport layers (SMTP, HTTP APIs, JMAP)
Requires (Dev)
- phpmailer/phpmailer: ^6.9
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
- ext-mbstring: Needed to send email in multibyte encoding charset or decode encoded addresses
- ext-openssl: Needed for secure SMTP sending (STARTTLS and implicit TLS)
- monolog/monolog: Recommended PSR-3 logger implementation
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-20 10:32:48 UTC
README
Modern PHP 8.2+ email library with decoupled architecture
A clean-slate email library built on modern principles with a strict separation of concerns.
Why ZenoMailer?
Traditional email libraries are monolithic. ZenoMailer embraces modern PHP architecture:
- Decoupled Services - Envelope (data) → Middleware (processing) → Transport (delivery)
- PSR-15-style Middleware - Composable "onion" pipeline for features like CSS inlining and dark mode
- Multiple Transports - SMTP, HTTP APIs (Brevo, Postmark, SES), JMAP - all via simple DSN strings
- Type-Safe - Full PHP 8.2+ type declarations with strict mode
- Performance - HTTP APIs are 2-5x faster than SMTP; SMTP batches get ~2x more from connection reuse (measured)
- Live-Verified - every transport exercised against real providers (Gmail, Brevo, Postmark, Stalwart); every failure hit in testing became a tested, documented behavior
Quick Start
SMTP (Universal)
use ZenoMailer\Envelope; use ZenoMailer\MailerBuilder; $mailer = MailerBuilder::create() ->withDsn('smtp://user:pass@smtp.gmail.com:587?starttls=true') ->build(); $envelope = new Envelope(); $envelope ->setFrom('sender@example.com', 'Sender Name') ->addTo('recipient@example.com', 'Recipient Name') ->setSubject('Hello from ZenoMailer') ->setHtmlBody('<h1>Hello World</h1>') ->setTextBody('Hello World'); $result = $mailer->send($envelope); if ($result->isSuccess()) { echo "Sent! ID: " . $result->getMessageId(); }
Brevo API (Fast, Free tier available)
$mailer = MailerBuilder::create() ->withDsn('brevo://YOUR_API_KEY@default') ->build(); // Same envelope code as above $result = $mailer->send($envelope);
Postmark API
$mailer = MailerBuilder::create() ->withDsn('postmark://SERVER_TOKEN@default') ->build(); $result = $mailer->send($envelope);
Amazon SES
$mailer = MailerBuilder::create() ->withDsn('ses://ACCESS_KEY:SECRET_KEY@default?region=us-east-1') ->build(); $result = $mailer->send($envelope);
JMAP
$mailer = MailerBuilder::create() ->withDsn('jmap://:API_TOKEN@jmap.example.com') // account id auto-discovered ->build(); $result = $mailer->send($envelope);
With Middleware and Telemetry
$mailer = MailerBuilder::create() ->withDsn('brevo://YOUR_API_KEY@default') ->enableCssInlining() // Inline CSS automatically (middleware) ->enableDarkModeGuard() // Ensure dark mode compatibility (middleware) ->enableTelemetry() // Delivery logging via PSR-3 (wraps the transport) ->build(); $result = $mailer->send($envelope);
Usage Guide
Building an Envelope
use ZenoMailer\Envelope; use ZenoMailer\Address; $envelope = new Envelope(); // Basic fields $envelope->setFrom('sender@example.com', 'Sender Name'); $envelope->addTo('recipient@example.com', 'Recipient Name'); $envelope->setSubject('Email Subject'); // HTML and text content $envelope->setHtmlBody('<h1>Hello</h1><p>HTML content</p>'); $envelope->setTextBody('Hello\nText content'); // Optional fields $envelope->addCc('cc@example.com', 'CC Name'); $envelope->addBcc('bcc@example.com'); $envelope->setReplyTo('reply@example.com', 'Reply Name'); // Attachments $envelope->addAttachment('/path/to/file.pdf', 'document.pdf'); // Embedded image, referenced from the HTML body via CID // (setHtmlBody replaces the body — include your full content) $envelope->addEmbeddedImage('/path/to/logo.png', 'logo'); $envelope->setHtmlBody('<img src="cid:logo" alt="Logo"><h1>Hello</h1><p>HTML content</p>'); // Custom headers $envelope->setHeaders(['X-Custom' => 'value']);
Configuring Transports
SMTP with STARTTLS:
$mailer = MailerBuilder::create() ->withDsn('smtp://user:pass@smtp.example.com:587?starttls=true') ->build();
SMTP with implicit TLS:
$mailer = MailerBuilder::create() ->withDsn('smtps://user:pass@smtp.example.com:465') ->build();
SMTP connection reuse (keepalive):
By default the transport reuses one authenticated connection across sends (validated with NOOP, reconnecting automatically if the server dropped it). This makes batch sending ~2x faster. Disable it, or cap messages per connection, via DSN options:
// One connection for the whole batch (default) $mailer = MailerBuilder::create() ->withDsn('smtp://user:pass@smtp.example.com:587?starttls=true') ->build(); foreach ($envelopes as $envelope) { $mailer->send($envelope); // reuses the connection } $mailer->getTransport()->disconnect(); // optional: close explicitly // Fresh connection per email: // ->withDsn('smtp://user:pass@host:587?starttls=true&keepalive=false') // Recycle the connection after N messages: // ->withDsn('smtp://user:pass@host:587?max_messages_per_connection=100')
Brevo (with async support):
$mailer = MailerBuilder::create() ->withDsn('brevo://API_KEY@default?async=true') // async=true for concurrent sending ->build();
Middleware Pipeline
Middleware processes emails before sending (telemetry wraps the transport instead — see the Usage Guide):
$mailer = MailerBuilder::create() ->withDsn('brevo://API_KEY@default') ->enableCssInlining() // Inline <style> tags ->enableDarkModeGuard() // Add dark mode support ->enableTelemetry() // Delivery logging via PSR-3 ->build();
DKIM Signing (SMTP & SES)
Sign outgoing messages with DKIM (RFC 6376, rsa-sha256, relaxed/relaxed) so receivers can validate them against your DNS:
# Generate a key pair (once): openssl genrsa -out dkim.private.pem 2048 openssl rsa -in dkim.private.pem -pubout -out dkim.public.pem
$mailer = MailerBuilder::create() ->withDsn('smtp://user:pass@smtp.example.com:587?starttls=true') ->withDkim('example.com', 's1', '/path/to/dkim.private.pem', ['verify_dns' => true]) ->build();
⚠️ Publish the key BEFORE signing. An unverifiable signature is worse than no signature: receivers evaluate DKIM as failed, and Gmail hard-rejects such mail with
550 5.7.30(verified empirically). Theverify_dnsoption makes the signer refuse to sign until the TXT record is live (result is cached per signer instance).
Publish the public key in DNS as a TXT record at s1._domainkey.example.com:
v=DKIM1; k=rsa; p=<base64 public key without headers>
Test your setup with aboutmy.email or by sending to a Gmail address and checking "Show original" (should show DKIM: PASS).
Embedded Images (CID)
Embed images directly in the HTML body (no external hosting needed). Supported by SMTP, SES and Postmark. The Brevo API does not support inline images - use hosted image URLs there.
$envelope ->setHtmlBody('<img src="cid:logo" alt="Logo"><h1>Hello</h1>') ->addEmbeddedImage('/path/to/logo.png', 'logo'); // type guessed from extension
Internationalized Addresses (IDN & SMTPUTF8)
Non-ASCII domains are punycode-converted automatically (requires ext-intl); UTF-8 local parts are supported via RFC 6531 SMTPUTF8, which the SMTP transport negotiates automatically:
$envelope ->setFrom('sender@example.com') ->addTo('用户@example.com') // UTF-8 local part -> SMTPUTF8 negotiated ->addTo('info@例え.test'); // IDN domain -> punycode (xn--r8jz45g.test) $mailer->send($envelope);
Transport support: SMTP ✓ (negotiates SMTPUTF8, fails clearly if the server doesn't advertise it), Brevo/Postmark ✓ (JSON APIs), SES ✗ (AWS does not support SMTPUTF8 - UTF-8 local parts are rejected explicitly; IDN domains work via punycode).
Batch Sending
sendBatch() works on every transport (Brevo delivers concurrently with async=true; the rest sequentially) — and every envelope still goes through the middleware pipeline:
$results = $mailer->sendBatch($envelopes); foreach ($results as $result) { if ($result->isSuccess()) { echo "Sent: " . $result->getMessageId(); } }
Features
🎯 Core Architecture
- Envelope - Pure data object for message content (no logic)
- Middleware Pipeline - Composable processing layers (PSR-15 style)
- Transport Layer - Pluggable delivery mechanisms
🚀 Transports
- SMTP - Universal, works everywhere
- Brevo - Free tier (300 emails/day), 2-3x faster than SMTP, async support
- Postmark - Premium deliverability and tracking
- Amazon SES - Scalable, pay-as-you-go
- JMAP - Modern JSON-based protocol
🎨 Middleware
- CSS Inliner - Automatically inline CSS for email clients
- Dark Mode Guard - Ensure compatibility with dark mode
- Custom - Easy to create your own
- Delivery Telemetry - PSR-3 logging of real delivery outcomes (transport decorator, not middleware)
🛡️ Resilience
- Auto-retry - transient failures (SMTP 4xx, HTTP 429/5xx) retried with exponential backoff + jitter
- Rate limiting - token bucket per transport (
withRateLimit(300, 'day')) - Failover - backup transport chain (
withFailover($backup)) - Spool/outbox - failed sends queued to disk, re-sent later (
withSpool($dir)+flushSpool()) — contact forms survive provider outages
🔐 Deliverability
- DKIM signing - RFC 6376, rsa-sha256, relaxed/relaxed (SMTP & SES)
- Embedded images - CID references from the HTML body (SMTP, SES, Postmark)
- Internationalized addresses - IDN punycode + RFC 6531 SMTPUTF8
- RFC-compliant MIME - RFC 2047 header encoding, line folding, dot-stuffing, automatic line-length protection (overlong bodies upgrade to quoted-printable)
- Streaming attachments - attachment content streams from disk to the SMTP socket; peak memory stays constant regardless of attachment size (a 9.3MB attachment costs ~2MB of RAM, measured)
Installation
Available on Packagist.
composer require zenomailer/zenomailer
Beta note: while the project is in beta, require it with a stability flag:
composer require zenomailer/zenomailer:^0.1.0@beta
Documentation
- Complete Usage Guide - every option, transport and middleware, reference-style
- PHPMailer vs ZenoMailer - honest feature-by-feature comparison
- Migration Guide - Coming from PHPMailer?
- Changelog - every release, Keep-a-Changelog format
- Benchmark Results
- Contributing - development workflow and release checklist
Requirements
- PHP 8.2 or later
- ext-curl (for HTTP API transports)
- ext-openssl (for SMTP TLS and DKIM signing)
- ext-intl (for internationalized domains)
- ext-mbstring (optional - needed only for non-UTF-8 charsets)
Performance
Benchmark Results
Run benchmarks to compare transports:
# Compare SMTP vs Brevo (sync) php benchmark-brevo.php # Compare SMTP vs Brevo (sync and async) php benchmark-async.php
Typical (measured):
- SMTP, fresh connection per email: ~3.9s/email (~84s for 20)
- SMTP, connection reuse (default): ~1.7s/email (~10s for 20)
- Brevo Sync: ~0.75s/email (~15s for 20)
- Brevo Async: ~1-2s for 20 (concurrent HTTP requests) ⚡
HTTP APIs are 2-5x faster than SMTP per email; async batching is faster still.
Examples
Check the examples/ directory:
examples/
├── modern-basic-usage.php # SMTP example
├── modern-brevo-api.php # Brevo API example
├── modern-postmark-api.php # Postmark example
├── modern-ses-api.php # Amazon SES example
├── modern-jmap.php # JMAP protocol example
├── modern-custom-middleware.php # Custom middleware
├── modern-dkim.php # DKIM signing
└── modern-with-logging.php # PSR-3 logging
Versioning & Compatibility
ZenoMailer follows Semantic Versioning:
- 0.x (current betas) — the API may still change between releases; check the Changelog when upgrading
- 1.0.0 (upcoming) — the API freezes: breaking changes only in major versions, deprecations kept for at least one minor
License
MIT