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>
This commit is contained in:
Kevin Adametz 2026-09-16 09:22:39 +00:00
parent fddfd8a61c
commit ada41cb067
34 changed files with 1985 additions and 25 deletions

View file

@ -1206,3 +1206,77 @@ ist weg (alle Tabellen neu angelegt 15:21:58–15:22:11). `composer run
test` räumt den Cache vorher weg, `php artisan test` direkt nicht.
`tests/TestCase.php` bricht jetzt hart ab, wenn die Verbindung nicht
SQLite :memory: ist. Die Kopie muss neu eingespielt werden.
## Kontakterreichbarkeit — Umsetzung der Punkte 1–4 (16.09.2026)
Grundlage: `dev/2026-09-11-audit/kontakterreichbarkeit-mx-bounces-2026-09-15.md`.
Vor der Entscheidung gemessen (Dev-DB, echtes DNS):
- 877 Firmen-Domains aus sechs Monaten API-Einlieferung: 870 mit MX, 1
nur A (zulässiger Fallback), **6 ohne Zustellweg**, 0 Null MX, 0
Resolver-Fehler. Die sechs stehen für 75 Meldungen; `sdmediapr.com`
allein für 68 Meldungen einer Firma und **66 der 151 Rückläufer**.
- Pressekontakte an Meldungen: 401 im Gesamtbestand, 21 in sechs
Monaten — Benachrichtigungen gehen praktisch immer an die Firmenadresse.
Grenze 2 des Dokuments (erster Kontakt verdeckt zweiten) ist damit ohne
praktische Bedeutung und wurde nicht angefasst.
- Mit Kontakten dazu (voller Lauf, 1 200 Domains): 10 Domains ohne
Zustellweg, darunter `kaeser-stroebelde` (Punkt verloren) und
`bester-immobilienmakler-neustadt-am-ruebenber` (bei 45 Zeichen
abgeschnitten) — Datenqualität beim Einlieferer, nicht bei uns.
Entscheidung mit dem Nutzer: Punkte 1–4 jetzt, **keine**
Veröffentlichungssperre (Abschnitt 6 / Stufe 3 des Dokuments) — ein bis
zwei Fälle im Monat rechtfertigen die Zustandsmaschine nicht, die
Sperrliste verhindert Wiederholungsmails bereits. Nach Beobachtung neu
entscheiden.
**1. Formprüfung beim Einreichen** — `App\Rules\PlausibleEmailDomain`,
deterministisch, ohne Netz: Domain braucht eine Endung, gültige Labels,
alphabetische TLD (oder `xn--`), kurze Liste beweisbarer Tippfehler-TLDs
(`.ded`, `.cmo` …). IDN wird per Punycode normalisiert. Greift an
`company.email` und `contact.email` der Legacy-API; 422 mit klarem Grund.
`info@hoerwelt-basel` bestand Laravels `email`-Regel bisher.
**2. Domainprüfung als Beobachtung** — `email_domain_checks` je Domain
mit Zustand (`EmailDomainState`: Deliverable, ImplicitMx, NullMx,
NoMailPath, DomainMissing, BrokenMx, Unresolved), Grund, Prüfzeit,
Ablauf. `MailDomainChecker` über die Schnittstelle `DnsResolver`
(`SystemDnsResolver` produktiv, `FakeDnsResolver` im Test — kein Test
fragt echtes DNS). Regeln aus dem Dokument umgesetzt: A/AAAA-Fallback nur
ohne MX, Null MX schlägt A, ein nutzbares MX-Ziel genügt, Timeout/SERVFAIL
ist kein Befund und wird mit wachsendem Abstand wiederholt, positive
Befunde gelten 30 Tage, negative 3. Anstoß über `EmailDomainObserver` an
`Company` und `Contact` (Job `CheckEmailDomain` nach Commit, alle
Strecken gleich), Auffrischung täglich 04:20 via `mail:check-domains
--stale`, Bestandsaufnahme mit `mail:check-domains --months=`. **Aus
einem DNS-Befund entsteht nie eine Adresssperre** — im Test verankert.
Firmenseite im Admin zeigt den Befund als Badge.
**3. Einlieferer-Bericht** — Admin ▸ Reports ▸ Einlieferer-Bericht
(`/admin/reports/einlieferer`), Auswahl Einlieferer und Zeitraum. Drei
Quellen, in der Anzeige vereint: nicht erreichbare Adressen (Sperrliste
und Domainbefund, letzterer als schärfere Aussage bevorzugt) mit Firma und
Zahl betroffener Meldungen; automatisch abgelehnte Meldungen mit der
Begründung aus dem Status-Log (`source = ki`); Richtlinien-Ablehnungen
als Hinweis. Bericht als Text zum Kopieren und zwei CSV-Downloads.
Versendet wird nichts — der Nutzer entscheidet, was er an connektar
weitergibt. Service `SubmitterContactReport`, Wertobjekte
`SubmitterSummary`, `BrokenContact`, `RejectedRelease`.
**4. Richtlinien-Ablehnungen zählen nicht** — neue Art
`EmailBounceKind::Policy` (5.7.x, „Relay access denied", Transportregel,
Spamfilter der Gegenseite): protokolliert und im Bericht sichtbar, aber
ohne Wirkung auf den Schwellwert. Reihenfolge im Prozessor: Statuscode
vor Diagnosetext — 5.1.x bleibt hart, auch wenn im Text „spam" steht
(Freitext des eigenen Relays). Volles Postfach bleibt weich.
Tests: `MailDomainCheckTest` (11), `PlausibleEmailDomainTest` (5),
`SubmitterContactReportTest` (8), `BounceMailProcessingTest` (+1, 10);
zwei bestehende Tests von `assertNothingPushed` auf
`assertNotPushed(ClassifyPressRelease)` verengt, weil der Observer jetzt
einen Job anstößt.
Deploy: `migrate --force`, dann einmal `php artisan mail:check-domains
--months=6 --limit=1500` (≈ 2 Minuten, nur DNS-Lesen). Danach ist der
Bericht gefüllt.