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 4cefdf7f5c
commit f581e1eb3a
34 changed files with 1985 additions and 25 deletions

View file

@ -65,8 +65,8 @@ class BounceMailProcessor
continue;
}
if ($kind === EmailBounceKind::Hard && $this->isTransient($recipient['status'], $recipient['diagnostic'])) {
$kind = EmailBounceKind::Soft;
if ($kind === EmailBounceKind::Hard) {
$kind = $this->reclassify($recipient['status'], $recipient['diagnostic']);
}
$bounces++;
@ -94,21 +94,45 @@ class BounceMailProcessor
}
/**
* `failed` heißt nicht immer „Adresse existiert nicht". Weich zählen
* (Sperre erst nach dem Schwellwert): 4.x.x, das der MTA aufgegeben hat;
* volles Postfach (5.2.2, 5.2.5, SMTP 552); Policy-Ablehnungen 5.7.x
* („Relay access denied", Spamfilter der Gegenseite) — die Adresse gibt
* es, ihr Server nimmt gerade nur nichts an. Bestand vom 15.09.2026:
* 16 von 147 DSNs fielen in diese Gruppe.
* `failed` heißt nicht immer „Adresse existiert nicht". Drei Gruppen:
*
* - **Richtlinie** (5.7.x, „Relay access denied", Spamfilter,
* Transportregel): Der Empfängerserver lehnt uns ab, das Postfach kann
* existieren. Nur protokollieren, nie zählen.
* - **Vorübergehend** (4.x.x, das der MTA aufgegeben hat; volles Postfach
* 5.2.2/5.2.5 bzw. SMTP 552): weich, Sperre erst ab Schwellwert.
* - Alles andere bleibt hart.
*
* Bestand vom 15.09.2026: 8 Richtlinien- und 8 vorübergehende Fälle unter
* 147 DSNs.
*/
private function isTransient(?string $status, ?string $diagnostic): bool
private function reclassify(?string $status, ?string $diagnostic): EmailBounceKind
{
$status = (string) $status;
$diagnostic = (string) $diagnostic;
return str_starts_with($status, '4.')
|| str_starts_with($status, '5.7.')
// Der Statuscode entscheidet, wo er eindeutig ist: 5.1.x heißt
// „Empfänger unbekannt" — auch wenn im Diagnosetext „spam" steht,
// wie beim Freitext-Hinweis des eigenen Relays.
if (str_starts_with($status, '5.1.')) {
return EmailBounceKind::Hard;
}
if (str_starts_with($status, '5.7.')) {
return EmailBounceKind::Policy;
}
if (str_starts_with($status, '4.')
|| in_array($status, ['5.2.2', '5.2.5'], true)
|| preg_match('/^\s*(4\d\d|552)\b/', (string) $diagnostic) === 1;
|| preg_match('/^\s*(4\d\d|552)\b/', $diagnostic) === 1) {
return EmailBounceKind::Soft;
}
if (preg_match('/relay access denied|blocked using|rejected by organization|transport\.rules|reject filter|spam(filter|haus| pattern)/i', $diagnostic) === 1) {
return EmailBounceKind::Policy;
}
return EmailBounceKind::Hard;
}
private function subject(string $raw): string

View file

@ -0,0 +1,27 @@
<?php
namespace App\Services\Mail;
use Illuminate\Support\Carbon;
/**
* Eine Adresse des Einlieferers, die nicht funktioniert.
*
* `fromDomain` unterscheidet die beiden Quellen: Ein Domainbefund erklärt,
* WARUM nichts ankommt (die Domain hat keinen Zustellweg), ein Rückläufer
* stellt nur fest, DASS nichts ankam. Beides bleibt getrennt, weil ein
* DNS-Ergebnis nie eine Adresssperre begründet.
*/
final readonly class BrokenContact
{
public function __construct(
public string $email,
public string $company,
public int $companyId,
public bool $fromDomain,
public string $reason,
public ?string $detail,
public int $releases,
public ?Carbon $lastEvent,
) {}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\Mail;
/**
* Schmale DNS-Schnittstelle, damit die Domainprüfung deterministisch testbar
* bleibt (Abnahmekriterium: keine öffentlichen Resolver im Testlauf).
*/
interface DnsResolver
{
/**
* Records eines Typs.
*
* @param 'MX'|'A'|'AAAA'|'NS'|'SOA' $type
* @return list<array<string, mixed>>|null null = Resolver-Fehler
* (Timeout/SERVFAIL), [] = Domain
* antwortet, hat aber keinen
* solchen Record
*/
public function records(string $domain, string $type): ?array;
}

View file

@ -95,9 +95,10 @@ class EmailSuppressionService
return null;
}
if ($kind === EmailBounceKind::Relay) {
// Kein Empfängerproblem — nur protokolliert, der Circuit Breaker
// zählt es separat.
if ($kind === EmailBounceKind::Relay || $kind === EmailBounceKind::Policy) {
// Kein Empfängerproblem — nur protokolliert. Relay zählt der
// Circuit Breaker separat, Richtlinien-Ablehnungen tauchen im
// Einlieferer-Bericht auf und sind Sache eines Menschen.
return $bounce;
}

View file

@ -0,0 +1,179 @@
<?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));
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\Mail;
use Illuminate\Support\Carbon;
/**
* Eine von der automatischen Prüfung abgelehnte Meldung samt Begründung.
*/
final readonly class RejectedRelease
{
public function __construct(
public int $id,
public string $title,
public string $company,
public string $reason,
public ?Carbon $rejectedAt,
) {}
}

View file

@ -0,0 +1,254 @@
<?php
namespace App\Services\Mail;
use App\Enums\EmailBounceKind;
use App\Models\Company;
use App\Models\Contact;
use App\Models\EmailBounce;
use App\Models\EmailDomainCheck;
use App\Models\EmailSuppression;
use App\Models\PressRelease;
use App\Models\User;
use App\Rules\PlausibleEmailDomain;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Stellt zusammen, was ein API-Einlieferer an seinen Daten reparieren muss
* (Kontakterreichbarkeit, Abschnitt 7 „API und Admin").
*
* Drei Quellen, bewusst getrennt gehalten und erst in der Anzeige vereint:
*
* - **Rückläufer:** Die Adresse hat nachweislich nicht angenommen.
* - **Domainprüfung:** Die Domain hat gar keinen Zustellweg — auffindbar,
* bevor je eine Mail geschrieben wurde.
* - **Ablehnungen:** Inhaltlich abgelehnte Meldungen mit Begründung. Das ist
* kein Kontaktproblem, gehört aber in denselben Bericht, weil derselbe
* Mensch beim Einlieferer beides abstellt.
*
* Der Bericht bewertet nichts und verschickt nichts — er bereitet auf, was
* ein Mensch dann selbst weitergibt.
*/
class SubmitterContactReport
{
/**
* Einlieferer mit Meldungen über die API, jüngste zuerst.
*
* @return Collection<int, SubmitterSummary>
*/
public function submitters(int $months = 6): Collection
{
return User::query()
->withoutGlobalScopes()
->select(['users.id', 'users.name', 'users.email'])
->selectSub(
PressRelease::query()->withoutGlobalScopes()
->whereColumn('press_releases.user_id', 'users.id')
->where('press_releases.created_at', '>=', now()->subMonths($months))
->selectRaw('count(*)'),
'releases'
)
->whereExists(fn ($q) => $q->selectRaw('1')
->from('press_releases as p')
->join('press_release_status_logs as l', 'l.press_release_id', '=', 'p.id')
->whereColumn('p.user_id', 'users.id')
->where('l.source', 'api')
->where('p.created_at', '>=', now()->subMonths($months))
)
->orderByDesc('releases')
->get()
->map(fn (User $user): SubmitterSummary => new SubmitterSummary(
id: (int) $user->id,
name: (string) $user->name,
email: (string) $user->email,
releases: (int) $user->getAttribute('releases'),
));
}
/**
* Adressen dieses Einlieferers, die nicht funktionieren.
*
* @return Collection<int, BrokenContact>
*/
public function brokenContacts(User $submitter, int $months = 6): Collection
{
$since = now()->subMonths($months);
$companies = $this->companiesOf($submitter, $since);
if ($companies->isEmpty()) {
return collect();
}
$addresses = $this->addressesOf($companies);
if ($addresses->isEmpty()) {
return collect();
}
$emails = $addresses->keys()->all();
$domains = $addresses->keys()
->map(fn (string $email): ?string => PlausibleEmailDomain::domainOf($email))
->filter()
->unique()
->values();
$suppressions = EmailSuppression::query()->active()->whereIn('email', $emails)->get()->keyBy('email');
$domainChecks = EmailDomainCheck::query()->broken()->whereIn('domain', $domains)->get()->keyBy('domain');
$rows = collect();
foreach ($addresses as $email => $context) {
$domain = PlausibleEmailDomain::domainOf($email);
$suppression = $suppressions->get($email);
$domainCheck = $domain !== null ? $domainChecks->get($domain) : null;
if ($suppression === null && $domainCheck === null) {
continue;
}
// Der Domainbefund ist die schärfere Aussage: Er erklärt, warum
// die Adresse nicht geht, statt nur festzustellen, dass sie es
// nicht tut.
$rows->push(new BrokenContact(
email: (string) $email,
company: $context['company'],
companyId: $context['company_id'],
fromDomain: $domainCheck !== null,
reason: $domainCheck?->state->label() ?? $suppression->reason->label(),
detail: $domainCheck->detail ?? $suppression->note ?? null,
releases: $context['releases'],
lastEvent: $suppression->last_bounced_at ?? $suppression->suppressed_at ?? $domainCheck?->checked_at,
));
}
return $rows->sortByDesc(fn (BrokenContact $row): int => $row->releases)->values();
}
/**
* Richtlinien-Ablehnungen: protokolliert, aber nie gesperrt. Für den
* Einlieferer trotzdem interessant — sein Empfänger filtert uns weg.
*
* @return Collection<int, EmailBounce>
*/
public function policyRejections(User $submitter, int $months = 6): Collection
{
$since = now()->subMonths($months);
$companies = $this->companiesOf($submitter, $since);
$emails = $this->addressesOf($companies)->keys()->all();
if ($emails === []) {
return collect();
}
return EmailBounce::query()
->where('kind', EmailBounceKind::Policy->value)
->whereIn('email', $emails)
->where('received_at', '>=', $since)
->orderByDesc('received_at')
->limit(100)
->get();
}
/**
* Automatisch abgelehnte Meldungen mit Begründung.
*
* @return Collection<int, RejectedRelease>
*/
public function rejectedReleases(User $submitter, int $months = 6): Collection
{
return collect(DB::table('press_release_status_logs as l')
->join('press_releases as p', 'p.id', '=', 'l.press_release_id')
->leftJoin('companies as c', 'c.id', '=', 'p.company_id')
->where('p.user_id', $submitter->getKey())
->where('l.source', 'ki')
->where('l.to_status', 'rejected')
->where('l.created_at', '>=', now()->subMonths($months))
->orderByDesc('l.created_at')
->limit(200)
->get(['p.id', 'p.title', 'c.name as company', 'l.reason', 'l.created_at']))
->map(fn (object $row): RejectedRelease => new RejectedRelease(
id: (int) $row->id,
title: (string) $row->title,
company: (string) ($row->company ?? '—'),
reason: trim((string) $row->reason),
rejectedAt: $row->created_at ? Carbon::parse($row->created_at) : null,
));
}
/**
* Firmen, für die dieser Einlieferer im Zeitraum Meldungen gesendet hat,
* mit deren Anzahl.
*
* @return Collection<int, SubmitterSummary> `releases` = Meldungen dieses
* Einlieferers für die Firma
*/
private function companiesOf(User $submitter, Carbon $since): Collection
{
return Company::query()
->withoutGlobalScopes()
->select(['companies.id', 'companies.name', 'companies.email'])
->selectSub(
PressRelease::query()->withoutGlobalScopes()
->whereColumn('press_releases.company_id', 'companies.id')
->where('press_releases.user_id', $submitter->getKey())
->where('press_releases.created_at', '>=', $since)
->selectRaw('count(*)'),
'releases'
)
->whereExists(fn ($q) => $q->selectRaw('1')
->from('press_releases as p')
->whereColumn('p.company_id', 'companies.id')
->where('p.user_id', $submitter->getKey())
->where('p.created_at', '>=', $since)
)
->get()
->map(fn (Company $company): SubmitterSummary => new SubmitterSummary(
id: (int) $company->id,
name: (string) $company->name,
email: (string) $company->email,
releases: (int) $company->getAttribute('releases'),
));
}
/**
* Alle Adressen dieser Firmen (Firmen-Mail und Pressekontakte), je
* Adresse die Firma und die Zahl betroffener Meldungen.
*
* @param Collection<int, SubmitterSummary> $companies
* @return Collection<string, array{company: string, company_id: int, releases: int}>
*/
private function addressesOf(Collection $companies): Collection
{
$addresses = collect();
foreach ($companies as $company) {
$email = EmailSuppressionService::normalise($company->email);
if ($email !== null) {
$addresses[$email] = ['company' => $company->name, 'company_id' => $company->id, 'releases' => $company->releases];
}
}
$contacts = Contact::query()
->withoutGlobalScopes()
->whereIn('company_id', $companies->pluck('id')->all())
->whereNotNull('email')
->where('email', '!=', '')
->get(['company_id', 'email']);
$byCompany = $companies->keyBy('id');
foreach ($contacts as $contact) {
$email = EmailSuppressionService::normalise($contact->email);
$company = $byCompany->get($contact->company_id);
if ($email !== null && $company !== null && ! $addresses->has($email)) {
$addresses[$email] = ['company' => $company->name, 'company_id' => $company->id, 'releases' => $company->releases];
}
}
return $addresses;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Services\Mail;
/**
* Ein API-Einlieferer mit seinem Volumen im Zeitraum.
*/
final readonly class SubmitterSummary
{
public function __construct(
public int $id,
public string $name,
public string $email,
public int $releases,
) {}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Services\Mail;
/**
* DNS über den Resolver des Systems (`dns_get_record`).
*
* `dns_get_record` unterscheidet nicht zwischen „Domain existiert nicht" und
* „Domain hat diesen Record nicht" — beides ist ein leeres Array. Ein
* Resolver-Fehler ist `false`. Die Unterscheidung NXDOMAIN/kein Record trifft
* der MailDomainChecker über eine zweite Abfrage (NS/SOA).
*/
class SystemDnsResolver implements DnsResolver
{
private const TYPES = [
'MX' => DNS_MX,
'A' => DNS_A,
'AAAA' => DNS_AAAA,
'NS' => DNS_NS,
'SOA' => DNS_SOA,
];
public function records(string $domain, string $type): ?array
{
$records = @dns_get_record($domain, self::TYPES[$type]);
return $records === false ? null : array_values($records);
}
}