'X-Pk-Ref: '.app(BounceReference::class)->sign(trim($m[1]), [$recipient]), bounceFixture($name), ); } 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(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example')); 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(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example')); 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(signedBounceFixture('delayed-4.4.1', 'voll@example.test')); 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 utf-8 dsn variant of rfc 6533 is read like a classic report', function () { /** @var TestCase $this */ // SmarterMail liefert `message/global-delivery-status` und die // Original-Header als `message/global-headers` (Bestand 15.09.2026). $report = app(DsnParser::class)->parse(bounceFixture('global-delivery-status')); expect($report)->not->toBeNull() ->and($report->recipients)->toHaveCount(1) ->and($report->recipients[0]['email'])->toBe('weiterleitung@example.net') ->and($report->recipients[0]['status'])->toBe('5.0.0') ->and($report->reference)->toBe('user:4711'); $result = app(BounceMailProcessor::class)->process(signedBounceFixture('global-delivery-status', 'weiterleitung@example.net')); expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard); }); test('the free-text notice of our own relay is read and never mistaken for an auto-reply', function () { /** @var TestCase $this */ // All-Inkl schickt „Auto-Submitted: auto-replied" mit — als Abwesenheit // ignoriert, wären das genau die Adressen, die nie gesperrt würden. expect(app(DsnParser::class)->looksLikeAutoReply(bounceFixture('kas-antispam')))->toBeFalse(); $result = app(BounceMailProcessor::class)->process(bounceFixture('kas-antispam')); $suppressions = app(EmailSuppressionService::class); // Der Freitext trägt keine Original-Header, also keine signierte // Referenz: nur zur Sichtung protokolliert, zählt auf keinen // Schwellwert (R-04, Umsetzungsaudit 02.10.2026, O01). expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and($result['bounces'])->toBe(2) ->and(EmailBounce::query()->where('email', 'presse@example.net')->value('kind'))->toBe(EmailBounceKind::Policy) ->and($suppressions->isSuppressed('presse@example.net'))->toBeFalse(); // Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture. $this->travelTo('2026-09-03 12:00:00'); foreach ([2, 3] as $i) { app(BounceMailProcessor::class)->process(str_replace('844FE5F41C65@', '844FE5F41C6'.$i.'@', bounceFixture('kas-antispam'))); } expect($suppressions->isSuppressed('presse@example.net'))->toBeFalse() ->and($suppressions->isSuppressed('info@example.org'))->toBeFalse(); }); test('policy rejections and full mailboxes never count hard', function () { /** @var TestCase $this */ // 5.7.1 „Relay access denied": die Adresse existiert, ihr Server nimmt // nur nichts an — protokollieren, nicht zählen. $result = app(BounceMailProcessor::class)->process(bounceFixture('policy-5.7.1')); expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Policy) ->and(EmailBounce::query()->first()->reference)->toBe('pm:99:company:7') ->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse(); $quota = str_replace( ['Status: 5.7.1', '554 5.7.1 : Relay access denied', '<20260915120000.ABC@'], ['Status: 5.2.2', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'], signedBounceFixture('policy-5.7.1', 'pr@example.net'), ); app(BounceMailProcessor::class)->process($quota); expect(EmailBounce::query()->count())->toBe(2) ->and(EmailBounce::query()->where('kind', EmailBounceKind::Soft->value)->count())->toBe(1) ->and(EmailBounce::query()->where('kind', EmailBounceKind::Hard->value)->count())->toBe(0); }); 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(); }); test('a policy rejection is logged but never counts toward suppression', function () { /** @var TestCase $this */ // Vier Richtlinien-Ablehnungen: Der Schwellwert (3) darf sie nicht // erfassen — das Postfach kann einwandfrei existieren. $raw = bounceFixture('policy-5.7.1'); foreach (range(1, 4) as $i) { app(BounceMailProcessor::class)->process(str_replace('<20260915120000.ABC@', '<2026091512000'.$i.'.ABC@', $raw)); } expect(EmailBounce::query()->where('kind', EmailBounceKind::Policy->value)->count())->toBe(4) ->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse(); }); test('a forged report without our signature never suppresses hard', function () { /** @var TestCase $this */ // Jeder kann ein multipart/report ans Return-Path-Postfach schicken. $result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1')); expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy) ->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (unsigniert)') ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); }); test('a report with a wrong or foreign signature is parked for review', function () { /** @var TestCase $this */ // Signatur einer echten Mail an den Angreifer selbst, auf die Opferadresse umgeschrieben. $replayed = signedBounceFixture('hard-5.1.1', 'angreifer@example.test'); $result = app(BounceMailProcessor::class)->process($replayed); expect($result['bounces'])->toBe(1) ->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy) ->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (Signatur ungültig)') ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); // Beliebig oft wiederholt: Richtlinie zählt auf keinen Schwellwert. foreach (range(1, 3) as $i) { app(BounceMailProcessor::class)->process(str_replace('<20260915071233.ABC123@', '<2026091507123'.$i.'.ABC123@', $replayed)); } expect(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); }); test('unsigned reports only count when the operator opts in, and then never hard', function () { /** @var TestCase $this */ config()->set('bounces.unsigned_kind', 'soft'); app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1')); expect(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Soft) ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse(); }); test('repeated unsigned or wrongly signed soft reports never suppress an address', function (string $variant) { /** @var TestCase $this */ // Umsetzungsaudit 02.10.2026, O01: Der weiche Weg (delayed, failed 4.x) // lief an der Signaturprüfung vorbei — drei gefälschte Berichte in // sieben Tagen sperrten jede beliebige Adresse. $raw = match ($variant) { 'delayed, unsigned' => bounceFixture('delayed-4.4.1'), 'delayed, foreign signature' => signedBounceFixture('delayed-4.4.1', 'angreifer@example.test'), 'failed 4.x, unsigned' => str_replace(['Action: delayed', 'action: delayed'], ['Action: failed', 'action: failed'], bounceFixture('delayed-4.4.1')), }; $this->travelTo('2026-09-17 12:00:00'); foreach (range(1, 4) as $i) { app(BounceMailProcessor::class)->process(str_replace('count())->toBe(4) ->and(EmailBounce::query()->where('kind', '!=', EmailBounceKind::Policy->value)->count())->toBe(0) ->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse(); })->with(['delayed, unsigned', 'delayed, foreign signature', 'failed 4.x, unsigned']); test('validly signed soft reports still suppress at the threshold', function () { /** @var TestCase $this */ // Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture. $this->travelTo('2026-09-17 12:00:00'); foreach (range(1, 3) as $i) { app(BounceMailProcessor::class)->process(str_replace('where('kind', EmailBounceKind::Soft->value)->count())->toBe(3) ->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeTrue(); }); test('the reference signature is bound to the recipient', function () { $references = app(BounceReference::class); $header = $references->sign('user:5', ['A@Example.test', 'b@example.test']); expect($header)->toStartWith('user:5; s=') ->and($references->plain($header))->toBe('user:5') ->and($references->verify($header, 'a@example.test'))->toBeTrue() ->and($references->verify($header, 'b@example.test'))->toBeTrue() ->and($references->verify($header, 'c@example.test'))->toBeFalse() ->and($references->verify(str_replace('user:5', 'user:6', $header), 'a@example.test'))->toBeFalse() ->and($references->verify('user:5', 'a@example.test'))->toBeFalse() ->and($references->isSigned('user:5'))->toBeFalse(); }); test('bounce events are purged after the retention period while the suppression stays', function () { /** @var TestCase $this */ app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example')); app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1')); EmailBounce::query()->where('email', 'presse@alte-firma.example')->update(['created_at' => now()->subDays(400)]); $this->artisan('conversion:purge-events', ['--dry-run' => true]) ->expectsOutputToContain('[Dry-Run] 1 Rückläufer-Ereignisse') ->assertSuccessful(); expect(EmailBounce::query()->count())->toBe(2); // Umsetzungsaudit 29.09.2026, R-10: Adresse und Diagnosetext nach 395 Tagen weg. $this->artisan('conversion:purge-events')->assertSuccessful(); expect(EmailBounce::query()->pluck('email')->all())->toBe(['voll@example.test']) ->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue(); });