Skip to content

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 imageswithEmbeddedImage() 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 sendingsendBatch() with partial result tracking

Installation

bash
composer require jardisadapter/mailer

GitHub: jardisAdapter/mailer

Required PHP extensions: ext-openssl, ext-mbstring

Basic Usage

Sending an Email

php
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

php
$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

php
$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

CombinationMIME type
Text onlytext/plain; charset=UTF-8
HTML onlytext/html; charset=UTF-8
Text + HTMLmultipart/alternative
+ Attachmentsmultipart/mixed (outer boundary)
+ Inline imagesmultipart/related (outer boundary)

Attachments

Regular Attachments

php
$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)

php
$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

php
$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

php
$config = new SmtpConfig(
    host: 'smtp.example.com',
    username: 'user@example.com',
    password: 'secret',
    maxRetries: 3,          // 3 retries
    retryDelayMs: 200,      // Base delay
);

Exponential Backoff

AttemptDelay
1immediate
2200ms
3400ms
4800ms

What is retried?

ErrorRetry?
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:

php
$mailer->disconnect();  // QUIT → 221 → fclose()

Encryption

ModePortBehavior
Encryption::Tls587Plain TCP → STARTTLS → TLS 1.2/1.3 upgrade
Encryption::Ssl465Implicit SSL (ssl:// prefix)
Encryption::None25No TLS (local development only)
php
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

ExceptionCause
MailMessageExceptionInvalid message (missing From, To or body)
SmtpConnectionExceptionHost unreachable, TLS error, timeout
SmtpAuthenticationExceptionAUTH LOGIN/PLAIN rejected
SmtpTransportExceptionSMTP protocol error (code via getCode())

All exceptions implement MailerExceptionInterface:

php
use JardisSupport\Contract\Mailer\MailerExceptionInterface;

try {
    $mailer->send($message);
} catch (MailerExceptionInterface $e) {
    // Catches all mailer exceptions
}

Custom Transport (Tests)

php
$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 string

Architecture

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 client

Directory 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.php

API Reference

Mailer

MethodSignatureDescription
sendsend(MailMessageInterface $message): voidSend one email
sendBatchsendBatch(array $messages): BatchResultBatch sending
disconnectdisconnect(): voidClose SMTP connection

SmtpConfig

PropertyTypeDefaultDescription
hoststringSMTP host
portint587Port
encryptionEncryptionTlsEncryption mode
username?stringnullAUTH username
password?stringnullAUTH password
timeoutint30Socket timeout
fromAddress?stringnullDefault sender address
fromName?stringnullDefault sender name
maxRetriesint0Retry attempts
retryDelayMsint100Base delay
verifySslbooltrueSSL verification

MailMessage

MethodSignatureDescription
createstatic create(): selfNew message
withFromwithFrom(string $email, ?string $name = null): selfSender
withTowithTo(string $email, ?string $name = null): selfRecipient (additive)
withCcwithCc(string $email, ?string $name = null): selfCC (additive)
withBccwithBcc(string $email, ?string $name = null): selfBCC (additive)
withReplyTowithReplyTo(string $email, ?string $name = null): selfReply-To
withSubjectwithSubject(string $subject): selfSubject
withTextwithText(string $text): selfPlain text body
withHtmlwithHtml(string $html): selfHTML body
withAttachmentwithAttachment(string $content, string $filename, string $type = '...'): selfAttachment
withEmbeddedImagewithEmbeddedImage(string $content, string $filename, string $type = '...'): selfInline image
withHeaderwithHeader(string $name, string $value): selfCustom header

Complete Example

Transactional order confirmation with HTML, logo and PDF invoice:

php
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();