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:
parent
0a658bb34b
commit
e80006589c
16 changed files with 1039 additions and 2 deletions
97
app/Console/Commands/FetchMailBounces.php
Normal file
97
app/Console/Commands/FetchMailBounces.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue