presseportale/app/Services/Mail/MailDomainChecker.php
Kevin Adametz ada41cb067 mail: Kontakterreichbarkeit - Formpruefung, Domainpruefung, Einlieferer-Bericht
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>
2026-09-16 09:22:39 +00:00

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