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>
179 lines
5.9 KiB
PHP
179 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Mail;
|
|
|
|
use App\Enums\EmailDomainState;
|
|
use App\Models\EmailDomainCheck;
|
|
use App\Rules\PlausibleEmailDomain;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* Prüft, ob eine E-Mail-Domain einen Zustellweg hat (Kontakterreichbarkeit,
|
|
* Abschnitt 3) — und nur das.
|
|
*
|
|
* Bewusste Grenzen, die das Konzept vorgibt:
|
|
*
|
|
* - Ein positiver Befund bestätigt weder Postfach noch Identität.
|
|
* - Ein vorhandener, aber defekter MX wird NICHT durch die Webserver-IP
|
|
* umgangen; der A/AAAA-Fallback gilt nur, wenn gar kein MX da ist.
|
|
* - Null MX (`MX 0 .`) hebt kein A-Eintrag auf.
|
|
* - Timeout/SERVFAIL ist kein Befund, sondern ein Grund zur Wiederholung.
|
|
* - Aus einem DNS-Ergebnis entsteht nie eine Adresssperre.
|
|
*/
|
|
class MailDomainChecker
|
|
{
|
|
public function __construct(private readonly DnsResolver $resolver) {}
|
|
|
|
/**
|
|
* Befund zur Domain einer Adresse — aus dem Speicher, solange er gilt.
|
|
*/
|
|
public function forEmail(?string $email, bool $force = false): ?EmailDomainCheck
|
|
{
|
|
$domain = PlausibleEmailDomain::domainOf($email);
|
|
|
|
return $domain === null ? null : $this->check($domain, $force);
|
|
}
|
|
|
|
public function check(string $domain, bool $force = false): EmailDomainCheck
|
|
{
|
|
$domain = PlausibleEmailDomain::asciiDomain($domain) ?? mb_strtolower(trim($domain));
|
|
$record = EmailDomainCheck::query()->firstOrNew(['domain' => mb_substr($domain, 0, 190)]);
|
|
|
|
if (! $force && $record->exists && $record->isFresh()) {
|
|
return $record;
|
|
}
|
|
|
|
[$state, $detail] = $this->resolve($domain);
|
|
|
|
$record->fill([
|
|
'state' => $state,
|
|
'detail' => $detail !== null ? mb_substr($detail, 0, 255) : null,
|
|
'attempts' => $state === EmailDomainState::Unresolved ? (int) $record->attempts + 1 : 0,
|
|
'checked_at' => now(),
|
|
'expires_at' => $this->expiryFor($state, (int) $record->attempts),
|
|
]);
|
|
|
|
if ($state->isBroken()) {
|
|
$record->first_broken_at ??= now();
|
|
} else {
|
|
$record->first_broken_at = null;
|
|
}
|
|
|
|
$record->save();
|
|
|
|
return $record;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: EmailDomainState, 1: string|null}
|
|
*/
|
|
private function resolve(string $domain): array
|
|
{
|
|
if (! str_contains($domain, '.')) {
|
|
return [EmailDomainState::DomainMissing, 'Domain ohne Endung'];
|
|
}
|
|
|
|
$mx = $this->resolver->records($domain, 'MX');
|
|
|
|
if ($mx === null) {
|
|
return [EmailDomainState::Unresolved, 'Resolver antwortet nicht (Timeout/SERVFAIL)'];
|
|
}
|
|
|
|
$targets = [];
|
|
|
|
foreach ($mx as $entry) {
|
|
$targets[] = rtrim((string) ($entry['target'] ?? ''), '.');
|
|
}
|
|
|
|
// RFC 7505: ein einzelner MX mit leerem Ziel heißt „nimmt keine Mail an".
|
|
if ($targets !== [] && array_filter($targets) === []) {
|
|
return [EmailDomainState::NullMx, 'Null MX (RFC 7505)'];
|
|
}
|
|
|
|
$targets = array_values(array_filter($targets));
|
|
|
|
if ($targets !== []) {
|
|
return $this->resolveMxTargets($targets);
|
|
}
|
|
|
|
// Kein MX: impliziter MX über A/AAAA der Domain selbst.
|
|
$a = $this->resolver->records($domain, 'A');
|
|
$aaaa = $this->resolver->records($domain, 'AAAA');
|
|
|
|
if ($a === null || $aaaa === null) {
|
|
return [EmailDomainState::Unresolved, 'Resolver antwortet nicht (Timeout/SERVFAIL)'];
|
|
}
|
|
|
|
if ($a !== [] || $aaaa !== []) {
|
|
return [EmailDomainState::ImplicitMx, 'Kein MX, Zustellung über A/AAAA'];
|
|
}
|
|
|
|
// Weder MX noch Adresse: existiert die Zone überhaupt?
|
|
return $this->zoneExists($domain)
|
|
? [EmailDomainState::NoMailPath, 'Domain vorhanden, kein MX und kein A/AAAA']
|
|
: [EmailDomainState::DomainMissing, 'Keine DNS-Einträge (NXDOMAIN)'];
|
|
}
|
|
|
|
/**
|
|
* Ein nutzbares Ziel genügt; defekte Geschwister sind unerheblich. Ein
|
|
* Resolver-Fehler auf einem Ziel darf nicht als „alle defekt" gelten.
|
|
*
|
|
* @param list<string> $targets
|
|
* @return array{0: EmailDomainState, 1: string|null}
|
|
*/
|
|
private function resolveMxTargets(array $targets): array
|
|
{
|
|
$unresolved = false;
|
|
|
|
foreach ($targets as $target) {
|
|
$a = $this->resolver->records($target, 'A');
|
|
$aaaa = $this->resolver->records($target, 'AAAA');
|
|
|
|
if ($a === null || $aaaa === null) {
|
|
$unresolved = true;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($a !== [] || $aaaa !== []) {
|
|
return [EmailDomainState::Deliverable, 'MX: '.implode(', ', array_slice($targets, 0, 3))];
|
|
}
|
|
}
|
|
|
|
return $unresolved
|
|
? [EmailDomainState::Unresolved, 'MX-Ziel nicht auflösbar (vorübergehend)']
|
|
: [EmailDomainState::BrokenMx, 'MX vorhanden, kein Ziel auflösbar: '.implode(', ', array_slice($targets, 0, 3))];
|
|
}
|
|
|
|
private function zoneExists(string $domain): bool
|
|
{
|
|
foreach (['NS', 'SOA'] as $type) {
|
|
$records = $this->resolver->records($domain, $type);
|
|
|
|
if ($records !== null && $records !== []) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Positive Befunde halten lange, negative kurz (eine Domain wird
|
|
* repariert), Resolver-Fehler nur Minuten mit wachsendem Abstand.
|
|
*/
|
|
private function expiryFor(EmailDomainState $state, int $attempts): Carbon
|
|
{
|
|
if ($state === EmailDomainState::Unresolved) {
|
|
$minutes = (int) config('bounces.domain_check.retry_minutes', 15);
|
|
|
|
return now()->addMinutes(min($minutes * (2 ** min($attempts, 5)), 60 * 24));
|
|
}
|
|
|
|
$days = $state->isDeliverable()
|
|
? (int) config('bounces.domain_check.ttl_days', 30)
|
|
: (int) config('bounces.domain_check.negative_ttl_days', 3);
|
|
|
|
return now()->addDays(max(1, $days));
|
|
}
|
|
}
|