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-Bilder —
withEmbeddedImage()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-Versand —
sendBatch()mit Teilergebnis-Tracking
Installation
composer require jardisadapter/mailerGitHub: jardisAdapter/mailer
Erforderliche PHP-Extensions: ext-openssl, ext-mbstring
Grundlegende Nutzung
Eine Mail senden
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
$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
$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
| Kombination | MIME-Typ |
|---|---|
| Nur Text | text/plain; charset=UTF-8 |
| Nur HTML | text/html; charset=UTF-8 |
| Text + HTML | multipart/alternative |
| + Attachments | multipart/mixed (äußere Boundary) |
| + Inline-Bilder | multipart/related (äußere Boundary) |
Attachments
Reguläre Attachments
$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)
$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
$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
$config = new SmtpConfig(
host: 'smtp.example.com',
username: 'user@example.com',
password: 'secret',
maxRetries: 3, // 3 Wiederholungen
retryDelayMs: 200, // Basis-Delay
);Exponential Backoff
| Versuch | Delay |
|---|---|
| 1 | sofort |
| 2 | 200ms |
| 3 | 400ms |
| 4 | 800ms |
Was wird wiederholt?
| Fehler | Retry? |
|---|---|
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:
$mailer->disconnect(); // QUIT → 221 → fclose()Verschlüsselung
| Modus | Port | Verhalten |
|---|---|---|
Encryption::Tls | 587 | Plain TCP → STARTTLS → TLS 1.2/1.3 Upgrade |
Encryption::Ssl | 465 | Implicit SSL (ssl:// Prefix) |
Encryption::None | 25 | Kein TLS (nur für lokale Entwicklung) |
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
| Exception | Ursache |
|---|---|
MailMessageException | Ungültige Nachricht (fehlendes From, To oder Body) |
SmtpConnectionException | Host nicht erreichbar, TLS-Fehler, Timeout |
SmtpAuthenticationException | AUTH LOGIN/PLAIN abgelehnt |
SmtpTransportException | SMTP-Protokollfehler (Code via getCode()) |
Alle Exceptions implementieren MailerExceptionInterface:
use JardisSupport\Contract\Mailer\MailerExceptionInterface;
try {
$mailer->send($message);
} catch (MailerExceptionInterface $e) {
// Fängt alle 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 enthält den kompletten MIME-StringArchitektur
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-ClientVerzeichnisstruktur
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.phpAPI-Referenz
Mailer
| Methode | Signatur | Beschreibung |
|---|---|---|
send | send(MailMessageInterface $message): void | Eine Mail senden |
sendBatch | sendBatch(array $messages): BatchResult | Batch-Versand |
disconnect | disconnect(): void | SMTP-Verbindung trennen |
SmtpConfig
| Property | Typ | Standard | Beschreibung |
|---|---|---|---|
host | string | — | SMTP-Host |
port | int | 587 | Port |
encryption | Encryption | Tls | Verschlüsselungsmodus |
username | ?string | null | AUTH-Username |
password | ?string | null | AUTH-Passwort |
timeout | int | 30 | Socket-Timeout |
fromAddress | ?string | null | Default-Absender |
fromName | ?string | null | Default-Name |
maxRetries | int | 0 | Retry-Versuche |
retryDelayMs | int | 100 | Basis-Delay |
verifySsl | bool | true | SSL-Verifizierung |
MailMessage
| Methode | Signatur | Beschreibung |
|---|---|---|
create | static create(): self | Neue Nachricht |
withFrom | withFrom(string $email, ?string $name = null): self | Absender |
withTo | withTo(string $email, ?string $name = null): self | Empfänger (additiv) |
withCc | withCc(string $email, ?string $name = null): self | CC (additiv) |
withBcc | withBcc(string $email, ?string $name = null): self | BCC (additiv) |
withReplyTo | withReplyTo(string $email, ?string $name = null): self | Reply-To |
withSubject | withSubject(string $subject): self | Betreff |
withText | withText(string $text): self | Klartext-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-Bild |
withHeader | withHeader(string $name, string $value): self | Custom Header |
Vollständiges Beispiel
Transaktionale Bestellbestätigung mit HTML, Logo und PDF-Rechnung:
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();