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:
Kevin Adametz 2026-09-15 13:55:30 +00:00
parent 8bf40ee37e
commit f9c97ccf2f
16 changed files with 1039 additions and 2 deletions

View file

@ -0,0 +1,85 @@
<?php
use App\Enums\ActivityType;
use App\Enums\EmailBounceKind;
use App\Models\ActivityEvent;
use App\Models\EmailBounce;
use App\Services\Mail\BounceMailProcessor;
use App\Services\Mail\DsnParser;
use App\Services\Mail\EmailSuppressionService;
use Tests\TestCase;
/**
* Rückläufer aus dem Bounce-Postfach lesen (Roadmap 1b.6, Schritt 3):
* eigener DSN-Parser nach RFC 3464, kein Raten aus dem Fließtext.
*/
function bounceFixture(string $name): string
{
return (string) file_get_contents(base_path("tests/Fixtures/bounces/{$name}.eml"));
}
test('a postfix hard bounce yields recipient, status, diagnostic and our reference header', function () {
/** @var TestCase $this */
$report = app(DsnParser::class)->parse(bounceFixture('hard-5.1.1'));
expect($report)->not->toBeNull()
->and($report->messageId)->toBe('20260915071233.ABC123@mx.kasserver.com')
->and($report->recipients)->toHaveCount(1)
->and($report->recipients[0]['email'])->toBe('presse@alte-firma.example')
->and($report->recipients[0]['action'])->toBe('failed')
->and($report->recipients[0]['status'])->toBe('5.1.1')
->and($report->recipients[0]['diagnostic'])->toContain('User unknown')
->and($report->reference)->toBe('pm:4711:company:815')
->and($report->receivedAt?->toDateString())->toBe('2026-09-15');
});
test('processing a hard bounce suppresses the address and keeps the reference', function () {
/** @var TestCase $this */
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and($result['bounces'])->toBe(1)
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue()
->and(EmailBounce::query()->first()->reference)->toBe('pm:4711:company:815')
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
// Zweiter Durchlauf derselben Mail (abgebrochener Lauf): nichts doppelt.
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
expect(EmailBounce::query()->count())->toBe(1);
});
test('a delayed report counts as a soft bounce and lower-case field names are fine', function () {
/** @var TestCase $this */
$result = app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
->and(EmailBounce::query()->first()->reference)->toBe('user:99')
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse();
});
test('a delivery confirmation blocks nobody', function () {
/** @var TestCase $this */
$result = app(BounceMailProcessor::class)->process(bounceFixture('delivered'));
expect($result['folder'])->toBe(BounceMailProcessor::Ignored)
->and(EmailBounce::query()->count())->toBe(0);
});
test('free-text bounces are not guessed but parked for a human, auto-replies are ignored', function () {
/** @var TestCase $this */
$unparsed = app(BounceMailProcessor::class)->process(bounceFixture('freitext'));
$ooo = app(BounceMailProcessor::class)->process(bounceFixture('abwesenheit'));
expect($unparsed['folder'])->toBe(BounceMailProcessor::Unparsed)
->and($ooo['folder'])->toBe(BounceMailProcessor::Ignored)
->and(app(EmailSuppressionService::class)->isSuppressed('jemand@alter-server.example'))->toBeFalse()
->and(ActivityEvent::query()->where('type', ActivityType::MailSuppressed->value)->where('description', 'like', '%nicht lesbar%')->exists())->toBeTrue();
});
test('the fetch command does nothing without a configured mailbox', function () {
/** @var TestCase $this */
config()->set('imap.accounts.bounces.host', null);
$this->artisan('mail:fetch-bounces')->expectsOutputToContain('nicht konfiguriert')->assertSuccessful();
});