Vier Punkte aus kontakterreichbarkeit-mx-bounces-2026-09-15.md, ohne Veroeffentlichungssperre: - PlausibleEmailDomain: Domain ohne Endung oder mit Tippfehler-TLD ist ein 422 an der Legacy-API, deterministisch ohne DNS. - email_domain_checks + MailDomainChecker ueber DnsResolver-Schnittstelle (RFC 5321 impliziter MX, RFC 7505 Null MX, Unresolved ist kein Befund), Observer an Company/Contact, Job nach Commit, taeglicher Refresh, mail:check-domains fuer den Bestand. Nie eine Adresssperre aus DNS. - Admin > Reports > Einlieferer-Bericht: tote Adressen aus Sperrliste und Domainbefund, automatisch abgelehnte Meldungen mit Grund, Text zum Kopieren und CSV. Versendet nichts. - EmailBounceKind::Policy: 5.7.x wird protokolliert, zaehlt aber nicht auf den Schwellwert. Statuscode entscheidet vor Diagnosetext. Messung vorab: 6 von 877 API-Domains ohne Zustellweg, eine davon fuer 66 der 151 Ruecklaeufer verantwortlich. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
7.4 KiB
PHP
155 lines
7.4 KiB
PHP
<?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 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 <pr@example.net>: 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();
|
|
});
|