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