presseportale/app/Console/Commands/FetchMailBounces.php
Kevin Adametz 4cefdf7f5c mail: Bounce-Abruf liest RFC 6533, All-Inkl-Freitext und ganze INBOX
Befund aus 151 echten Rücklaeufern: 2 SmarterMail-DSNs
(message/global-delivery-status) und 2 Freitext-Hinweise des eigenen
Relays (X-KASMail-Error, formal auto-replied) fielen durch. Policy- und
Quota-Codes zaehlen weich statt hart. Abruf ohne UNSEEN-Filter, weil
das Verschieben die Zustandsmarke ist.

Dazu Schutz in tests/TestCase.php: Suite bricht ab, wenn ein Config-Cache
sie auf die Dev-Datenbank umlenkt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 15:27:09 +00:00

100 lines
3.8 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 — 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. Deshalb zählt auch alles in
* der INBOX, nicht nur Ungelesenes: Was ein Mensch dort angesehen oder aus
* `service@` hinübergeschoben hat, trägt das Gelesen-Flag schon und würde
* sonst nie verarbeitet.
*/
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()->all()->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);
}
}