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:
Kevin Adametz 2026-09-15 13:55:30 +00:00
parent 8bf40ee37e
commit f9c97ccf2f
16 changed files with 1039 additions and 2 deletions

View file

@ -130,6 +130,13 @@ MAIL_FROM_NAME="${APP_NAME}"
# der From-Domain liegen (SPF-Ausrichtung), z. B. bounce@pressekonto.com. # der From-Domain liegen (SPF-Ausrichtung), z. B. bounce@pressekonto.com.
# Leer = From-Adresse. Schwellen siehe config/bounces.php. # Leer = From-Adresse. Schwellen siehe config/bounces.php.
MAIL_RETURN_PATH= 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_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=

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

View 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)';
}
}

View 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;
}
}
}

View 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,
) {}
}

View file

@ -21,7 +21,8 @@
"livewire/flux-pro": "^2.1", "livewire/flux-pro": "^2.1",
"livewire/volt": "^1.7.0", "livewire/volt": "^1.7.0",
"mews/purifier": "^3.4", "mews/purifier": "^3.4",
"spatie/laravel-permission": "^6.17" "spatie/laravel-permission": "^6.17",
"webklex/laravel-imap": "^6.2"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.16", "barryvdh/laravel-debugbar": "^3.16",

160
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7644392ee85d975d7adeb80988fbee8d", "content-hash": "d25da84dee547a1bf63626e58af6e723",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -7724,6 +7724,164 @@
} }
], ],
"time": "2026-04-26T05:33:54+00:00" "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": [ "packages-dev": [

196
config/imap.php Normal file
View 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,
],
];

View file

@ -1055,3 +1055,36 @@ Handliste in die zentrale Sperre kommt.
**Offen:** Schritt 3 (IMAP-Abholung, `webklex/laravel-imap`, freigegeben **Offen:** Schritt 3 (IMAP-Abholung, `webklex/laravel-imap`, freigegeben
15.09.2026) und Schritt 4 (Markierung im Admin, Claim-Seite, Freigabe). 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`.

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Console\Commands\AggregatePressReleaseViews; use App\Console\Commands\AggregatePressReleaseViews;
use App\Console\Commands\FetchMailBounces;
use App\Console\Commands\GenerateManualInvoices; use App\Console\Commands\GenerateManualInvoices;
use App\Console\Commands\PublishScheduledPressReleases; use App\Console\Commands\PublishScheduledPressReleases;
use App\Console\Commands\PurgeConversionEvents; use App\Console\Commands\PurgeConversionEvents;
@ -35,6 +36,15 @@ $alertOnFailure = fn (string $task) => function (Event $event) use ($task): void
// Tägliche Bereinigungen // 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) // Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
Schedule::command(PurgeMagicLinks::class, ['--days=30']) Schedule::command(PurgeMagicLinks::class, ['--days=30'])
->dailyAt('03:00') ->dailyAt('03:00')

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

View 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.

View 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--

View 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--

View 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

View 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--