diff --git a/.env.example b/.env.example index bd224309..cd97d29d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/Console/Commands/FetchMailBounces.php b/app/Console/Commands/FetchMailBounces.php new file mode 100644 index 00000000..5bb46f18 --- /dev/null +++ b/app/Console/Commands/FetchMailBounces.php @@ -0,0 +1,97 @@ +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); + } +} diff --git a/app/Services/Mail/BounceMailProcessor.php b/app/Services/Mail/BounceMailProcessor.php new file mode 100644 index 00000000..419a1fd8 --- /dev/null +++ b/app/Services/Mail/BounceMailProcessor.php @@ -0,0 +1,102 @@ +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)'; + } +} diff --git a/app/Services/Mail/DsnParser.php b/app/Services/Mail/DsnParser.php new file mode 100644 index 00000000..94df3867 --- /dev/null +++ b/app/Services/Mail/DsnParser.php @@ -0,0 +1,198 @@ +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 + */ + 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, 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 + */ + 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; + } + } +} diff --git a/app/Services/Mail/DsnReport.php b/app/Services/Mail/DsnReport.php new file mode 100644 index 00000000..42d9765a --- /dev/null +++ b/app/Services/Mail/DsnReport.php @@ -0,0 +1,21 @@ + $recipients + */ + public function __construct( + public string $messageId, + public array $recipients, + public ?string $reference, + public ?Carbon $receivedAt, + ) {} +} diff --git a/composer.json b/composer.json index 5e18031c..90768d28 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 661e9e9f..0dbfca73 100644 --- a/composer.lock +++ b/composer.lock @@ -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": [ diff --git a/config/imap.php b/config/imap.php new file mode 100644 index 00000000..926e91ae --- /dev/null +++ b/config/imap.php @@ -0,0 +1,196 @@ + '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, + ], +]; diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index b311eac5..9a949ac9 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -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`. diff --git a/routes/console.php b/routes/console.php index 2cb024e2..4a25c314 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ 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') diff --git a/tests/Feature/BounceMailProcessingTest.php b/tests/Feature/BounceMailProcessingTest.php new file mode 100644 index 00000000..04b0e4f4 --- /dev/null +++ b/tests/Feature/BounceMailProcessingTest.php @@ -0,0 +1,85 @@ +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(); +}); diff --git a/tests/Fixtures/bounces/abwesenheit.eml b/tests/Fixtures/bounces/abwesenheit.eml new file mode 100644 index 00000000..1706e8ed --- /dev/null +++ b/tests/Fixtures/bounces/abwesenheit.eml @@ -0,0 +1,9 @@ +Date: Tue, 16 Sep 2026 10:00:00 +0200 +From: Erika Muster +To: bounce@pressekonto.com +Subject: Automatische Antwort: Ihre Pressemitteilung wurde veroeffentlicht +Auto-Submitted: auto-replied +Message-ID: +Content-Type: text/plain; charset=utf-8 + +Ich bin bis zum 30.09. nicht im Buero. diff --git a/tests/Fixtures/bounces/delayed-4.4.1.eml b/tests/Fixtures/bounces/delayed-4.4.1.eml new file mode 100644 index 00000000..20fcb29f --- /dev/null +++ b/tests/Fixtures/bounces/delayed-4.4.1.eml @@ -0,0 +1,32 @@ +Date: Tue, 16 Sep 2026 10:00:00 +0200 +From: Mail Delivery System +To: bounce@pressekonto.com +Subject: Delayed Mail (still being retried) +Message-ID: +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-- diff --git a/tests/Fixtures/bounces/delivered.eml b/tests/Fixtures/bounces/delivered.eml new file mode 100644 index 00000000..122296c8 --- /dev/null +++ b/tests/Fixtures/bounces/delivered.eml @@ -0,0 +1,21 @@ +Date: Tue, 16 Sep 2026 10:00:00 +0200 +From: Mail Delivery System +Subject: Successful Mail Delivery Report +Message-ID: +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-- diff --git a/tests/Fixtures/bounces/freitext.eml b/tests/Fixtures/bounces/freitext.eml new file mode 100644 index 00000000..c3f31e85 --- /dev/null +++ b/tests/Fixtures/bounces/freitext.eml @@ -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: +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 diff --git a/tests/Fixtures/bounces/hard-5.1.1.eml b/tests/Fixtures/bounces/hard-5.1.1.eml new file mode 100644 index 00000000..1e727aff --- /dev/null +++ b/tests/Fixtures/bounces/hard-5.1.1.eml @@ -0,0 +1,53 @@ +Return-Path: <> +Date: Tue, 15 Sep 2026 09:12:33 +0200 +From: Mail Delivery System +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. + +: host mail.alte-firma.example[192.0.2.10] said: + 550 5.1.1 : 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 : 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: +From: pressekonto +To: presse@alte-firma.example +Subject: Ihre Pressemitteilung wurde veroeffentlicht +X-Pk-Ref: pm:4711:company:815 +Message-ID: + +--ABC123.1758112353/mx.kasserver.com--