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>
114 lines
3.6 KiB
PHP
114 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace App\Rules;
|
|
|
|
use Closure;
|
|
use Illuminate\Contracts\Validation\ValidationRule;
|
|
|
|
/**
|
|
* Deterministische Plausibilitätsprüfung der Domain einer E-Mail-Adresse —
|
|
* ohne Netz (Kontakterreichbarkeit, Abschnitt 5.1).
|
|
*
|
|
* Laravels `email`-Regel folgt der RFC und lässt Domains ohne Punkt zu:
|
|
* `info@hoerwelt-basel` besteht sie anstandslos. Im Bestand vom 15.09.2026
|
|
* waren drei der sechs unzustellbaren API-Domains genau so kaputt — ohne TLD
|
|
* oder mit vertippter TLD. Das ist kein DNS-Befund, sondern ein Formfehler,
|
|
* und darf deshalb sofort als Validierungsfehler zurückgehen.
|
|
*
|
|
* Bewusst NICHT hier: Existenz der Domain oder des Postfachs. Dafür gibt es
|
|
* die asynchrone Domainprüfung (`MailDomainChecker`) und die Rückläufer.
|
|
*/
|
|
class PlausibleEmailDomain implements ValidationRule
|
|
{
|
|
/**
|
|
* Reine Tippfehler-TLDs aus dem Bestand. Die Liste bleibt kurz und
|
|
* beweisbar — geraten wird hier nicht.
|
|
*/
|
|
private const TYPO_TLDS = ['ded', 'dee', 'cmo', 'con', 'comm', 'ne', 'ed'];
|
|
|
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
{
|
|
$email = trim((string) $value);
|
|
|
|
if ($email === '') {
|
|
return;
|
|
}
|
|
|
|
$at = strrpos($email, '@');
|
|
|
|
if ($at === false || $at === 0 || $at === mb_strlen($email) - 1) {
|
|
$fail('Die E-Mail-Adresse :attribute ist unvollständig.')->translate();
|
|
|
|
return;
|
|
}
|
|
|
|
$domain = self::asciiDomain(mb_substr($email, $at + 1));
|
|
|
|
if ($domain === null || ! str_contains($domain, '.')) {
|
|
$fail('Die Domain der E-Mail-Adresse :attribute ist unvollständig — es fehlt die Endung (z. B. .de).')->translate();
|
|
|
|
return;
|
|
}
|
|
|
|
$labels = explode('.', $domain);
|
|
|
|
foreach ($labels as $label) {
|
|
if (preg_match('/^(?!-)[a-z0-9-]{1,63}(?<!-)$/', $label) !== 1) {
|
|
$fail('Die Domain der E-Mail-Adresse :attribute ist keine gültige Internet-Adresse.')->translate();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$tld = end($labels);
|
|
|
|
if (preg_match('/^(xn--[a-z0-9-]+|[a-z]{2,63})$/', $tld) !== 1) {
|
|
$fail('Die Domain der E-Mail-Adresse :attribute hat keine gültige Endung.')->translate();
|
|
|
|
return;
|
|
}
|
|
|
|
if (in_array($tld, self::TYPO_TLDS, true)) {
|
|
$fail('Die Endung „.:tld" der E-Mail-Adresse :attribute sieht nach einem Tippfehler aus.')->translate(['tld' => $tld]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internationale Domains in die DNS-taugliche Form bringen (Punycode).
|
|
* Ohne `intl` bleibt die Eingabe unverändert — dann greifen nur die
|
|
* ASCII-Regeln, was für den Zweck reicht.
|
|
*/
|
|
public static function asciiDomain(string $domain): ?string
|
|
{
|
|
$domain = mb_strtolower(trim(rtrim($domain, '.')));
|
|
|
|
if ($domain === '') {
|
|
return null;
|
|
}
|
|
|
|
if (function_exists('idn_to_ascii')) {
|
|
$converted = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
|
|
|
|
if (is_string($converted) && $converted !== '') {
|
|
return $converted;
|
|
}
|
|
|
|
// Nicht konvertierbar (verbotene Zeichen, zu lang) — als
|
|
// unplausibel behandeln, nicht als gültige ASCII-Domain.
|
|
return null;
|
|
}
|
|
|
|
return $domain;
|
|
}
|
|
|
|
/**
|
|
* Die Domain einer Adresse in DNS-Form, oder null.
|
|
*/
|
|
public static function domainOf(?string $email): ?string
|
|
{
|
|
$email = trim((string) $email);
|
|
$at = strrpos($email, '@');
|
|
|
|
return $at === false ? null : self::asciiDomain(mb_substr($email, $at + 1));
|
|
}
|
|
}
|