Mailer
Direct SMTP delivery with STARTTLS, attachments and connection keepalive: without Swiftmailer, without Symfony.
Introduction
Email delivery in PHP often means: Swiftmailer or Symfony Mailer with dozens of dependencies. Or mail(), with all its limitations. Anyone who needs a reliable SMTP client that sends HTML emails with attachments while keeping the connection open faces an unsatisfying choice.
jardisadapter/mailer implements SMTP directly at the socket level, with pure PHP and ext-openssl:
- Zero external dependencies — no Swiftmailer, no Symfony, only PHP built-ins
- STARTTLS and Implicit SSL — TLS 1.2/1.3 on port 587 (STARTTLS) or port 465 (Implicit SSL)
- AUTH LOGIN and AUTH PLAIN — automatic detection of the mechanism supported by the server
- HTML + Text + Attachments — multipart MIME assembled automatically
- Inline images —
withEmbeddedImage()for<img src="cid:...">in HTML emails - Connection keepalive — one TCP connection for hundreds of emails, with NOOP health check
- Retry with Exponential Backoff — automatic retries on connection failures
- Batch sending —
sendBatch()with partial result tracking
Installation
composer require jardisadapter/mailerGitHub: jardisAdapter/mailer
Required PHP extensions: ext-openssl, ext-mbstring
Basic Usage
Sending an Email
use JardisAdapter\Mailer\Mailer;
use JardisAdapter\Mailer\Config\SmtpConfig;
use JardisAdapter\Mailer\Config\Encryption;
use JardisAdapter\Mailer\Data\MailMessage;
$mailer = new Mailer(new SmtpConfig(
host: 'smtp.example.com',
port: 587,
encryption: Encryption::Tls,
username: 'user@example.com',
password: 'secret',
));
$message = MailMessage::create()
->withFrom('noreply@example.com', 'My App')
->withTo('customer@example.com', 'John Doe')
->withSubject('Your Order #4711')
->withText('Thank you for your order.')
->withHtml('<h1>Order Confirmation</h1><p>Thank you!</p>');
$mailer->send($message);Default Sender from Config
$mailer = new Mailer(new SmtpConfig(
host: 'smtp.example.com',
username: 'user@example.com',
password: 'secret',
fromAddress: 'noreply@example.com',
fromName: 'My Application',
));
// From is set automatically if not explicitly provided
$message = MailMessage::create()
->withTo('customer@example.com')
->withSubject('Welcome!')
->withText('Your account has been created.');
$mailer->send($message);MailMessage — Fluent Builder
$message = MailMessage::create()
// Sender and recipients
->withFrom('sender@example.com', 'Sender Name')
->withTo('recipient@example.com', 'Recipient Name')
->withTo('another@example.com') // Additive — multiple recipients
->withCc('cc@example.com')
->withBcc('bcc@example.com')
->withReplyTo('reply@example.com')
// Content
->withSubject('Subject')
->withText('Plain text version')
->withHtml('<h1>HTML version</h1>')
// Attachments
->withAttachment($pdfContent, 'invoice.pdf', 'application/pdf')
->withAttachment($csvContent, 'export.csv', 'text/csv')
// Inline images
->withEmbeddedImage($logoContent, 'logo.png', 'image/png')
// Custom headers
->withHeader('X-Mailer', 'Jardis Mailer')
->withHeader('X-Priority', '1');MailMessage is immutable: every with* method returns a new instance.
Text, HTML or Both
| Combination | MIME type |
|---|---|
| Text only | text/plain; charset=UTF-8 |
| HTML only | text/html; charset=UTF-8 |
| Text + HTML | multipart/alternative |
| + Attachments | multipart/mixed (outer boundary) |
| + Inline images | multipart/related (outer boundary) |
Attachments
Regular Attachments
$pdf = file_get_contents('/tmp/invoice.pdf');
$message = MailMessage::create()
->withFrom('noreply@example.com')
->withTo('customer@example.com')
->withSubject('Your Invoice')
->withText('Please find your invoice attached.')
->withAttachment($pdf, 'invoice-2025-001.pdf', 'application/pdf');Inline Images (Embedded Images)
$logo = file_get_contents('/var/app/assets/logo.png');
$message = MailMessage::create()
->withFrom('noreply@example.com')
->withTo('customer@example.com')
->withSubject('Newsletter')
->withEmbeddedImage($logo, 'logo.png', 'image/png')
->withHtml('<img src="cid:logo.png"> <h1>Newsletter</h1>');Every inline image automatically receives a Content-ID that is referenced in the HTML via cid:.
Batch Sending
$messages = [
MailMessage::create()->withTo('a@example.com')->withSubject('Mail 1')->withText('...'),
MailMessage::create()->withTo('b@example.com')->withSubject('Mail 2')->withText('...'),
MailMessage::create()->withTo('c@example.com')->withSubject('Mail 3')->withText('...'),
];
$result = $mailer->sendBatch($messages);
$result->successCount(); // 2
$result->failureCount(); // 1
$result->isAllSuccessful(); // false
foreach ($result->failed() as $failure) {
echo $failure['error']->getMessage();
}All emails are sent over one TCP connection (keepalive). disconnect() is only called after the entire batch.
Retry
$config = new SmtpConfig(
host: 'smtp.example.com',
username: 'user@example.com',
password: 'secret',
maxRetries: 3, // 3 retries
retryDelayMs: 200, // Base delay
);Exponential Backoff
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 200ms |
| 3 | 400ms |
| 4 | 800ms |
What is retried?
| Error | Retry? |
|---|---|
Connection error (SmtpConnectionException) | Yes |
| Temporary SMTP errors (4xx) | Yes |
| Permanent SMTP errors (5xx) | No — thrown immediately |
Connection Keepalive
The SmtpTransport keeps the TCP connection open and checks it before every send via NOOP:
Before every send():
Socket present? → send NOOP → 250? → reuse connection
→ error? → reconnect
No socket? → connect (EHLO, STARTTLS, AUTH)Explicit disconnect:
$mailer->disconnect(); // QUIT → 221 → fclose()Encryption
| Mode | Port | Behavior |
|---|---|---|
Encryption::Tls | 587 | Plain TCP → STARTTLS → TLS 1.2/1.3 upgrade |
Encryption::Ssl | 465 | Implicit SSL (ssl:// prefix) |
Encryption::None | 25 | No TLS (local development only) |
use JardisAdapter\Mailer\Config\Encryption;
// STARTTLS (standard)
new SmtpConfig(host: 'smtp.example.com', port: 587, encryption: Encryption::Tls);
// Implicit SSL
new SmtpConfig(host: 'smtp.example.com', port: 465, encryption: Encryption::Ssl);
// Without TLS (MailHog, Mailpit etc.)
new SmtpConfig(host: 'localhost', port: 1025, encryption: Encryption::None);SSL verification can be disabled for local development: verifySsl: false.
Error Handling
| Exception | Cause |
|---|---|
MailMessageException | Invalid message (missing From, To or body) |
SmtpConnectionException | Host unreachable, TLS error, timeout |
SmtpAuthenticationException | AUTH LOGIN/PLAIN rejected |
SmtpTransportException | SMTP protocol error (code via getCode()) |
All exceptions implement MailerExceptionInterface:
use JardisSupport\Contract\Mailer\MailerExceptionInterface;
try {
$mailer->send($message);
} catch (MailerExceptionInterface $e) {
// Catches all mailer exceptions
}Custom Transport (Tests)
$captured = null;
$transport = function ($envelope) use (&$captured): void {
$captured = $envelope;
};
$mailer = new Mailer(
new SmtpConfig(host: 'localhost'),
$transport,
);
$mailer->send($message);
// $captured->sender === 'noreply@example.com'
// $captured->recipients === ['customer@example.com']
// $captured->rawMessage contains the complete MIME stringArchitecture
The package follows the Closure-Orchestrator-Pattern, the Mailer binds three handler stages:
Mailer ← Orchestrator
├── Transformers (MailMessage → MailMessage)
│ ├── DefaultFrom ← Set default sender
│ └── MessageValidator ← Validation (From, To, Body)
├── Encoder (MailMessage → Envelope)
│ └── MimeEncoder ← RFC 2822 MIME assembly
└── Transport (Envelope → void)
└── SmtpTransport ← Socket-based SMTP clientDirectory Structure
src/
├── Mailer.php ← Orchestrator
├── Config/
│ ├── SmtpConfig.php ← SMTP configuration
│ └── Encryption.php ← Enum: Tls, Ssl, None
├── Data/
│ ├── MailMessage.php ← Immutable message builder
│ ├── Address.php ← Email address VO
│ ├── Attachment.php ← Attachment VO
│ ├── Envelope.php ← Wire-level representation
│ └── BatchResult.php ← Batch result
├── Handler/
│ ├── DefaultFrom.php ← Default sender
│ ├── MessageValidator.php ← Validation
│ ├── MimeEncoder.php ← MIME assembly
│ └── SmtpTransport.php ← SMTP transport
└── Exception/
├── MailMessageException.php
├── SmtpAuthenticationException.php
├── SmtpConnectionException.php
└── SmtpTransportException.phpAPI Reference
Mailer
| Method | Signature | Description |
|---|---|---|
send | send(MailMessageInterface $message): void | Send one email |
sendBatch | sendBatch(array $messages): BatchResult | Batch sending |
disconnect | disconnect(): void | Close SMTP connection |
SmtpConfig
| Property | Type | Default | Description |
|---|---|---|---|
host | string | — | SMTP host |
port | int | 587 | Port |
encryption | Encryption | Tls | Encryption mode |
username | ?string | null | AUTH username |
password | ?string | null | AUTH password |
timeout | int | 30 | Socket timeout |
fromAddress | ?string | null | Default sender address |
fromName | ?string | null | Default sender name |
maxRetries | int | 0 | Retry attempts |
retryDelayMs | int | 100 | Base delay |
verifySsl | bool | true | SSL verification |
MailMessage
| Method | Signature | Description |
|---|---|---|
create | static create(): self | New message |
withFrom | withFrom(string $email, ?string $name = null): self | Sender |
withTo | withTo(string $email, ?string $name = null): self | Recipient (additive) |
withCc | withCc(string $email, ?string $name = null): self | CC (additive) |
withBcc | withBcc(string $email, ?string $name = null): self | BCC (additive) |
withReplyTo | withReplyTo(string $email, ?string $name = null): self | Reply-To |
withSubject | withSubject(string $subject): self | Subject |
withText | withText(string $text): self | Plain text body |
withHtml | withHtml(string $html): self | HTML body |
withAttachment | withAttachment(string $content, string $filename, string $type = '...'): self | Attachment |
withEmbeddedImage | withEmbeddedImage(string $content, string $filename, string $type = '...'): self | Inline image |
withHeader | withHeader(string $name, string $value): self | Custom header |
Complete Example
Transactional order confirmation with HTML, logo and PDF invoice:
use JardisAdapter\Mailer\Mailer;
use JardisAdapter\Mailer\Config\SmtpConfig;
use JardisAdapter\Mailer\Config\Encryption;
use JardisAdapter\Mailer\Data\MailMessage;
$mailer = new Mailer(new SmtpConfig(
host: $_ENV['MAIL_HOST'],
port: (int) $_ENV['MAIL_PORT'],
encryption: Encryption::Tls,
username: $_ENV['MAIL_USERNAME'],
password: $_ENV['MAIL_PASSWORD'],
fromAddress: 'shop@example.com',
fromName: 'Example Shop',
maxRetries: 2,
retryDelayMs: 500,
));
$logo = file_get_contents('/var/app/assets/logo.png');
$invoice = file_get_contents("/tmp/invoices/{$orderId}.pdf");
$message = MailMessage::create()
->withTo($customer->getEmail(), $customer->getName())
->withSubject("Order Confirmation #{$orderId}")
->withText("Thank you for your order #{$orderId}.")
->withHtml("
<img src=\"cid:logo.png\" width=\"200\">
<h1>Order Confirmation</h1>
<p>Thank you for your order <strong>#{$orderId}</strong>.</p>
<p>Please find your invoice attached.</p>
")
->withEmbeddedImage($logo, 'logo.png', 'image/png')
->withAttachment($invoice, "invoice-{$orderId}.pdf", 'application/pdf')
->withHeader('X-Order-Id', $orderId);
$mailer->send($message);
$mailer->disconnect();