presseportale/app/Console/Commands/FetchMailBounces.php
Kevin Adametz f9c97ccf2f 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>
2026-09-15 13:55:30 +00:00

97 lines
3.6 KiB
PHP

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