Skip to content

Mailer

Direkter SMTP-Versand mit STARTTLS, Attachments und Connection-Keepalive: ohne Swiftmailer, ohne Symfony.

Einführung

E-Mail-Versand in PHP bedeutet oft: Swiftmailer oder Symfony Mailer mit dutzenden Abhängigkeiten. Oder mail(), mit all seinen Einschränkungen. Wer einen zuverlässigen SMTP-Client braucht, der HTML-Mails mit Attachments verschickt und dabei die Verbindung offen hält, steht vor einer unbefriedigenden Wahl.

jardisadapter/mailer implementiert SMTP direkt auf Socket-Ebene, mit purem PHP und ext-openssl:

  • Null externe Abhängigkeiten — kein Swiftmailer, kein Symfony, nur PHP-Builtins
  • STARTTLS und Implicit SSL — TLS 1.2/1.3 auf Port 587 (STARTTLS) oder Port 465 (Implicit SSL)
  • AUTH LOGIN und AUTH PLAIN — automatische Erkennung des vom Server unterstützten Mechanismus
  • HTML + Text + Attachments — Multipart-MIME wird automatisch zusammengebaut
  • Inline-BilderwithEmbeddedImage() für <img src="cid:..."> in HTML-Mails
  • Connection-Keepalive — eine TCP-Verbindung für hunderte Mails, mit NOOP-Health-Check
  • Retry mit Exponential Backoff — automatische Wiederholung bei Verbindungsfehlern
  • Batch-VersandsendBatch() mit Teilergebnis-Tracking

Installation

bash
composer require jardisadapter/mailer

GitHub: jardisAdapter/mailer

Erforderliche PHP-Extensions: ext-openssl, ext-mbstring

Grundlegende Nutzung

Eine Mail senden

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('Ihre Bestellung #4711')
    ->withText('Vielen Dank für Ihre Bestellung.')
    ->withHtml('<h1>Bestellbestätigung</h1><p>Vielen Dank!</p>');

$mailer->send($message);

Default-Absender aus 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 wird automatisch gesetzt, wenn nicht explizit angegeben
$message = MailMessage::create()
    ->withTo('customer@example.com')
    ->withSubject('Willkommen!')
    ->withText('Ihr Account wurde erstellt.');

$mailer->send($message);

MailMessage — Fluent Builder

php
$message = MailMessage::create()
    // Absender und Empfänger
    ->withFrom('sender@example.com', 'Sender Name')
    ->withTo('recipient@example.com', 'Recipient Name')
    ->withTo('another@example.com')       // Additiv — mehrere Empfänger
    ->withCc('cc@example.com')
    ->withBcc('bcc@example.com')
    ->withReplyTo('reply@example.com')

    // Inhalt
    ->withSubject('Betreff')
    ->withText('Klartext-Version')
    ->withHtml('<h1>HTML-Version</h1>')

    // Attachments
    ->withAttachment($pdfContent, 'rechnung.pdf', 'application/pdf')
    ->withAttachment($csvContent, 'export.csv', 'text/csv')

    // Inline-Bilder
    ->withEmbeddedImage($logoContent, 'logo.png', 'image/png')

    // Custom Headers
    ->withHeader('X-Mailer', 'Jardis Mailer')
    ->withHeader('X-Priority', '1');

MailMessage ist immutabel: jede with*-Methode gibt eine neue Instanz zurück.

Text, HTML oder beides

KombinationMIME-Typ
Nur Texttext/plain; charset=UTF-8
Nur HTMLtext/html; charset=UTF-8
Text + HTMLmultipart/alternative
+ Attachmentsmultipart/mixed (äußere Boundary)
+ Inline-Bildermultipart/related (äußere Boundary)

Attachments

Reguläre Attachments

php
$pdf = file_get_contents('/tmp/invoice.pdf');

$message = MailMessage::create()
    ->withFrom('noreply@example.com')
    ->withTo('customer@example.com')
    ->withSubject('Ihre Rechnung')
    ->withText('Im Anhang finden Sie Ihre Rechnung.')
    ->withAttachment($pdf, 'rechnung-2025-001.pdf', 'application/pdf');

Inline-Bilder (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>');

Jedes Inline-Bild erhält automatisch eine Content-ID, die im HTML über cid: referenziert wird.

Batch-Versand

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

Alle Mails werden über eine TCP-Verbindung versendet (Keepalive). disconnect() wird erst nach dem gesamten Batch aufgerufen.

Retry

php
$config = new SmtpConfig(
    host: 'smtp.example.com',
    username: 'user@example.com',
    password: 'secret',
    maxRetries: 3,          // 3 Wiederholungen
    retryDelayMs: 200,      // Basis-Delay
);

Exponential Backoff

VersuchDelay
1sofort
2200ms
3400ms
4800ms

Was wird wiederholt?

FehlerRetry?
Verbindungsfehler (SmtpConnectionException)Ja
Temporäre SMTP-Fehler (4xx)Ja
Permanente SMTP-Fehler (5xx)Nein — sofort geworfen

Connection-Keepalive

Der SmtpTransport hält die TCP-Verbindung offen und prüft sie vor jedem Versand per NOOP:

Vor jedem send():
  Socket vorhanden? → NOOP senden → 250? → Verbindung wiederverwenden
                                    → Fehler? → Reconnect
  Kein Socket?      → Neu verbinden (EHLO, STARTTLS, AUTH)

Explizites Trennen:

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

Verschlüsselung

ModusPortVerhalten
Encryption::Tls587Plain TCP → STARTTLS → TLS 1.2/1.3 Upgrade
Encryption::Ssl465Implicit SSL (ssl:// Prefix)
Encryption::None25Kein TLS (nur für lokale Entwicklung)
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);

// Ohne TLS (MailHog, Mailpit etc.)
new SmtpConfig(host: 'localhost', port: 1025, encryption: Encryption::None);

SSL-Verifizierung kann für lokale Entwicklung deaktiviert werden: verifySsl: false.

Fehlerbehandlung

ExceptionUrsache
MailMessageExceptionUngültige Nachricht (fehlendes From, To oder Body)
SmtpConnectionExceptionHost nicht erreichbar, TLS-Fehler, Timeout
SmtpAuthenticationExceptionAUTH LOGIN/PLAIN abgelehnt
SmtpTransportExceptionSMTP-Protokollfehler (Code via getCode())

Alle Exceptions implementieren MailerExceptionInterface:

php
use JardisSupport\Contract\Mailer\MailerExceptionInterface;

try {
    $mailer->send($message);
} catch (MailerExceptionInterface $e) {
    // Fängt alle 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 enthält den kompletten MIME-String

Architektur

Das Package folgt dem Closure-Orchestrator-Pattern, der Mailer bindet drei Handler-Stufen:

Mailer                                 ← Orchestrator
├── Transformers (MailMessage → MailMessage)
│   ├── DefaultFrom                    ← Default-Absender setzen
│   └── MessageValidator               ← Validierung (From, To, Body)
├── Encoder (MailMessage → Envelope)
│   └── MimeEncoder                    ← RFC 2822 MIME-Assembly
└── Transport (Envelope → void)
    └── SmtpTransport                  ← Socket-basierter SMTP-Client

Verzeichnisstruktur

src/
├── Mailer.php                      ← Orchestrator
├── Config/
│   ├── SmtpConfig.php              ← SMTP-Konfiguration
│   └── Encryption.php              ← Enum: Tls, Ssl, None
├── Data/
│   ├── MailMessage.php             ← Immutable Message Builder
│   ├── Address.php                 ← E-Mail-Adresse VO
│   ├── Attachment.php              ← Attachment VO
│   ├── Envelope.php                ← Wire-Level-Repräsentation
│   └── BatchResult.php             ← Batch-Ergebnis
├── Handler/
│   ├── DefaultFrom.php             ← Default-Absender
│   ├── MessageValidator.php        ← Validierung
│   ├── MimeEncoder.php             ← MIME-Assembly
│   └── SmtpTransport.php           ← SMTP-Transport
└── Exception/
    ├── MailMessageException.php
    ├── SmtpAuthenticationException.php
    ├── SmtpConnectionException.php
    └── SmtpTransportException.php

API-Referenz

Mailer

MethodeSignaturBeschreibung
sendsend(MailMessageInterface $message): voidEine Mail senden
sendBatchsendBatch(array $messages): BatchResultBatch-Versand
disconnectdisconnect(): voidSMTP-Verbindung trennen

SmtpConfig

PropertyTypStandardBeschreibung
hoststringSMTP-Host
portint587Port
encryptionEncryptionTlsVerschlüsselungsmodus
username?stringnullAUTH-Username
password?stringnullAUTH-Passwort
timeoutint30Socket-Timeout
fromAddress?stringnullDefault-Absender
fromName?stringnullDefault-Name
maxRetriesint0Retry-Versuche
retryDelayMsint100Basis-Delay
verifySslbooltrueSSL-Verifizierung

MailMessage

MethodeSignaturBeschreibung
createstatic create(): selfNeue Nachricht
withFromwithFrom(string $email, ?string $name = null): selfAbsender
withTowithTo(string $email, ?string $name = null): selfEmpfänger (additiv)
withCcwithCc(string $email, ?string $name = null): selfCC (additiv)
withBccwithBcc(string $email, ?string $name = null): selfBCC (additiv)
withReplyTowithReplyTo(string $email, ?string $name = null): selfReply-To
withSubjectwithSubject(string $subject): selfBetreff
withTextwithText(string $text): selfKlartext-Body
withHtmlwithHtml(string $html): selfHTML-Body
withAttachmentwithAttachment(string $content, string $filename, string $type = '...'): selfAttachment
withEmbeddedImagewithEmbeddedImage(string $content, string $filename, string $type = '...'): selfInline-Bild
withHeaderwithHeader(string $name, string $value): selfCustom Header

Vollständiges Beispiel

Transaktionale Bestellbestätigung mit HTML, Logo und PDF-Rechnung:

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("Bestellbestätigung #{$orderId}")
    ->withText("Vielen Dank für Ihre Bestellung #{$orderId}.")
    ->withHtml("
        <img src=\"cid:logo.png\" width=\"200\">
        <h1>Bestellbestätigung</h1>
        <p>Vielen Dank für Ihre Bestellung <strong>#{$orderId}</strong>.</p>
        <p>Im Anhang finden Sie Ihre Rechnung.</p>
    ")
    ->withEmbeddedImage($logo, 'logo.png', 'image/png')
    ->withAttachment($invoice, "rechnung-{$orderId}.pdf", 'application/pdf')
    ->withHeader('X-Order-Id', $orderId);

$mailer->send($message);
$mailer->disconnect();