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 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(bounceFixture('global-delivery-status')); 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); expect($result['folder'])->toBe(BounceMailProcessor::Processed) ->and($result['bounces'])->toBe(2) ->and($suppressions->isSuppressed('presse@example.net'))->toBeTrue() ->and($suppressions->isSuppressed('info@example.org'))->toBeTrue(); }); 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@'], bounceFixture('policy-5.7.1'), ); 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(); });