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>
102 lines
3.2 KiB
PHP
102 lines
3.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Mail;
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Enums\EmailBounceKind;
|
|
use App\Services\ActivityLog;
|
|
|
|
/**
|
|
* Was mit einer Mail aus dem Bounce-Postfach geschieht — getrennt vom
|
|
* Abholen, damit es sich mit .eml-Fixtures testen lässt.
|
|
*
|
|
* Ergebnis ist der Ziel-Ordner: `Processed` (Zustellbericht gelesen),
|
|
* `Ignored` (Abwesenheitsnotiz oder reine Zustellbestätigung) oder
|
|
* `Unparsed` (kein lesbarer Bericht — sieht sich ein Mensch an).
|
|
*/
|
|
class BounceMailProcessor
|
|
{
|
|
public const Processed = 'Processed';
|
|
|
|
public const Ignored = 'Ignored';
|
|
|
|
public const Unparsed = 'Unparsed';
|
|
|
|
public function __construct(
|
|
private readonly DsnParser $parser,
|
|
private readonly EmailSuppressionService $suppressions,
|
|
private readonly ActivityLog $activityLog,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{folder: string, bounces: int, subject: string}
|
|
*/
|
|
public function process(string $raw, bool $dryRun = false): array
|
|
{
|
|
$subject = $this->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)';
|
|
}
|
|
}
|