mail: Rückläufer aus dem Bounce-Postfach lesen (Roadmap 1b.6, Schritt 3)
webklex/laravel-imap als Transport, eigener DSN-Parser nach RFC 3464 auf der rohen Nachricht: Final-Recipient, Action, Status, Diagnostic-Code und unser X-Pk-Ref aus der Original-Nachricht. Maßgeblich ist Action — Zustellbestätigungen sperren niemanden, Freitext-Rückläufer wandern nach Unparsed vor einen Menschen, Abwesenheitsnotizen nach Ignored. mail:fetch-bounces alle zehn Minuten; ohne Konto tut es nichts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8bf40ee37e
commit
f9c97ccf2f
16 changed files with 1039 additions and 2 deletions
|
|
@ -130,6 +130,13 @@ MAIL_FROM_NAME="${APP_NAME}"
|
|||
# der From-Domain liegen (SPF-Ausrichtung), z. B. bounce@pressekonto.com.
|
||||
# Leer = From-Adresse. Schwellen siehe config/bounces.php.
|
||||
MAIL_RETURN_PATH=
|
||||
# IMAP-Zugang zum Bounce-Postfach für `mail:fetch-bounces` (alle 10 Minuten).
|
||||
# Leer = der Befehl tut nichts.
|
||||
BOUNCE_IMAP_HOST=
|
||||
BOUNCE_IMAP_PORT=993
|
||||
BOUNCE_IMAP_ENCRYPTION=ssl
|
||||
BOUNCE_IMAP_USERNAME=
|
||||
BOUNCE_IMAP_PASSWORD=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
|
|
|||
97
app/Console/Commands/FetchMailBounces.php
Normal file
97
app/Console/Commands/FetchMailBounces.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Mail\BounceMailProcessor;
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
use Webklex\PHPIMAP\ClientManager;
|
||||
use Webklex\PHPIMAP\Folder;
|
||||
use Webklex\PHPIMAP\Message;
|
||||
|
||||
/**
|
||||
* Holt Rückläufer aus dem Bounce-Postfach (Roadmap 1b.6, Konzept
|
||||
* `bounce-mails-2026-09-15.md`). Alle zehn Minuten auf UNSEEN — IDLE braucht
|
||||
* es bei zwei bis drei Mails am Tag nicht.
|
||||
*
|
||||
* Jede Mail wird nach dem Lesen in einen Ordner verschoben: `Processed`,
|
||||
* `Ignored` oder `Unparsed`. Verschieben statt Flag: Bricht der Lauf
|
||||
* dazwischen ab, liest der nächste dieselbe Mail erneut — die Sperre bleibt
|
||||
* dank Message-ID-Unique trotzdem einmalig.
|
||||
*/
|
||||
class FetchMailBounces extends Command
|
||||
{
|
||||
protected $signature = 'mail:fetch-bounces
|
||||
{--account=bounces : IMAP-Konto aus config/imap.php}
|
||||
{--limit=200 : Höchstens so viele Mails je Lauf}
|
||||
{--dry-run : Nur lesen und zählen, nichts sperren, nichts verschieben}';
|
||||
|
||||
protected $description = 'Liest Rückläufer aus dem Bounce-Postfach und sperrt unzustellbare Adressen.';
|
||||
|
||||
public function handle(ClientManager $clients, BounceMailProcessor $processor): int
|
||||
{
|
||||
$account = (string) $this->option('account');
|
||||
|
||||
if (blank(config("imap.accounts.{$account}.host")) || blank(config("imap.accounts.{$account}.username"))) {
|
||||
$this->warn("IMAP-Konto „{$account}“ ist nicht konfiguriert (BOUNCE_IMAP_HOST/USERNAME/PASSWORD) — nichts zu tun.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$counts = [BounceMailProcessor::Processed => 0, BounceMailProcessor::Ignored => 0, BounceMailProcessor::Unparsed => 0];
|
||||
$bounces = 0;
|
||||
|
||||
try {
|
||||
$client = $clients->account($account);
|
||||
$client->connect();
|
||||
|
||||
$inbox = $client->getFolderByName('INBOX');
|
||||
|
||||
if ($inbox === null) {
|
||||
throw new \RuntimeException('INBOX nicht gefunden.');
|
||||
}
|
||||
|
||||
$messages = $inbox->query()->whereUnseen()->setFetchBody(true)->limit(max(1, (int) $this->option('limit')))->get();
|
||||
|
||||
/** @var Message $message */
|
||||
foreach ($messages as $message) {
|
||||
$raw = (string) $message->getHeader()?->raw."\r\n\r\n".$message->getRawBody();
|
||||
$result = $processor->process($raw, $dryRun);
|
||||
|
||||
$counts[$result['folder']]++;
|
||||
$bounces += $result['bounces'];
|
||||
|
||||
$this->line(sprintf(' %-10s %s', $result['folder'], $result['subject']));
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->moveTo($client->getFolderByName($result['folder']) ?? $client->createFolder($result['folder']), $message);
|
||||
}
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
} catch (Throwable $e) {
|
||||
OperationalAlert::send('Bounce-Postfach konnte nicht gelesen werden', ['account' => $account, 'error' => $e->getMessage()]);
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s%d Rückläufer verarbeitet (%d Bounces), %d ignoriert, %d nicht lesbar.',
|
||||
$dryRun ? '[Dry-Run] ' : '',
|
||||
$counts[BounceMailProcessor::Processed],
|
||||
$bounces,
|
||||
$counts[BounceMailProcessor::Ignored],
|
||||
$counts[BounceMailProcessor::Unparsed],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function moveTo(Folder $folder, Message $message): void
|
||||
{
|
||||
$message->move($folder->path, expunge: true);
|
||||
}
|
||||
}
|
||||
102
app/Services/Mail/BounceMailProcessor.php
Normal file
102
app/Services/Mail/BounceMailProcessor.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\EmailBounceKind;
|
||||
use App\Services\ActivityLog;
|
||||
|
||||
/**
|
||||
* Was mit einer Mail aus dem Bounce-Postfach geschieht — getrennt vom
|
||||
* Abholen, damit es sich mit .eml-Fixtures testen lässt.
|
||||
*
|
||||
* Ergebnis ist der Ziel-Ordner: `Processed` (Zustellbericht gelesen),
|
||||
* `Ignored` (Abwesenheitsnotiz oder reine Zustellbestätigung) oder
|
||||
* `Unparsed` (kein lesbarer Bericht — sieht sich ein Mensch an).
|
||||
*/
|
||||
class BounceMailProcessor
|
||||
{
|
||||
public const Processed = 'Processed';
|
||||
|
||||
public const Ignored = 'Ignored';
|
||||
|
||||
public const Unparsed = 'Unparsed';
|
||||
|
||||
public function __construct(
|
||||
private readonly DsnParser $parser,
|
||||
private readonly EmailSuppressionService $suppressions,
|
||||
private readonly ActivityLog $activityLog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{folder: string, bounces: int, subject: string}
|
||||
*/
|
||||
public function process(string $raw, bool $dryRun = false): array
|
||||
{
|
||||
$subject = $this->subject($raw);
|
||||
$report = $this->parser->parse($raw);
|
||||
|
||||
if ($report === null) {
|
||||
if ($this->parser->looksLikeAutoReply($raw)) {
|
||||
return ['folder' => self::Ignored, 'bounces' => 0, 'subject' => $subject];
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->activityLog->record(
|
||||
ActivityType::MailSuppressed,
|
||||
sprintf('Rückläufer nicht lesbar, im Postfach unter „Unparsed“: %s', $subject),
|
||||
actorLabel: 'imap',
|
||||
);
|
||||
}
|
||||
|
||||
return ['folder' => self::Unparsed, 'bounces' => 0, 'subject' => $subject];
|
||||
}
|
||||
|
||||
$bounces = 0;
|
||||
|
||||
foreach ($report->recipients as $index => $recipient) {
|
||||
$kind = match ($recipient['action']) {
|
||||
'failed' => EmailBounceKind::Hard,
|
||||
'delayed' => EmailBounceKind::Soft,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($kind === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4.x.x mit `failed` ist ein weicher Fehler, den der MTA
|
||||
// aufgegeben hat — zählt weich, nicht hart.
|
||||
if ($kind === EmailBounceKind::Hard && str_starts_with((string) $recipient['status'], '4.')) {
|
||||
$kind = EmailBounceKind::Soft;
|
||||
}
|
||||
|
||||
$bounces++;
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->suppressions->recordBounce(
|
||||
$recipient['email'],
|
||||
$kind,
|
||||
'imap',
|
||||
$report->messageId.($index > 0 ? '#'.$index : ''),
|
||||
[
|
||||
'action' => $recipient['action'],
|
||||
'status' => $recipient['status'],
|
||||
'diagnostic' => $recipient['diagnostic'],
|
||||
'reference' => $report->reference,
|
||||
'received_at' => $report->receivedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ['folder' => $bounces > 0 ? self::Processed : self::Ignored, 'bounces' => $bounces, 'subject' => $subject];
|
||||
}
|
||||
|
||||
private function subject(string $raw): string
|
||||
{
|
||||
return preg_match('/^Subject:\s*(.*)$/mi', $raw, $m) === 1 ? mb_substr(trim($m[1]), 0, 200) : '(ohne Betreff)';
|
||||
}
|
||||
}
|
||||
198
app/Services/Mail/DsnParser.php
Normal file
198
app/Services/Mail/DsnParser.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Liest Zustellberichte (RFC 3464, `multipart/report; report-type=
|
||||
* delivery-status`) aus der rohen Nachricht — ohne zu raten.
|
||||
*
|
||||
* Ein DSN hat drei Teile: lesbarer Text, `message/delivery-status` mit den
|
||||
* Feldern je Empfänger, und die Original-Nachricht (oder deren Header). Der
|
||||
* Parser braucht aus dem zweiten Teil genau vier Felder (`Final-Recipient`,
|
||||
* `Action`, `Status`, `Diagnostic-Code`) und aus dem dritten unseren
|
||||
* `X-Pk-Ref`. Maßgeblich ist `Action`: `failed` und `delayed` sind Bounces,
|
||||
* `delivered`/`relayed`/`expanded` sind Zustellbestätigungen — wer nur nach
|
||||
* `Final-Recipient` greift, sperrt Adressen, die funktioniert haben.
|
||||
*
|
||||
* Alles, was kein `message/delivery-status` trägt (handgeschriebene
|
||||
* Ablehnungen, alte MTAs mit Freitext), liefert null. Das wandert in den
|
||||
* Ordner `Unparsed` und vor einen Menschen — zwei Mails pro Woche von Hand
|
||||
* sind akzeptabel, eine falsch gesperrte Adresse nicht.
|
||||
*/
|
||||
class DsnParser
|
||||
{
|
||||
public function parse(string $raw): ?DsnReport
|
||||
{
|
||||
$raw = str_replace("\r\n", "\n", $raw);
|
||||
[$topHeaders, $topBody] = $this->splitHeadersAndBody($raw);
|
||||
$headers = $this->parseHeaders($topHeaders);
|
||||
|
||||
$parts = $this->allParts($topBody, $headers['content-type'] ?? '');
|
||||
$status = null;
|
||||
$original = null;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$type = strtolower((string) ($part['headers']['content-type'] ?? ''));
|
||||
|
||||
if ($status === null && str_starts_with($type, 'message/delivery-status')) {
|
||||
$status = $part['body'];
|
||||
} elseif ($original === null && (str_starts_with($type, 'message/rfc822') || str_starts_with($type, 'text/rfc822-headers'))) {
|
||||
$original = $part['body'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$recipients = $this->recipientsFrom($status);
|
||||
|
||||
if ($recipients === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$referenceHeader = strtolower((string) config('bounces.reference_header', 'X-Pk-Ref'));
|
||||
$originalHeaders = $original !== null ? $this->parseHeaders($this->splitHeadersAndBody(ltrim($original, "\n"))[0]) : [];
|
||||
|
||||
return new DsnReport(
|
||||
messageId: trim((string) ($headers['message-id'] ?? ''), '<> ') ?: 'sha1:'.sha1($raw),
|
||||
recipients: $recipients,
|
||||
reference: $originalHeaders[$referenceHeader] ?? null,
|
||||
receivedAt: $this->date($headers['date'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grobe Erkennung ohne Parsen: für die Entscheidung, ob eine unlesbare
|
||||
* Mail eine Abwesenheitsnotiz ist (ignorieren) oder ein Rückläufer, den
|
||||
* ein Mensch ansehen sollte.
|
||||
*/
|
||||
public function looksLikeAutoReply(string $raw): bool
|
||||
{
|
||||
$raw = str_replace("\r\n", "\n", $raw);
|
||||
$headers = $this->parseHeaders($this->splitHeadersAndBody($raw)[0]);
|
||||
|
||||
$autoSubmitted = strtolower((string) ($headers['auto-submitted'] ?? ''));
|
||||
$subject = strtolower((string) ($headers['subject'] ?? ''));
|
||||
|
||||
return ($autoSubmitted !== '' && $autoSubmitted !== 'no')
|
||||
|| isset($headers['x-autoreply'])
|
||||
|| isset($headers['x-autorespond'])
|
||||
|| preg_match('/abwesen|out of office|autoreply|automatische antwort|auto-reply/i', $subject) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function splitHeadersAndBody(string $raw): array
|
||||
{
|
||||
$pos = strpos($raw, "\n\n");
|
||||
|
||||
return $pos === false ? [$raw, ''] : [substr($raw, 0, $pos), substr($raw, $pos + 2)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Header mit Fortsetzungszeilen zusammengefaltet, Namen kleingeschrieben.
|
||||
* Bei Mehrfach-Headern gewinnt der erste.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function parseHeaders(string $block): array
|
||||
{
|
||||
$unfolded = preg_replace('/\n[ \t]+/', ' ', $block) ?? $block;
|
||||
$headers = [];
|
||||
|
||||
foreach (explode("\n", $unfolded) as $line) {
|
||||
if (preg_match('/^([A-Za-z0-9\-]+):\s*(.*)$/', $line, $m) === 1) {
|
||||
$headers[strtolower($m[1])] ??= trim($m[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle MIME-Teile, rekursiv über verschachtelte Multiparts.
|
||||
*
|
||||
* @return list<array{headers: array<string, string>, body: string}>
|
||||
*/
|
||||
private function allParts(string $body, string $contentType): array
|
||||
{
|
||||
if (preg_match('/boundary="?([^";\s]+)"?/i', $contentType, $m) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$boundary = $m[1];
|
||||
$segments = preg_split('/^--'.preg_quote($boundary, '/').'(--)?[ \t]*$/m', $body) ?: [];
|
||||
$parts = [];
|
||||
|
||||
// Erstes Segment ist der Präambel-Text vor dem ersten Boundary,
|
||||
// letztes der Epilog nach dem Schluss-Boundary.
|
||||
foreach (array_slice($segments, 1, -1) as $segment) {
|
||||
[$headerBlock, $partBody] = $this->splitHeadersAndBody(ltrim($segment, "\n"));
|
||||
$partHeaders = $this->parseHeaders($headerBlock);
|
||||
$parts[] = ['headers' => $partHeaders, 'body' => $partBody];
|
||||
|
||||
if (str_starts_with(strtolower((string) ($partHeaders['content-type'] ?? '')), 'multipart/')) {
|
||||
array_push($parts, ...$this->allParts($partBody, $partHeaders['content-type']));
|
||||
}
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der `message/delivery-status`-Teil: ein Block je Nachricht, dann je
|
||||
* Empfänger ein Block, durch Leerzeilen getrennt. Feldnamen sind
|
||||
* case-insensitiv.
|
||||
*
|
||||
* @return list<array{email: string, action: string, status: string|null, diagnostic: string|null}>
|
||||
*/
|
||||
private function recipientsFrom(string $status): array
|
||||
{
|
||||
$recipients = [];
|
||||
|
||||
foreach (preg_split('/\n{2,}/', trim($status)) ?: [] as $block) {
|
||||
$fields = $this->parseHeaders($block);
|
||||
$recipient = $fields['final-recipient'] ?? $fields['original-recipient'] ?? null;
|
||||
|
||||
if ($recipient === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// „rfc822; user@example.com" — der Adresstyp vor dem Semikolon.
|
||||
$email = EmailSuppressionService::normalise(trim((string) preg_replace('/^[^;]*;\s*/', '', $recipient)));
|
||||
$action = strtolower(trim((string) ($fields['action'] ?? '')));
|
||||
|
||||
if ($email === null || $action === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diagnostic = isset($fields['diagnostic-code']) ? trim((string) preg_replace('/^[^;]*;\s*/', '', $fields['diagnostic-code'])) : null;
|
||||
|
||||
$recipients[] = [
|
||||
'email' => $email,
|
||||
'action' => $action,
|
||||
'status' => isset($fields['status']) && preg_match('/^\d\.\d{1,3}\.\d{1,3}$/', trim($fields['status'])) === 1 ? trim($fields['status']) : null,
|
||||
'diagnostic' => $diagnostic !== '' ? $diagnostic : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $recipients;
|
||||
}
|
||||
|
||||
private function date(?string $value): ?Carbon
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/Services/Mail/DsnReport.php
Normal file
21
app/Services/Mail/DsnReport.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Ergebnis eines gelesenen Zustellberichts (RFC 3464).
|
||||
*/
|
||||
final readonly class DsnReport
|
||||
{
|
||||
/**
|
||||
* @param list<array{email: string, action: string, status: string|null, diagnostic: string|null}> $recipients
|
||||
*/
|
||||
public function __construct(
|
||||
public string $messageId,
|
||||
public array $recipients,
|
||||
public ?string $reference,
|
||||
public ?Carbon $receivedAt,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -21,7 +21,8 @@
|
|||
"livewire/flux-pro": "^2.1",
|
||||
"livewire/volt": "^1.7.0",
|
||||
"mews/purifier": "^3.4",
|
||||
"spatie/laravel-permission": "^6.17"
|
||||
"spatie/laravel-permission": "^6.17",
|
||||
"webklex/laravel-imap": "^6.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-debugbar": "^3.16",
|
||||
|
|
|
|||
160
composer.lock
generated
160
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "7644392ee85d975d7adeb80988fbee8d",
|
||||
"content-hash": "d25da84dee547a1bf63626e58af6e723",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
|
|
@ -7724,6 +7724,164 @@
|
|||
}
|
||||
],
|
||||
"time": "2026-04-26T05:33:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webklex/laravel-imap",
|
||||
"version": "6.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Webklex/laravel-imap.git",
|
||||
"reference": "57609df58c2f4ef625e4d90a47d8615cfb15f925"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Webklex/laravel-imap/zipball/57609df58c2f4ef625e4d90a47d8615cfb15f925",
|
||||
"reference": "57609df58c2f4ef625e4d90a47d8615cfb15f925",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"laravel/framework": ">=6.0.0",
|
||||
"php": "^8.0.2",
|
||||
"webklex/php-imap": "^6.2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Client": "Webklex\\IMAP\\Facades\\Client"
|
||||
},
|
||||
"providers": [
|
||||
"Webklex\\IMAP\\Providers\\LaravelServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webklex\\IMAP\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Malte Goldenbaum",
|
||||
"email": "github@webklex.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Laravel IMAP client",
|
||||
"homepage": "https://github.com/webklex/laravel-imap",
|
||||
"keywords": [
|
||||
"idle",
|
||||
"imap",
|
||||
"laravel",
|
||||
"laravel-imap",
|
||||
"mail",
|
||||
"oauth",
|
||||
"pop3",
|
||||
"webklex"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Webklex/laravel-imap/issues",
|
||||
"source": "https://github.com/Webklex/laravel-imap/tree/6.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.buymeacoffee.com/webklex",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/webklex",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-25T06:06:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webklex/php-imap",
|
||||
"version": "6.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Webklex/php-imap.git",
|
||||
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-zip": "*",
|
||||
"illuminate/pagination": ">=5.0.0",
|
||||
"nesbot/carbon": "^2.62.1|^3.2.4",
|
||||
"php": "^8.0.2",
|
||||
"symfony/http-foundation": ">=2.8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5.10"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/mime": "Recomended for better extension support",
|
||||
"symfony/var-dumper": "Usefull tool for debugging"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "6.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webklex\\PHPIMAP\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Malte Goldenbaum",
|
||||
"email": "github@webklex.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP IMAP client",
|
||||
"homepage": "https://github.com/webklex/php-imap",
|
||||
"keywords": [
|
||||
"imap",
|
||||
"mail",
|
||||
"php-imap",
|
||||
"pop3",
|
||||
"webklex"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Webklex/php-imap/issues",
|
||||
"source": "https://github.com/Webklex/php-imap/tree/6.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.buymeacoffee.com/webklex",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/webklex",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-25T06:02:37+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
|
|
|
|||
196
config/imap.php
Normal file
196
config/imap.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
use Webklex\IMAP\Events\FlagDeletedEvent;
|
||||
use Webklex\IMAP\Events\FlagNewEvent;
|
||||
use Webklex\IMAP\Events\FolderDeletedEvent;
|
||||
use Webklex\IMAP\Events\FolderMovedEvent;
|
||||
use Webklex\IMAP\Events\FolderNewEvent;
|
||||
use Webklex\IMAP\Events\MessageCopiedEvent;
|
||||
use Webklex\IMAP\Events\MessageDeletedEvent;
|
||||
use Webklex\IMAP\Events\MessageMovedEvent;
|
||||
use Webklex\IMAP\Events\MessageNewEvent;
|
||||
use Webklex\IMAP\Events\MessageRestoredEvent;
|
||||
use Webklex\PHPIMAP\Decoder\AttachmentDecoder;
|
||||
use Webklex\PHPIMAP\Decoder\HeaderDecoder;
|
||||
use Webklex\PHPIMAP\Decoder\MessageDecoder;
|
||||
use Webklex\PHPIMAP\IMAP;
|
||||
use Webklex\PHPIMAP\Support\Masks\AttachmentMask;
|
||||
use Webklex\PHPIMAP\Support\Masks\MessageMask;
|
||||
|
||||
/*
|
||||
* File: imap.php
|
||||
* Category: config
|
||||
* Author: M. Goldenbaum
|
||||
* Created: 24.09.16 22:36
|
||||
* Updated: -
|
||||
*
|
||||
* Description:
|
||||
* -
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IMAP default account
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default account identifier. It will be used as default for any missing account parameters.
|
||||
| If however the default account is missing a parameter the package default will be used.
|
||||
| Set to 'false' [boolean] to disable this functionality.
|
||||
|
|
||||
*/
|
||||
'default' => 'bounces',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default date format
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default date format is used to convert any given Carbon::class object into a valid date string.
|
||||
| These are currently known working formats: "d-M-Y", "d-M-y", "d M y"
|
||||
|
|
||||
*/
|
||||
'date_format' => 'd-M-Y',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Available IMAP accounts
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Please list all IMAP accounts which you are planning to use within the
|
||||
| array below.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
| `bounces`: das Postfach hinter MAIL_RETURN_PATH (bounce@pressekonto.com),
|
||||
| gelesen von `mail:fetch-bounces` (Roadmap 1b.6). Ohne Host/Benutzer tut
|
||||
| der Befehl nichts — so läuft der Scheduler auch ohne Postfach durch.
|
||||
*/
|
||||
'accounts' => [
|
||||
'bounces' => [
|
||||
'host' => env('BOUNCE_IMAP_HOST'),
|
||||
'port' => (int) env('BOUNCE_IMAP_PORT', 993),
|
||||
'protocol' => 'imap',
|
||||
'encryption' => env('BOUNCE_IMAP_ENCRYPTION', 'ssl'),
|
||||
'validate_cert' => (bool) env('BOUNCE_IMAP_VALIDATE_CERT', true),
|
||||
'username' => env('BOUNCE_IMAP_USERNAME'),
|
||||
'password' => env('BOUNCE_IMAP_PASSWORD'),
|
||||
'authentication' => null,
|
||||
'proxy' => [
|
||||
'socket' => null,
|
||||
'request_fulluri' => false,
|
||||
'username' => null,
|
||||
'password' => null,
|
||||
],
|
||||
'timeout' => 30,
|
||||
'extensions' => [],
|
||||
],
|
||||
],
|
||||
|
||||
'options' => [
|
||||
'delimiter' => '/',
|
||||
'fetch' => IMAP::FT_PEEK,
|
||||
'sequence' => IMAP::ST_UID,
|
||||
'fetch_body' => true,
|
||||
'fetch_flags' => true,
|
||||
'soft_fail' => false,
|
||||
'rfc822' => true,
|
||||
'debug' => false,
|
||||
'uid_cache' => true,
|
||||
// 'fallback_date' => "01.01.1970 00:00:00",
|
||||
'boundary' => '/boundary=(.*?(?=;)|(.*))/i',
|
||||
'message_key' => 'list',
|
||||
'fetch_order' => 'asc',
|
||||
'dispositions' => ['attachment', 'inline'],
|
||||
'common_folders' => [
|
||||
'root' => 'INBOX',
|
||||
'junk' => 'INBOX/Junk',
|
||||
'draft' => 'INBOX/Drafts',
|
||||
'sent' => 'INBOX/Sent',
|
||||
'trash' => 'INBOX/Trash',
|
||||
],
|
||||
'open' => [
|
||||
// 'DISABLE_AUTHENTICATOR' => 'GSSAPI'
|
||||
],
|
||||
],
|
||||
|
||||
/**
|
||||
* |--------------------------------------------------------------------------
|
||||
* | Available decoding options
|
||||
* |--------------------------------------------------------------------------
|
||||
* |
|
||||
* | Available php imap config parameters are listed below
|
||||
* | -options: Decoder options (currently only the message subject and attachment name decoder can be set)
|
||||
* | 'utf-8' - Uses imap_utf8($string) to decode a string
|
||||
* | 'mimeheader' - Uses mb_decode_mimeheader($string) to decode a string
|
||||
* | -decoder: Decoder to be used. Can be replaced by custom decoders if needed.
|
||||
* | 'header' - HeaderDecoder
|
||||
* | 'message' - MessageDecoder
|
||||
* | 'attachment' - AttachmentDecoder
|
||||
*/
|
||||
'decoding' => [
|
||||
'options' => [
|
||||
'header' => 'utf-8', // mimeheader
|
||||
'message' => 'utf-8', // mimeheader
|
||||
'attachment' => 'utf-8', // mimeheader
|
||||
],
|
||||
'decoder' => [
|
||||
'header' => HeaderDecoder::class,
|
||||
'message' => MessageDecoder::class,
|
||||
'attachment' => AttachmentDecoder::class,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Available flags
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List all available / supported flags. Set to null to accept all given flags.
|
||||
*/
|
||||
'flags' => ['recent', 'flagged', 'answered', 'deleted', 'seen', 'draft'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Available events
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
*/
|
||||
'events' => [
|
||||
'message' => [
|
||||
'new' => MessageNewEvent::class,
|
||||
'moved' => MessageMovedEvent::class,
|
||||
'copied' => MessageCopiedEvent::class,
|
||||
'deleted' => MessageDeletedEvent::class,
|
||||
'restored' => MessageRestoredEvent::class,
|
||||
],
|
||||
'folder' => [
|
||||
'new' => FolderNewEvent::class,
|
||||
'moved' => FolderMovedEvent::class,
|
||||
'deleted' => FolderDeletedEvent::class,
|
||||
],
|
||||
'flag' => [
|
||||
'new' => FlagNewEvent::class,
|
||||
'deleted' => FlagDeletedEvent::class,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Available masking options
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By using your own custom masks you can implement your own methods for
|
||||
| a better and faster access and less code to write.
|
||||
|
|
||||
| Checkout the two examples custom_attachment_mask and custom_message_mask
|
||||
| for a quick start.
|
||||
|
|
||||
| The provided masks below are used as the default masks.
|
||||
*/
|
||||
'masks' => [
|
||||
'message' => MessageMask::class,
|
||||
'attachment' => AttachmentMask::class,
|
||||
],
|
||||
];
|
||||
|
|
@ -1055,3 +1055,36 @@ Handliste in die zentrale Sperre kommt.
|
|||
|
||||
**Offen:** Schritt 3 (IMAP-Abholung, `webklex/laravel-imap`, freigegeben
|
||||
15.09.2026) und Schritt 4 (Markierung im Admin, Claim-Seite, Freigabe).
|
||||
|
||||
**Schritt 3 — Abholung aus dem Postfach** (15.09.2026)
|
||||
|
||||
- Abhängigkeit `webklex/laravel-imap` ^6.2 (freigegeben 15.09.2026). Bringt
|
||||
ein eigenes IMAP-Protokoll mit — keine PHP-Extension nötig.
|
||||
- `DsnParser`: eigener Leser für RFC-3464-Zustellberichte auf der rohen
|
||||
Nachricht. Zerlegt MIME (auch verschachtelt), nimmt aus dem
|
||||
`message/delivery-status`-Teil je Empfänger `Final-Recipient`, `Action`,
|
||||
`Status`, `Diagnostic-Code`, aus dem Original-Teil unseren `X-Pk-Ref`.
|
||||
Maßgeblich ist `Action`: `failed` → hart (bei 4.x.x weich), `delayed` →
|
||||
weich, `delivered`/`relayed` → ignoriert. Kein `message/delivery-status`
|
||||
→ null, kein Raten.
|
||||
- `BounceMailProcessor`: entscheidet je Mail den Zielordner — `Processed`,
|
||||
`Ignored` (Zustellbestätigung, Abwesenheitsnotiz per `Auto-Submitted`/
|
||||
Betreff) oder `Unparsed` (Freitext-Rückläufer; Eintrag im Aktivitätslog,
|
||||
damit ein Mensch ihn ansieht). Testbar mit .eml-Fixtures in
|
||||
`tests/Fixtures/bounces/`.
|
||||
- `mail:fetch-bounces [--account=bounces] [--limit=200] [--dry-run]`: alle
|
||||
zehn Minuten im Scheduler, liest UNSEEN aus INBOX, verschiebt jede Mail in
|
||||
ihren Ordner (legt ihn an, falls er fehlt). Verschieben statt Flag: Bricht
|
||||
der Lauf ab, liest der nächste dieselbe Mail erneut — dank
|
||||
Message-ID-Unique ohne Doppelzählung. Ohne konfiguriertes Konto beendet
|
||||
sich der Befehl sofort; Verbindungsfehler lösen einen Betriebs-Alert aus.
|
||||
- `config/imap.php` (Paketvorlage, Konto `bounces` aus `BOUNCE_IMAP_*`).
|
||||
|
||||
Tests: `tests/Feature/BounceMailProcessingTest.php` (6) mit fünf Fixtures
|
||||
(Postfix-Hardbounce mit Fortsetzungszeilen und Kleinschreibung, delayed,
|
||||
Zustellbestätigung, Freitext, Abwesenheit).
|
||||
|
||||
**Deploy Schritt 3:** `composer install`, `config:cache`, dann in der `.env`
|
||||
`BOUNCE_IMAP_HOST=w01eb4f6.kasserver.com`, `BOUNCE_IMAP_USERNAME`/`PASSWORD`
|
||||
des Postfachs bounce@pressekonto.com, `MAIL_RETURN_PATH=bounce@pressekonto.com`.
|
||||
Erster Lauf von Hand: `php artisan mail:fetch-bounces --dry-run`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\AggregatePressReleaseViews;
|
||||
use App\Console\Commands\FetchMailBounces;
|
||||
use App\Console\Commands\GenerateManualInvoices;
|
||||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Console\Commands\PurgeConversionEvents;
|
||||
|
|
@ -35,6 +36,15 @@ $alertOnFailure = fn (string $task) => function (Event $event) use ($task): void
|
|||
// Tägliche Bereinigungen
|
||||
// ========================================
|
||||
|
||||
// Bounce-Postfach: Rückläufer lesen und unzustellbare Adressen sperren
|
||||
// (Roadmap 1b.6). Alle zehn Minuten auf UNSEEN; ohne konfiguriertes Konto
|
||||
// beendet sich der Befehl sofort.
|
||||
Schedule::command(FetchMailBounces::class)
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('mail:fetch-bounces'));
|
||||
|
||||
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
|
||||
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
|
||||
->dailyAt('03:00')
|
||||
|
|
|
|||
85
tests/Feature/BounceMailProcessingTest.php
Normal file
85
tests/Feature/BounceMailProcessingTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\EmailBounceKind;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\EmailBounce;
|
||||
use App\Services\Mail\BounceMailProcessor;
|
||||
use App\Services\Mail\DsnParser;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Rückläufer aus dem Bounce-Postfach lesen (Roadmap 1b.6, Schritt 3):
|
||||
* eigener DSN-Parser nach RFC 3464, kein Raten aus dem Fließtext.
|
||||
*/
|
||||
function bounceFixture(string $name): string
|
||||
{
|
||||
return (string) file_get_contents(base_path("tests/Fixtures/bounces/{$name}.eml"));
|
||||
}
|
||||
|
||||
test('a postfix hard bounce yields recipient, status, diagnostic and our reference header', function () {
|
||||
/** @var TestCase $this */
|
||||
$report = app(DsnParser::class)->parse(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect($report)->not->toBeNull()
|
||||
->and($report->messageId)->toBe('20260915071233.ABC123@mx.kasserver.com')
|
||||
->and($report->recipients)->toHaveCount(1)
|
||||
->and($report->recipients[0]['email'])->toBe('presse@alte-firma.example')
|
||||
->and($report->recipients[0]['action'])->toBe('failed')
|
||||
->and($report->recipients[0]['status'])->toBe('5.1.1')
|
||||
->and($report->recipients[0]['diagnostic'])->toContain('User unknown')
|
||||
->and($report->reference)->toBe('pm:4711:company:815')
|
||||
->and($report->receivedAt?->toDateString())->toBe('2026-09-15');
|
||||
});
|
||||
|
||||
test('processing a hard bounce suppresses the address and keeps the reference', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and($result['bounces'])->toBe(1)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue()
|
||||
->and(EmailBounce::query()->first()->reference)->toBe('pm:4711:company:815')
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
|
||||
|
||||
// Zweiter Durchlauf derselben Mail (abgebrochener Lauf): nichts doppelt.
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
expect(EmailBounce::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a delayed report counts as a soft bounce and lower-case field names are fine', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
|
||||
->and(EmailBounce::query()->first()->reference)->toBe('user:99')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a delivery confirmation blocks nobody', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('delivered'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Ignored)
|
||||
->and(EmailBounce::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('free-text bounces are not guessed but parked for a human, auto-replies are ignored', function () {
|
||||
/** @var TestCase $this */
|
||||
$unparsed = app(BounceMailProcessor::class)->process(bounceFixture('freitext'));
|
||||
$ooo = app(BounceMailProcessor::class)->process(bounceFixture('abwesenheit'));
|
||||
|
||||
expect($unparsed['folder'])->toBe(BounceMailProcessor::Unparsed)
|
||||
->and($ooo['folder'])->toBe(BounceMailProcessor::Ignored)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('jemand@alter-server.example'))->toBeFalse()
|
||||
->and(ActivityEvent::query()->where('type', ActivityType::MailSuppressed->value)->where('description', 'like', '%nicht lesbar%')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the fetch command does nothing without a configured mailbox', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('imap.accounts.bounces.host', null);
|
||||
|
||||
$this->artisan('mail:fetch-bounces')->expectsOutputToContain('nicht konfiguriert')->assertSuccessful();
|
||||
});
|
||||
9
tests/Fixtures/bounces/abwesenheit.eml
Normal file
9
tests/Fixtures/bounces/abwesenheit.eml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: Erika Muster <erika@example.test>
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Automatische Antwort: Ihre Pressemitteilung wurde veroeffentlicht
|
||||
Auto-Submitted: auto-replied
|
||||
Message-ID: <ooo-1@example.test>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Ich bin bis zum 30.09. nicht im Buero.
|
||||
32
tests/Fixtures/bounces/delayed-4.4.1.eml
Normal file
32
tests/Fixtures/bounces/delayed-4.4.1.eml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: Mail Delivery System <MAILER-DAEMON@mx.kasserver.com>
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Delayed Mail (still being retried)
|
||||
Message-ID: <delay-1@mx.kasserver.com>
|
||||
Content-Type: multipart/report; report-type=delivery-status; boundary="DLY"
|
||||
|
||||
--DLY
|
||||
Content-Type: text/plain
|
||||
|
||||
Your message is still being retried.
|
||||
|
||||
--DLY
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
Reporting-MTA: dns; mx.kasserver.com
|
||||
|
||||
Final-Recipient: rfc822; voll@example.test
|
||||
action: delayed
|
||||
status: 4.4.1
|
||||
Will-Retry-Until: Thu, 18 Sep 2026 10:00:00 +0200
|
||||
|
||||
--DLY
|
||||
Content-Type: message/rfc822
|
||||
|
||||
From: service@pressekonto.com
|
||||
To: voll@example.test
|
||||
X-Pk-Ref: user:99
|
||||
|
||||
Hallo.
|
||||
|
||||
--DLY--
|
||||
21
tests/Fixtures/bounces/delivered.eml
Normal file
21
tests/Fixtures/bounces/delivered.eml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: Mail Delivery System <MAILER-DAEMON@mx.example>
|
||||
Subject: Successful Mail Delivery Report
|
||||
Message-ID: <ok-1@mx.example>
|
||||
Content-Type: multipart/report; report-type=delivery-status; boundary="OK"
|
||||
|
||||
--OK
|
||||
Content-Type: text/plain
|
||||
|
||||
Delivered.
|
||||
|
||||
--OK
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
Reporting-MTA: dns; mx.example
|
||||
|
||||
Final-Recipient: rfc822; lebt@example.test
|
||||
Action: delivered
|
||||
Status: 2.0.0
|
||||
|
||||
--OK--
|
||||
14
tests/Fixtures/bounces/freitext.eml
Normal file
14
tests/Fixtures/bounces/freitext.eml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: postmaster@alter-server.example
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Mail delivery failed: returning message to sender
|
||||
Message-ID: <free-1@alter-server.example>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
This message was created automatically by mail delivery software.
|
||||
|
||||
A message that you sent could not be delivered to one or more of its
|
||||
recipients. The following address(es) failed:
|
||||
|
||||
jemand@alter-server.example
|
||||
Unrouteable address
|
||||
53
tests/Fixtures/bounces/hard-5.1.1.eml
Normal file
53
tests/Fixtures/bounces/hard-5.1.1.eml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
Return-Path: <>
|
||||
Date: Tue, 15 Sep 2026 09:12:33 +0200
|
||||
From: Mail Delivery System <MAILER-DAEMON@mx.kasserver.com>
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Undelivered Mail Returned to Sender
|
||||
Message-ID: <20260915071233.ABC123@mx.kasserver.com>
|
||||
Content-Type: multipart/report; report-type=delivery-status;
|
||||
boundary="ABC123.1758112353/mx.kasserver.com"
|
||||
|
||||
This is a MIME-encapsulated message.
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com
|
||||
Content-Description: Notification
|
||||
Content-Type: text/plain; charset=us-ascii
|
||||
|
||||
This is the mail system at host mx.kasserver.com.
|
||||
|
||||
I'm sorry to have to inform you that your message could not
|
||||
be delivered to one or more recipients.
|
||||
|
||||
<Presse@Alte-Firma.example>: host mail.alte-firma.example[192.0.2.10] said:
|
||||
550 5.1.1 <presse@alte-firma.example>: Recipient address rejected: User
|
||||
unknown in virtual mailbox table (in reply to RCPT TO command)
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com
|
||||
Content-Description: Delivery report
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
Reporting-MTA: dns; mx.kasserver.com
|
||||
X-Postfix-Queue-ID: 4XyZ12
|
||||
X-Postfix-Sender: rfc822; bounce@pressekonto.com
|
||||
Arrival-Date: Tue, 15 Sep 2026 09:12:30 +0200
|
||||
|
||||
Final-Recipient: rfc822; Presse@Alte-Firma.example
|
||||
Original-Recipient: rfc822;presse@alte-firma.example
|
||||
Action: failed
|
||||
Status: 5.1.1
|
||||
Remote-MTA: dns; mail.alte-firma.example
|
||||
Diagnostic-Code: smtp; 550 5.1.1 <presse@alte-firma.example>: Recipient
|
||||
address rejected: User unknown in virtual mailbox table
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com
|
||||
Content-Description: Undelivered Message Headers
|
||||
Content-Type: text/rfc822-headers
|
||||
|
||||
Return-Path: <bounce@pressekonto.com>
|
||||
From: pressekonto <service@pressekonto.com>
|
||||
To: presse@alte-firma.example
|
||||
Subject: Ihre Pressemitteilung wurde veroeffentlicht
|
||||
X-Pk-Ref: pm:4711:company:815
|
||||
Message-ID: <orig-1@pressekonto.com>
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com--
|
||||
Loading…
Add table
Add a link
Reference in a new issue