presseportale/app/Rules/PlausibleEmailDomain.php
Kevin Adametz f581e1eb3a 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

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