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>
151 lines
5.9 KiB
PHP
151 lines
5.9 KiB
PHP
<?php
|
|
|
|
use App\Enums\EmailDomainState;
|
|
use App\Jobs\CheckEmailDomain;
|
|
use App\Models\Company;
|
|
use App\Models\EmailDomainCheck;
|
|
use App\Services\Mail\DnsResolver;
|
|
use App\Services\Mail\EmailSuppressionService;
|
|
use App\Services\Mail\MailDomainChecker;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Tests\Support\FakeDnsResolver;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* DNS-Prüfung der Mail-Domains (Kontakterreichbarkeit, Abschnitt 3 und 8).
|
|
* Der Resolver ist gefälscht — kein Test fragt echtes DNS.
|
|
*/
|
|
function fakeDns(array $zones): MailDomainChecker
|
|
{
|
|
$resolver = new FakeDnsResolver($zones);
|
|
app()->instance(DnsResolver::class, $resolver);
|
|
|
|
return app(MailDomainChecker::class);
|
|
}
|
|
|
|
test('a usable mx means deliverable, without claiming the mailbox exists', function () {
|
|
/** @var TestCase $this */
|
|
$check = fakeDns([
|
|
'firma.example' => ['MX' => [['target' => 'mx.firma.example', 'pri' => 10]]],
|
|
'mx.firma.example' => ['A' => [['ip' => '203.0.113.5']]],
|
|
])->check('firma.example');
|
|
|
|
expect($check->state)->toBe(EmailDomainState::Deliverable)
|
|
->and($check->state->isDeliverable())->toBeTrue()
|
|
->and($check->state->isBroken())->toBeFalse()
|
|
->and($check->expires_at)->not->toBeNull();
|
|
});
|
|
|
|
test('no mx but an a record is the implicit mx fallback of rfc 5321', function () {
|
|
/** @var TestCase $this */
|
|
$check = fakeDns(['firma.example' => ['A' => [['ip' => '203.0.113.5']]]])->check('firma.example');
|
|
|
|
expect($check->state)->toBe(EmailDomainState::ImplicitMx)
|
|
->and($check->state->isDeliverable())->toBeTrue();
|
|
});
|
|
|
|
test('a null mx says no mail, even with an a record present', function () {
|
|
/** @var TestCase $this */
|
|
// RFC 7505: `MX 0 .` — kein Zurückfallen auf die Webserver-IP.
|
|
$check = fakeDns([
|
|
'firma.example' => ['MX' => [['target' => '.', 'pri' => 0]], 'A' => [['ip' => '203.0.113.5']]],
|
|
])->check('firma.example');
|
|
|
|
expect($check->state)->toBe(EmailDomainState::NullMx)
|
|
->and($check->state->isBroken())->toBeTrue();
|
|
});
|
|
|
|
test('a domain without any records is missing, one with a zone but no mail path is not', function () {
|
|
/** @var TestCase $this */
|
|
$missing = fakeDns(['weg.example' => []])->check('weg.example');
|
|
$noPath = fakeDns(['nurweb.example' => ['NS' => [['target' => 'ns1.example']]]])->check('nurweb.example');
|
|
|
|
expect($missing->state)->toBe(EmailDomainState::DomainMissing)
|
|
->and($noPath->state)->toBe(EmailDomainState::NoMailPath);
|
|
});
|
|
|
|
test('one usable mx target is enough even when a sibling is broken', function () {
|
|
/** @var TestCase $this */
|
|
$check = fakeDns([
|
|
'firma.example' => ['MX' => [['target' => 'kaputt.example'], ['target' => 'mx2.example']]],
|
|
'kaputt.example' => [],
|
|
'mx2.example' => ['AAAA' => [['ipv6' => '2001:db8::1']]],
|
|
])->check('firma.example');
|
|
|
|
expect($check->state)->toBe(EmailDomainState::Deliverable);
|
|
});
|
|
|
|
test('mx targets that all fail to resolve are a broken configuration', function () {
|
|
/** @var TestCase $this */
|
|
$check = fakeDns([
|
|
'firma.example' => ['MX' => [['target' => 'kaputt.example']]],
|
|
'kaputt.example' => [],
|
|
])->check('firma.example');
|
|
|
|
expect($check->state)->toBe(EmailDomainState::BrokenMx)
|
|
->and($check->state->isBroken())->toBeTrue();
|
|
});
|
|
|
|
test('a resolver failure is no finding and is retried with growing distance', function () {
|
|
/** @var TestCase $this */
|
|
// Timeout/SERVFAIL darf nie als „Domain kaputt" gelten.
|
|
$checker = fakeDns(['firma.example' => ['MX' => null]]);
|
|
$first = $checker->check('firma.example');
|
|
$firstExpiry = $first->expires_at;
|
|
|
|
expect($first->state)->toBe(EmailDomainState::Unresolved)
|
|
->and($first->state->isBroken())->toBeFalse()
|
|
->and($first->attempts)->toBe(1);
|
|
|
|
$second = $checker->check('firma.example', force: true);
|
|
|
|
expect($second->attempts)->toBe(2)
|
|
->and($second->expires_at->greaterThan($firstExpiry))->toBeTrue();
|
|
});
|
|
|
|
test('a temporary mx target failure does not become a broken configuration', function () {
|
|
/** @var TestCase $this */
|
|
$check = fakeDns([
|
|
'firma.example' => ['MX' => [['target' => 'mx.example']]],
|
|
'mx.example' => ['A' => null, 'AAAA' => null],
|
|
])->check('firma.example');
|
|
|
|
expect($check->state)->toBe(EmailDomainState::Unresolved);
|
|
});
|
|
|
|
test('a fresh result is reused and a correction re-checks the new domain', function () {
|
|
/** @var TestCase $this */
|
|
$resolver = new FakeDnsResolver(['firma.example' => []]);
|
|
app()->instance(DnsResolver::class, $resolver);
|
|
$checker = app(MailDomainChecker::class);
|
|
|
|
expect($checker->check('firma.example')->state)->toBe(EmailDomainState::DomainMissing);
|
|
|
|
// Domain repariert — der gespeicherte Befund gilt aber noch.
|
|
$resolver->set('firma.example', 'A', [['ip' => '203.0.113.9']]);
|
|
|
|
expect($checker->check('firma.example')->state)->toBe(EmailDomainState::DomainMissing)
|
|
->and($checker->check('firma.example', force: true)->state)->toBe(EmailDomainState::ImplicitMx)
|
|
->and(EmailDomainCheck::query()->where('domain', 'firma.example')->first()->first_broken_at)->toBeNull();
|
|
});
|
|
|
|
test('saving a company queues a check for its mail domain, unchanged addresses do not', function () {
|
|
/** @var TestCase $this */
|
|
Queue::fake();
|
|
|
|
$company = Company::factory()->create(['email' => 'presse@firma.example']);
|
|
Queue::assertPushed(CheckEmailDomain::class, fn ($job) => $job->domain === 'firma.example');
|
|
|
|
$company->update(['name' => 'Neuer Name']);
|
|
Queue::assertPushed(CheckEmailDomain::class, 1);
|
|
|
|
$company->update(['email' => 'presse@andere.example']);
|
|
Queue::assertPushed(CheckEmailDomain::class, fn ($job) => $job->domain === 'andere.example');
|
|
});
|
|
|
|
test('the domain finding never suppresses an address', function () {
|
|
/** @var TestCase $this */
|
|
fakeDns(['firma.example' => []])->check('firma.example');
|
|
|
|
expect(app(EmailSuppressionService::class)->isSuppressed('presse@firma.example'))->toBeFalse();
|
|
});
|