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>
100 lines
3.8 KiB
PHP
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);
|
|
}
|
|
}
|