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(); });