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

@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands;
use App\Models\Company;
use App\Models\Contact;
use App\Models\EmailDomainCheck;
use App\Rules\PlausibleEmailDomain;
use App\Services\Mail\MailDomainChecker;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
/**
* Prüft die Mail-Domains des Bestands (Kontakterreichbarkeit, Abschnitt 7).
*
* Zwei Aufgaben: einmalig den Bestand aufnehmen (`--months=` grenzt auf
* Firmen mit jüngeren Meldungen ein) und danach täglich die abgelaufenen
* Befunde erneuern. Der Lauf bewertet nichts und sperrt nichts — er hält
* fest, was das DNS sagt.
*/
class CheckMailDomains extends Command
{
protected $signature = 'mail:check-domains
{--months= : Nur Firmen mit Meldungen aus den letzten N Monaten}
{--limit=500 : Höchstens so viele Domains je Lauf}
{--refresh : Auch gültige Befunde neu prüfen}
{--stale : Nur abgelaufene Befunde erneuern (Scheduler)}';
protected $description = 'Prüft die DNS-Zustellwege der hinterlegten E-Mail-Domains.';
public function handle(MailDomainChecker $checker): int
{
$limit = max(1, (int) $this->option('limit'));
$domains = $this->option('stale') ? $this->staleDomains($limit) : $this->domainsFromContacts($limit);
if ($domains->isEmpty()) {
$this->info('Nichts zu prüfen.');
return self::SUCCESS;
}
$counts = [];
$bar = $this->output->createProgressBar($domains->count());
foreach ($domains as $domain) {
$check = $checker->check($domain, force: (bool) $this->option('refresh') || (bool) $this->option('stale'));
$counts[$check->state->value] = ($counts[$check->state->value] ?? 0) + 1;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
foreach ($counts as $state => $count) {
$this->line(sprintf(' %-16s %d', $state, $count));
}
$broken = EmailDomainCheck::query()->broken()->count();
$this->info(sprintf('%d Domains geprüft, %d ohne Zustellweg im Bestand.', $domains->count(), $broken));
return self::SUCCESS;
}
/**
* @return Collection<int, string>
*/
private function staleDomains(int $limit): Collection
{
return EmailDomainCheck::query()
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '<=', now()))
->orderBy('expires_at')
->limit($limit)
->pluck('domain');
}
/**
* @return Collection<int, string>
*/
private function domainsFromContacts(int $limit): Collection
{
$months = $this->option('months') !== null ? max(1, (int) $this->option('months')) : null;
$companies = Company::query()
->withoutGlobalScopes()
->whereNotNull('email')
->where('email', '!=', '')
->when($months !== null, fn ($q) => $q->whereHas('pressReleases', fn ($r) => $r->withoutGlobalScopes()->where('press_releases.created_at', '>=', now()->subMonths($months))))
->pluck('email');
$contacts = Contact::query()
->withoutGlobalScopes()
->whereNotNull('email')
->where('email', '!=', '')
->when($months !== null, fn ($q) => $q->whereHas('company', fn ($c) => $c->withoutGlobalScopes()->whereHas('pressReleases', fn ($r) => $r->withoutGlobalScopes()->where('press_releases.created_at', '>=', now()->subMonths($months)))))
->pluck('email');
$known = EmailDomainCheck::query()
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->pluck('domain')
->flip();
$domains = [];
foreach ($companies->merge($contacts) as $email) {
$domain = PlausibleEmailDomain::domainOf($email);
if ($domain !== null && ! isset($known[$domain])) {
$domains[$domain] = true;
}
}
return collect(array_keys($domains))->take($limit)->values();
}
}

View file

@ -17,12 +17,22 @@ enum EmailBounceKind: string
/** Relay lehnt UNS ab (5.7.x, 554 Spam-Pattern, Verbindung) — kein Empfängerproblem, Circuit Breaker */
case Relay = 'relay';
/**
* Der Empfängerserver lehnt aus Richtlinie ab (5.7.x: „Relay access
* denied", Spamfilter, Transportregel). Das Postfach kann einwandfrei
* existieren — wird protokolliert und angezeigt, zählt aber auf keinen
* Schwellwert. Sonst sperrt ein dreimal zuschlagender Firmenfilter eine
* funktionierende Adresse.
*/
case Policy = 'policy';
public function label(): string
{
return match ($this) {
self::Hard => 'hart',
self::Soft => 'weich',
self::Relay => 'Relay',
self::Policy => 'Richtlinie',
};
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Enums;
/**
* Ergebnis der DNS-Prüfung einer E-Mail-Domain (Kontakterreichbarkeit,
* Abschnitt 3). Bewertet ausschließlich den Zustellweg der Domain — nie das
* einzelne Postfach und nie die Identität des Kontakts.
*/
enum EmailDomainState: string
{
/** MX vorhanden und mindestens ein Ziel auflösbar. */
case Deliverable = 'deliverable';
/** Kein MX, aber A/AAAA — impliziter MX nach RFC 5321 § 5.1, zulässig. */
case ImplicitMx = 'implicit_mx';
/** `MX 0 .` — die Domain erklärt ausdrücklich, keine Mail anzunehmen (RFC 7505). */
case NullMx = 'null_mx';
/** Domain existiert, hat aber weder MX noch A/AAAA. */
case NoMailPath = 'no_mail_path';
/** Die Domain gibt es nicht (NXDOMAIN). */
case DomainMissing = 'domain_missing';
/** MX vorhanden, aber kein Ziel auflösbar — fehlerhafte Mailkonfiguration. */
case BrokenMx = 'broken_mx';
/** Timeout/SERVFAIL — Ergebnis unbekannt, erneut prüfen. Kein Befund. */
case Unresolved = 'unresolved';
public function isDeliverable(): bool
{
return $this === self::Deliverable || $this === self::ImplicitMx;
}
/**
* Nachweislich kein Zustellweg. `Unresolved` gehört ausdrücklich nicht
* dazu — „nicht geprüft" ist kein Befund.
*/
public function isBroken(): bool
{
return match ($this) {
self::NullMx, self::NoMailPath, self::DomainMissing, self::BrokenMx => true,
default => false,
};
}
public function label(): string
{
return match ($this) {
self::Deliverable => 'Zustellweg vorhanden',
self::ImplicitMx => 'Zustellweg über A-Eintrag',
self::NullMx => 'Domain nimmt keine Mail an',
self::NoMailPath => 'Kein Zustellweg im DNS',
self::DomainMissing => 'Domain existiert nicht',
self::BrokenMx => 'Mailserver nicht auflösbar',
self::Unresolved => 'Prüfung offen',
};
}
public function badgeClass(): string
{
return match ($this) {
self::Deliverable, self::ImplicitMx => 'ok',
self::Unresolved => 'warn',
default => 'err',
};
}
}

View file

@ -7,6 +7,7 @@ use App\Enums\Portal;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Rules\PlausibleEmailDomain;
use App\Services\Company\ApiNewsroomAssignment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -35,7 +36,7 @@ class LegacyCompanyController extends Controller
'company.country_id' => ['nullable', 'integer'],
'company.phone' => ['nullable', 'string', 'max:100'],
'company.fax' => ['nullable', 'string', 'max:100'],
'company.email' => ['nullable', 'email', 'max:255'],
'company.email' => ['nullable', 'email', 'max:255', new PlausibleEmailDomain],
'company.website' => ['nullable', 'string', 'max:255'],
])['company'];
} catch (ValidationException $exception) {

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Rules\PlausibleEmailDomain;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -40,7 +41,7 @@ class LegacyContactController extends Controller
'contact.responsibility' => ['nullable', 'string', 'max:255'],
'contact.phone' => ['nullable', 'string', 'max:100'],
'contact.fax' => ['nullable', 'string', 'max:100'],
'contact.email' => ['nullable', 'email', 'max:255'],
'contact.email' => ['nullable', 'email', 'max:255', new PlausibleEmailDomain],
])['contact'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);

View file

@ -0,0 +1,43 @@
<?php
namespace App\Jobs;
use App\Services\Mail\MailDomainChecker;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
/**
* Prüft die Domain einer Kontaktadresse im Hintergrund
* (Kontakterreichbarkeit, Abschnitt 5.4).
*
* Wird nach dem Commit ausgelöst — die API wartet nie auf DNS, und eine
* DNS-Störung darf keine eingereichten Daten kosten. Das Ergebnis steuert
* nichts, es wird nur festgehalten und angezeigt.
*/
class CheckEmailDomain implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public function __construct(public string $domain) {}
/**
* @return list<object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping('maildomain:'.$this->domain))->dontRelease()];
}
public function handle(MailDomainChecker $checker): void
{
$checker->check($this->domain);
}
public function uniqueId(): string
{
return $this->domain;
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Models;
use App\Enums\EmailDomainState;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Gespeicherter DNS-Befund einer E-Mail-Domain (Kontakterreichbarkeit).
*
* @property string $domain
* @property EmailDomainState $state
* @property string|null $detail
* @property int $attempts
* @property Carbon|null $checked_at
* @property Carbon|null $expires_at
* @property Carbon|null $first_broken_at
*/
class EmailDomainCheck extends Model
{
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'state' => EmailDomainState::class,
'checked_at' => 'datetime',
'expires_at' => 'datetime',
'first_broken_at' => 'datetime',
];
}
public function isFresh(): bool
{
return $this->expires_at !== null && $this->expires_at->isFuture();
}
/**
* Nachweislich unzustellbare Domains — ohne „Prüfung offen".
*
* @param Builder<EmailDomainCheck> $query
*/
public function scopeBroken(Builder $query): void
{
$query->whereIn('state', [
EmailDomainState::NullMx->value,
EmailDomainState::NoMailPath->value,
EmailDomainState::DomainMissing->value,
EmailDomainState::BrokenMx->value,
]);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Observers;
use App\Jobs\CheckEmailDomain;
use App\Models\Company;
use App\Models\Contact;
use App\Models\EmailDomainCheck;
use App\Rules\PlausibleEmailDomain;
use Illuminate\Database\Eloquent\Model;
/**
* Stößt die Domainprüfung an, sobald eine Kontaktadresse entsteht oder sich
* ändert (Kontakterreichbarkeit, Abschnitt 5.4).
*
* Am Model statt in den Controllern: API v1, Legacy-API, Admin und
* Kundenportal schreiben dieselben Felder und bekommen damit dieselbe
* Prüfung — ohne dass eine Strecke es vergessen kann. Der Job läuft nach dem
* Commit; die Anfrage wartet nie auf DNS.
*
* Eine Korrektur der Adresse macht den alten Befund gegenstandslos: geprüft
* wird die neue Domain, und der Datensatz zeigt danach deren Ergebnis.
*/
class EmailDomainObserver
{
public function created(Model $model): void
{
$this->queue($model);
}
public function updated(Model $model): void
{
if ($model->wasChanged('email')) {
$this->queue($model);
}
}
private function queue(Company|Contact|Model $model): void
{
$domain = PlausibleEmailDomain::domainOf($model->getAttribute('email'));
if ($domain === null) {
return;
}
// Frischer Befund vorhanden? Dann kostet die Einreichung keine
// DNS-Abfrage — bei einem Einlieferer mit 68 Meldungen aus derselben
// Domain ist das der Regelfall.
$known = EmailDomainCheck::query()->where('domain', $domain)->first();
if ($known?->isFresh()) {
return;
}
CheckEmailDomain::dispatch($domain)->afterCommit();
}
}

View file

@ -30,8 +30,11 @@ use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver;
use App\Observers\EmailDomainObserver;
use App\Observers\PressReleaseSearchObserver;
use App\Services\Admin\AdminRequestPerformanceMetrics;
use App\Services\Mail\DnsResolver;
use App\Services\Mail\SystemDnsResolver;
use App\Services\Newsletter\NullNewsletterSyncClient;
use App\Services\PressRelease\PressReleaseService;
use App\Support\OperationalAlert;
@ -80,6 +83,7 @@ class AppServiceProvider extends ServiceProvider
return new NullNewsletterSyncClient;
});
$this->app->bind(DnsResolver::class, SystemDnsResolver::class);
$this->app->singleton(PressReleaseService::class);
$this->app->singleton(AdminRequestPerformanceMetrics::class);
}
@ -208,6 +212,11 @@ class AppServiceProvider extends ServiceProvider
// damit press_releases ohne teuren Rebuild erweitert werden kann.
PressRelease::observe(PressReleaseSearchObserver::class);
// Kontakterreichbarkeit: DNS-Befund der Mail-Domain nachziehen,
// sobald eine Adresse entsteht oder sich ändert.
Company::observe(EmailDomainObserver::class);
Contact::observe(EmailDomainObserver::class);
AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);

View file

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

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

View file

@ -35,6 +35,15 @@ return [
'window_days' => (int) env('BOUNCE_SOFT_WINDOW_DAYS', 7),
],
'domain_check' => [
// Wie lange ein Befund gilt, bis erneut gefragt wird. Ein positiver
// Befund altert langsam, ein negativer schnell — Domains werden
// repariert, und ein DNS-Befund darf nie zur Dauersperre werden.
'ttl_days' => (int) env('MAIL_DOMAIN_TTL_DAYS', 30),
'negative_ttl_days' => (int) env('MAIL_DOMAIN_NEGATIVE_TTL_DAYS', 3),
'retry_minutes' => (int) env('MAIL_DOMAIN_RETRY_MINUTES', 15),
],
'circuit_breaker' => [
'threshold' => (int) env('MAIL_CIRCUIT_THRESHOLD', 5),
'window_minutes' => (int) env('MAIL_CIRCUIT_WINDOW_MINUTES', 15),

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* DNS-Befund je E-Mail-Domain (Kontakterreichbarkeit, Abschnitt 7).
*
* Getrennt von `email_suppressions`: Die Sperrliste sagt, ob eine konkrete
* ADRESSE angeschrieben werden darf; diese Tabelle sagt, ob die DOMAIN
* überhaupt einen Zustellweg hat. Ein DNS-Befund wird nie zu einem
* synthetischen Hard Bounce und hebt umgekehrt nie eine Sperre auf.
*
* `expires_at` steuert die Wiederholung: positive Befunde halten lange,
* negative kurz, Resolver-Fehler nur Minuten. Ein Befund darf nicht
* unbegrenzt als dauerhafte Domainsperre stehen bleiben.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('email_domain_checks', function (Blueprint $table) {
$table->id();
$table->string('domain', 190)->unique();
$table->string('state', 24);
$table->string('detail', 255)->nullable();
$table->unsignedSmallInteger('attempts')->default(0);
$table->timestamp('checked_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('first_broken_at')->nullable();
$table->timestamps();
$table->index(['state', 'expires_at'], 'email_domain_checks_state_idx');
});
}
public function down(): void
{
Schema::dropIfExists('email_domain_checks');
}
};

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.

View file

@ -226,6 +226,10 @@
:current="request()->routeIs('admin.reports.slow-requests')" wire:navigate>
{{ __('Performance') }}
</flux:navlist.item>
<flux:navlist.item icon="inbox-stack" :href="route('admin.reports.submitter-contacts')"
:current="request()->routeIs('admin.reports.submitter-contacts')" wire:navigate>
{{ __('Einlieferer-Bericht') }}
</flux:navlist.item>
@can('settings:manage')
<flux:navlist.item icon="heart" :href="route('admin.reports.system-health')"
:current="request()->routeIs('admin.reports.system-health')" wire:navigate>

View file

@ -108,6 +108,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
'contactLookupResults' => collect(),
'recentPressReleases' => collect(),
'unreachableEmails' => [],
'domainCheck' => null,
];
}
@ -174,8 +175,14 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
...$filteredContacts->pluck('email')->all(),
])));
// Kontakterreichbarkeit: DNS-Befund der Firmen-Domain, nur aus dem
// Speicher — die Seite fragt kein DNS.
$domain = \App\Rules\PlausibleEmailDomain::domainOf($company->email);
$domainCheck = $domain === null ? null : \App\Models\EmailDomainCheck::query()->where('domain', $domain)->first();
return [
'company' => $company,
'domainCheck' => $domainCheck,
'unreachableEmails' => $unreachableEmails,
'filteredContacts' => $filteredContacts,
'filteredContactsTotal' => $filteredContactsTotal,
@ -223,6 +230,10 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
{{-- Bounce-Suppression (Roadmap 1b.6): gesperrte Adressen --}}
<a href="{{ route('admin.mail.suppressions', ['search' => $company->email]) }}" class="badge err dot" title="{{ implode(', ', $unreachableEmails) }}">{{ __('E-Mail nicht erreichbar') }}</a>
@endif
@if ($domainCheck?->state->isBroken())
{{-- Kontakterreichbarkeit: die Domain hat keinen Zustellweg --}}
<span class="badge err dot" title="{{ $domainCheck->detail }}">{{ $domainCheck->state->label() }}</span>
@endif
</div>
<div class="flex items-start gap-4">
@if ($logoUrl)

View file

@ -0,0 +1,310 @@
<?php
use App\Models\User;
use App\Services\Mail\SubmitterContactReport;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Admin ▸ Reports ▸ Einlieferer-Bericht (Kontakterreichbarkeit, Abschnitt 7).
*
* Bereitet auf, was ein API-Einlieferer an seinen Daten reparieren muss:
* nicht erreichbare Adressen (aus Rückläufern und Domainprüfung) und
* inhaltlich abgelehnte Meldungen. Der Bericht verschickt nichts — er liefert
* eine Liste zum Kopieren und als CSV, die ein Mensch selbst weitergibt.
*/
new #[Layout('components.layouts.app'), Title('Einlieferer-Bericht')] class extends Component
{
#[Url]
public ?int $submitter = null;
#[Url]
public int $months = 6;
public function mount(): void
{
$this->submitter ??= $this->submitters->first()?->id;
}
#[Computed]
public function submitters(): \Illuminate\Support\Collection
{
return app(SubmitterContactReport::class)->submitters(12);
}
#[Computed]
public function user(): ?User
{
return $this->submitter === null
? null
: User::query()->withoutGlobalScopes()->find($this->submitter);
}
/**
* Der Bericht als Text — genau das, was in eine Mail an den Einlieferer
* kopiert wird.
*/
#[Computed]
public function plainText(): string
{
$user = $this->user;
if ($user === null) {
return '';
}
$report = app(SubmitterContactReport::class);
$contacts = $report->brokenContacts($user, $this->months);
$rejected = $report->rejectedReleases($user, $this->months);
$lines = [
sprintf('Kontakt- und Prüfbericht %s', now()->format('d.m.Y')),
sprintf('Einlieferer: %s (%s)', $user->name, $user->email),
sprintf('Zeitraum: letzte %d Monate', $this->months),
'',
sprintf('1. Nicht erreichbare E-Mail-Adressen (%d)', $contacts->count()),
'',
];
foreach ($contacts as $row) {
$lines[] = sprintf('- %s — %s', $row->email, $row->company);
$lines[] = sprintf(' Grund: %s%s', $row->reason, $row->detail !== null ? ' ('.$row->detail.')' : '');
$lines[] = sprintf(' Betroffene Meldungen im Zeitraum: %d', $row->releases);
}
if ($contacts->isEmpty()) {
$lines[] = '- keine';
}
$lines[] = '';
$lines[] = sprintf('2. Automatisch abgelehnte Meldungen (%d)', $rejected->count());
$lines[] = '';
foreach ($rejected as $release) {
$lines[] = sprintf('- %s — %s', $release->title, $release->company);
$lines[] = sprintf(' %s: %s', $release->rejectedAt?->format('d.m.Y') ?? '', $release->reason);
}
if ($rejected->isEmpty()) {
$lines[] = '- keine';
}
return implode("\n", $lines);
}
public function downloadContacts(): StreamedResponse
{
$user = $this->user;
abort_if($user === null, 404);
$rows = app(SubmitterContactReport::class)->brokenContacts($user, $this->months);
$name = sprintf('kontakte-%s-%s.csv', \Illuminate\Support\Str::slug($user->name), now()->format('Y-m-d'));
return response()->streamDownload(function () use ($rows): void {
$handle = fopen('php://output', 'wb');
fputcsv($handle, ['E-Mail', 'Firma', 'Befund', 'Grund', 'Detail', 'Meldungen', 'Zuletzt'], ';');
foreach ($rows as $row) {
fputcsv($handle, [
$row->email,
$row->company,
$row->fromDomain ? 'Domainprüfung' : 'Rückläufer',
$row->reason,
$row->detail ?? '',
$row->releases,
$row->lastEvent?->format('d.m.Y') ?? '',
], ';');
}
fclose($handle);
}, $name, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function downloadRejected(): StreamedResponse
{
$user = $this->user;
abort_if($user === null, 404);
$rows = app(SubmitterContactReport::class)->rejectedReleases($user, $this->months);
$name = sprintf('ablehnungen-%s-%s.csv', \Illuminate\Support\Str::slug($user->name), now()->format('Y-m-d'));
return response()->streamDownload(function () use ($rows): void {
$handle = fopen('php://output', 'wb');
fputcsv($handle, ['ID', 'Titel', 'Firma', 'Datum', 'Begründung'], ';');
foreach ($rows as $row) {
fputcsv($handle, [$row->id, $row->title, $row->company, $row->rejectedAt?->format('d.m.Y') ?? '', $row->reason], ';');
}
fclose($handle);
}, $name, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function with(): array
{
$user = $this->user;
if ($user === null) {
return ['contacts' => collect(), 'rejected' => collect(), 'policy' => collect()];
}
$report = app(SubmitterContactReport::class);
return [
'contacts' => $report->brokenContacts($user, $this->months),
'rejected' => $report->rejectedReleases($user, $this->months),
'policy' => $report->policyRejections($user, $this->months),
];
}
}; ?>
<div class="space-y-8">
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Reports')"
:title="__('Einlieferer-Bericht')"
:description="__('Was ein Einlieferer an seinen Daten reparieren muss: Adressen ohne Zustellung — aus Rückläufern und aus der DNS-Prüfung der Domain — und Meldungen, die die automatische Prüfung abgelehnt hat. Zum Kopieren und als CSV; versendet wird nichts.')"
/>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Auswahl') }}</span>
</div>
<div class="p-5 flex flex-col gap-4 sm:flex-row sm:items-end">
<flux:field class="flex-1">
<flux:select wire:model.live="submitter" :label="__('Einlieferer')">
@foreach ($this->submitters as $option)
<option value="{{ $option->id }}">{{ $option->name }} · {{ $option->email }} ({{ $option->releases }})</option>
@endforeach
</flux:select>
</flux:field>
<flux:field class="sm:w-56">
<flux:select wire:model.live="months" :label="__('Zeitraum')">
<option value="1">{{ __('1 Monat') }}</option>
<option value="3">{{ __('3 Monate') }}</option>
<option value="6">{{ __('6 Monate') }}</option>
<option value="12">{{ __('12 Monate') }}</option>
</flux:select>
</flux:field>
</div>
</article>
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-portal.stat-card variant="primary" :label="__('Adressen ohne Zustellung')" :value="number_format($contacts->count())">
<x-slot:meta>{{ __(':count Meldungen betroffen', ['count' => $contacts->sum(fn ($row) => $row->releases)]) }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('Abgelehnte Meldungen')" :value="number_format($rejected->count())">
<x-slot:meta>{{ __('automatische Prüfung') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Richtlinien-Ablehnungen')" :value="number_format($policy->count())">
<x-slot:meta>{{ __('Empfänger filtert — keine Sperre') }}</x-slot:meta>
</x-portal.stat-card>
</section>
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Nicht erreichbare Adressen') }}</span>
@if ($contacts->isNotEmpty())
<flux:button size="sm" variant="filled" icon="arrow-down-tray" wire:click="downloadContacts">{{ __('CSV') }}</flux:button>
@endif
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Adresse') }}</flux:table.column>
<flux:table.column>{{ __('Firma') }}</flux:table.column>
<flux:table.column>{{ __('Befund') }}</flux:table.column>
<flux:table.column>{{ __('Meldungen') }}</flux:table.column>
<flux:table.column>{{ __('Zuletzt') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($contacts as $row)
<flux:table.row :key="$row->email">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)] break-all">{{ $row->email }}</span>
</flux:table.cell>
<flux:table.cell>
<a href="{{ route('admin.companies.show', $row->companyId) }}" class="text-[12.5px] text-[color:var(--color-brand)] hover:underline">{{ $row->company }}</a>
</flux:table.cell>
<flux:table.cell>
<span class="badge err dot">{{ $row->reason }}</span>
<span class="block text-[11px] text-[color:var(--color-ink-4)] mt-1">{{ $row->fromDomain ? __('DNS-Prüfung der Domain') : __('Rückläufer') }}@if ($row->detail) · {{ $row->detail }}@endif</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $row->releases }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $row->lastEvent?->format('d.m.Y') ?? '—' }}</span>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine unzustellbaren Adressen in diesem Zeitraum.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
</article>
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Abgelehnte Meldungen') }}</span>
@if ($rejected->isNotEmpty())
<flux:button size="sm" variant="filled" icon="arrow-down-tray" wire:click="downloadRejected">{{ __('CSV') }}</flux:button>
@endif
</div>
<ul class="divide-y divide-[color:var(--color-bg-rule)]">
@forelse ($rejected as $release)
<li class="p-5 space-y-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<a href="{{ route('admin.press-releases.show', $release->id) }}" class="text-[13px] font-semibold text-[color:var(--color-ink)] hover:underline">{{ $release->title }}</a>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $release->company }}</span>
<span class="text-[11px] text-[color:var(--color-ink-4)] tabular-nums">{{ $release->rejectedAt?->format('d.m.Y') }}</span>
</div>
<p class="text-[12.5px] leading-relaxed text-[color:var(--color-ink-2)]">{{ $release->reason }}</p>
</li>
@empty
<li class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine automatischen Ablehnungen in diesem Zeitraum.') }}
</li>
@endforelse
</ul>
</article>
@if ($policy->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Richtlinien-Ablehnungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Empfängerserver hat abgelehnt — die Adresse bleibt frei') }}</span>
</div>
<ul class="p-5 space-y-2 text-[12.5px]">
@foreach ($policy as $bounce)
<li class="flex flex-wrap items-baseline gap-x-3">
<span class="font-mono text-[11px] text-[color:var(--color-ink-4)]">{{ $bounce->received_at?->format('d.m.') }}</span>
<span class="font-semibold text-[color:var(--color-ink-2)] break-all">{{ $bounce->email }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $bounce->status }} · {{ Str::limit($bounce->diagnostic, 90) }}</span>
</li>
@endforeach
</ul>
</article>
@endif
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bericht zum Kopieren') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('für die Mail an den Einlieferer') }}</span>
</div>
<div class="p-5">
<textarea readonly rows="14"
class="w-full font-mono text-[11.5px] leading-relaxed p-4 rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-soft)] text-[color:var(--color-ink-2)]"
onclick="this.select()">{{ $this->plainText }}</textarea>
</div>
</article>
</div>

View file

@ -121,6 +121,10 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
Volt::route('admin/reports/activity', 'admin.reports.activity')->name('admin.reports.activity');
Volt::route('admin/reports/slow-requests', 'admin.reports.slow-requests')->name('admin.reports.slow-requests');
// Einlieferer-Bericht: unzustellbare Kontaktdaten und abgelehnte
// Meldungen je API-Einlieferer (Kontakterreichbarkeit).
Volt::route('admin/reports/einlieferer', 'admin.reports.submitter-contacts')->name('admin.reports.submitter-contacts');
Route::middleware('can:settings:manage')->group(function (): void {
Volt::route('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health');
});

View file

@ -1,6 +1,7 @@
<?php
use App\Console\Commands\AggregatePressReleaseViews;
use App\Console\Commands\CheckMailDomains;
use App\Console\Commands\FetchMailBounces;
use App\Console\Commands\GenerateManualInvoices;
use App\Console\Commands\PublishScheduledPressReleases;
@ -45,6 +46,15 @@ Schedule::command(FetchMailBounces::class)
->runInBackground()
->onFailure($alertOnFailure('mail:fetch-bounces'));
// Kontakterreichbarkeit: abgelaufene DNS-Befunde der Mail-Domains erneuern.
// Nur Auffrischung — neue Adressen prüft der Observer beim Anlegen. Ein
// Befund darf nicht unbegrenzt als Domainsperre stehen bleiben.
Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
->dailyAt('04:20')
->withoutOverlapping()
->runInBackground()
->onFailure($alertOnFailure('mail:check-domains'));
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
->dailyAt('03:00')

View file

@ -76,7 +76,7 @@ test('runKiCheck does nothing when no check is selected', function () {
->call('runKiCheck')
->assertHasNoErrors();
Queue::assertNothingPushed();
Queue::assertNotPushed(ClassifyPressRelease::class);
});
test('a non-routing classification job updates the verdict but leaves the status unchanged', function () {

View file

@ -110,25 +110,26 @@ test('the free-text notice of our own relay is read and never mistaken for an au
->and($suppressions->isSuppressed('info@example.org'))->toBeTrue();
});
test('policy rejections and full mailboxes count soft, not hard', function () {
test('policy rejections and full mailboxes never count hard', function () {
/** @var TestCase $this */
// 5.7.1 „Relay access denied": die Adresse existiert, ihr Server nimmt
// nur nichts an — erst der Schwellwert sperrt.
// nur nichts an — protokollieren, nicht zählen.
$result = app(BounceMailProcessor::class)->process(bounceFixture('policy-5.7.1'));
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Policy)
->and(EmailBounce::query()->first()->reference)->toBe('pm:99:company:7')
->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse();
$quota = str_replace(
['Status: 5.7.1', '554 5.7.1 <pr@example.net>: Relay access denied', '<20260915120000.ABC@'],
['Status: 5.0.0', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'],
['Status: 5.2.2', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'],
bounceFixture('policy-5.7.1'),
);
app(BounceMailProcessor::class)->process($quota);
expect(EmailBounce::query()->count())->toBe(2)
->and(EmailBounce::query()->where('kind', EmailBounceKind::Soft->value)->count())->toBe(1)
->and(EmailBounce::query()->where('kind', EmailBounceKind::Hard->value)->count())->toBe(0);
});
@ -138,3 +139,17 @@ test('the fetch command does nothing without a configured mailbox', function ()
$this->artisan('mail:fetch-bounces')->expectsOutputToContain('nicht konfiguriert')->assertSuccessful();
});
test('a policy rejection is logged but never counts toward suppression', function () {
/** @var TestCase $this */
// Vier Richtlinien-Ablehnungen: Der Schwellwert (3) darf sie nicht
// erfassen — das Postfach kann einwandfrei existieren.
$raw = bounceFixture('policy-5.7.1');
foreach (range(1, 4) as $i) {
app(BounceMailProcessor::class)->process(str_replace('<20260915120000.ABC@', '<2026091512000'.$i.'.ABC@', $raw));
}
expect(EmailBounce::query()->where('kind', EmailBounceKind::Policy->value)->count())->toBe(4)
->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse();
});

View file

@ -0,0 +1,151 @@
<?php
use App\Enums\EmailDomainState;
use App\Jobs\CheckEmailDomain;
use App\Models\Company;
use App\Models\EmailDomainCheck;
use App\Services\Mail\DnsResolver;
use App\Services\Mail\EmailSuppressionService;
use App\Services\Mail\MailDomainChecker;
use Illuminate\Support\Facades\Queue;
use Tests\Support\FakeDnsResolver;
use Tests\TestCase;
/**
* DNS-Prüfung der Mail-Domains (Kontakterreichbarkeit, Abschnitt 3 und 8).
* Der Resolver ist gefälscht — kein Test fragt echtes DNS.
*/
function fakeDns(array $zones): MailDomainChecker
{
$resolver = new FakeDnsResolver($zones);
app()->instance(DnsResolver::class, $resolver);
return app(MailDomainChecker::class);
}
test('a usable mx means deliverable, without claiming the mailbox exists', function () {
/** @var TestCase $this */
$check = fakeDns([
'firma.example' => ['MX' => [['target' => 'mx.firma.example', 'pri' => 10]]],
'mx.firma.example' => ['A' => [['ip' => '203.0.113.5']]],
])->check('firma.example');
expect($check->state)->toBe(EmailDomainState::Deliverable)
->and($check->state->isDeliverable())->toBeTrue()
->and($check->state->isBroken())->toBeFalse()
->and($check->expires_at)->not->toBeNull();
});
test('no mx but an a record is the implicit mx fallback of rfc 5321', function () {
/** @var TestCase $this */
$check = fakeDns(['firma.example' => ['A' => [['ip' => '203.0.113.5']]]])->check('firma.example');
expect($check->state)->toBe(EmailDomainState::ImplicitMx)
->and($check->state->isDeliverable())->toBeTrue();
});
test('a null mx says no mail, even with an a record present', function () {
/** @var TestCase $this */
// RFC 7505: `MX 0 .` — kein Zurückfallen auf die Webserver-IP.
$check = fakeDns([
'firma.example' => ['MX' => [['target' => '.', 'pri' => 0]], 'A' => [['ip' => '203.0.113.5']]],
])->check('firma.example');
expect($check->state)->toBe(EmailDomainState::NullMx)
->and($check->state->isBroken())->toBeTrue();
});
test('a domain without any records is missing, one with a zone but no mail path is not', function () {
/** @var TestCase $this */
$missing = fakeDns(['weg.example' => []])->check('weg.example');
$noPath = fakeDns(['nurweb.example' => ['NS' => [['target' => 'ns1.example']]]])->check('nurweb.example');
expect($missing->state)->toBe(EmailDomainState::DomainMissing)
->and($noPath->state)->toBe(EmailDomainState::NoMailPath);
});
test('one usable mx target is enough even when a sibling is broken', function () {
/** @var TestCase $this */
$check = fakeDns([
'firma.example' => ['MX' => [['target' => 'kaputt.example'], ['target' => 'mx2.example']]],
'kaputt.example' => [],
'mx2.example' => ['AAAA' => [['ipv6' => '2001:db8::1']]],
])->check('firma.example');
expect($check->state)->toBe(EmailDomainState::Deliverable);
});
test('mx targets that all fail to resolve are a broken configuration', function () {
/** @var TestCase $this */
$check = fakeDns([
'firma.example' => ['MX' => [['target' => 'kaputt.example']]],
'kaputt.example' => [],
])->check('firma.example');
expect($check->state)->toBe(EmailDomainState::BrokenMx)
->and($check->state->isBroken())->toBeTrue();
});
test('a resolver failure is no finding and is retried with growing distance', function () {
/** @var TestCase $this */
// Timeout/SERVFAIL darf nie als „Domain kaputt" gelten.
$checker = fakeDns(['firma.example' => ['MX' => null]]);
$first = $checker->check('firma.example');
$firstExpiry = $first->expires_at;
expect($first->state)->toBe(EmailDomainState::Unresolved)
->and($first->state->isBroken())->toBeFalse()
->and($first->attempts)->toBe(1);
$second = $checker->check('firma.example', force: true);
expect($second->attempts)->toBe(2)
->and($second->expires_at->greaterThan($firstExpiry))->toBeTrue();
});
test('a temporary mx target failure does not become a broken configuration', function () {
/** @var TestCase $this */
$check = fakeDns([
'firma.example' => ['MX' => [['target' => 'mx.example']]],
'mx.example' => ['A' => null, 'AAAA' => null],
])->check('firma.example');
expect($check->state)->toBe(EmailDomainState::Unresolved);
});
test('a fresh result is reused and a correction re-checks the new domain', function () {
/** @var TestCase $this */
$resolver = new FakeDnsResolver(['firma.example' => []]);
app()->instance(DnsResolver::class, $resolver);
$checker = app(MailDomainChecker::class);
expect($checker->check('firma.example')->state)->toBe(EmailDomainState::DomainMissing);
// Domain repariert — der gespeicherte Befund gilt aber noch.
$resolver->set('firma.example', 'A', [['ip' => '203.0.113.9']]);
expect($checker->check('firma.example')->state)->toBe(EmailDomainState::DomainMissing)
->and($checker->check('firma.example', force: true)->state)->toBe(EmailDomainState::ImplicitMx)
->and(EmailDomainCheck::query()->where('domain', 'firma.example')->first()->first_broken_at)->toBeNull();
});
test('saving a company queues a check for its mail domain, unchanged addresses do not', function () {
/** @var TestCase $this */
Queue::fake();
$company = Company::factory()->create(['email' => 'presse@firma.example']);
Queue::assertPushed(CheckEmailDomain::class, fn ($job) => $job->domain === 'firma.example');
$company->update(['name' => 'Neuer Name']);
Queue::assertPushed(CheckEmailDomain::class, 1);
$company->update(['email' => 'presse@andere.example']);
Queue::assertPushed(CheckEmailDomain::class, fn ($job) => $job->domain === 'andere.example');
});
test('the domain finding never suppresses an address', function () {
/** @var TestCase $this */
fakeDns(['firma.example' => []])->check('firma.example');
expect(app(EmailSuppressionService::class)->isSuppressed('presse@firma.example'))->toBeFalse();
});

View file

@ -0,0 +1,69 @@
<?php
use App\Enums\Portal;
use App\Models\User;
use App\Rules\PlausibleEmailDomain;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* Formprüfung der Domain beim Einreichen (Kontakterreichbarkeit, 5.1) —
* deterministisch, ohne Netz.
*/
function passesPlausibility(string $email): bool
{
return Validator::make(['email' => $email], ['email' => ['nullable', 'email', new PlausibleEmailDomain]])->passes();
}
test('addresses without a real domain ending are rejected', function () {
/** @var TestCase $this */
// Alle drei stammen aus dem Bestand vom 15.09.2026 und bestehen
// Laravels `email`-Regel anstandslos.
expect(passesPlausibility('info@hoerwelt-basel'))->toBeFalse()
->and(passesPlausibility('kontakt@hoerex'))->toBeFalse()
->and(passesPlausibility('cks@kaeser-stroebelde'))->toBeFalse()
->and(passesPlausibility('info@urbanbnb.ded'))->toBeFalse();
});
test('ordinary addresses pass, international ones included', function () {
/** @var TestCase $this */
expect(passesPlausibility('presse@firma.de'))->toBeTrue()
->and(passesPlausibility('info@sub.firma.co.uk'))->toBeTrue()
->and(passesPlausibility('info@münchen-presse.de'))->toBeTrue()
->and(passesPlausibility('info@xn--mnchen-presse-mfb.de'))->toBeTrue();
});
test('malformed domains are rejected without asking dns', function () {
/** @var TestCase $this */
expect(passesPlausibility('info@-firma.de'))->toBeFalse()
->and(passesPlausibility('info@firma-.de'))->toBeFalse()
->and(passesPlausibility('info@firma..de'))->toBeFalse()
->and(passesPlausibility('info@firma.12'))->toBeFalse();
});
test('the domain of an address is returned in dns form', function () {
/** @var TestCase $this */
expect(PlausibleEmailDomain::domainOf('Presse@Firma.DE'))->toBe('firma.de')
->and(PlausibleEmailDomain::domainOf('info@münchen.de'))->toBe('xn--mnchen-3ya.de')
->and(PlausibleEmailDomain::domainOf('ohne-at'))->toBeNull()
->and(PlausibleEmailDomain::domainOf(null))->toBeNull();
});
test('the legacy api refuses a company address whose domain cannot exist', function () {
/** @var TestCase $this */
Queue::fake();
test()->seed(RolesAndPermissionsSeeder::class);
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Hörwelt Basel', 'email' => 'info@hoerwelt-basel'],
])->assertStatus(422);
$this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Hörwelt Basel', 'email' => 'info@hoerwelt-basel.ch'],
])->assertCreated();
});

View file

@ -35,7 +35,7 @@ test('reclassifyIfClassified does nothing for a never-classified press release',
app(PressReleaseService::class)->reclassifyIfClassified($pressRelease);
Queue::assertNothingPushed();
Queue::assertNotPushed(ClassifyPressRelease::class);
});
test('reprocessAfterContentChange reclassifies and rescores on the fresh model state', function () {
@ -63,7 +63,7 @@ test('reprocessAfterContentChange does nothing for an unclassified, unscored pre
app(PressReleaseService::class)->reprocessAfterContentChange($pressRelease);
Queue::assertNothingPushed();
Queue::assertNotPushed(ClassifyPressRelease::class);
});
test('api update of a classified press release re-classifies when the content changes', function () {
@ -119,5 +119,5 @@ test('api update does not re-classify when content is unchanged', function () {
'keywords' => 'neu',
])->assertOk();
Queue::assertNothingPushed();
Queue::assertNotPushed(ClassifyPressRelease::class);
});

View file

@ -0,0 +1,178 @@
<?php
use App\Enums\EmailBounceKind;
use App\Enums\EmailDomainState;
use App\Enums\EmailSuppressionReason;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\Contact;
use App\Models\EmailDomainCheck;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\Mail\EmailSuppressionService;
use App\Services\Mail\SubmitterContactReport;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
/**
* Einlieferer-Bericht (Kontakterreichbarkeit, Abschnitt 7): was ein
* API-Einlieferer an seinen Daten reparieren muss.
*/
function submitterWithRelease(array $companyAttributes = [], ?string $rejectionReason = null): array
{
Queue::fake();
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
$company = Company::factory()->create(array_merge(['portal' => Portal::Businessportal24], $companyAttributes));
$release = PressRelease::factory()
->for($company)
->create([
'user_id' => $user->id,
'portal' => Portal::Businessportal24,
'status' => $rejectionReason !== null ? PressReleaseStatus::Rejected : PressReleaseStatus::Published,
]);
PressReleaseStatusLog::query()->create([
'press_release_id' => $release->id,
'from_status' => 'draft',
'to_status' => 'published',
'source' => 'api',
]);
if ($rejectionReason !== null) {
PressReleaseStatusLog::query()->create([
'press_release_id' => $release->id,
'from_status' => 'review',
'to_status' => 'rejected',
'source' => 'ki',
'reason' => $rejectionReason,
]);
}
return [$user, $company, $release];
}
test('the report lists api submitters with their volume', function () {
/** @var TestCase $this */
[$user] = submitterWithRelease();
$submitters = app(SubmitterContactReport::class)->submitters();
expect($submitters)->toHaveCount(1)
->and($submitters->first()->id)->toBe($user->id)
->and($submitters->first()->releases)->toBe(1);
});
test('a suppressed company address shows up as a broken contact', function () {
/** @var TestCase $this */
[$user, $company] = submitterWithRelease(['email' => 'presse@tot.example']);
app(EmailSuppressionService::class)->recordBounce('presse@tot.example', EmailBounceKind::Hard, 'imap', 'm1');
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
expect($rows)->toHaveCount(1)
->and($rows->first()->email)->toBe('presse@tot.example')
->and($rows->first()->fromDomain)->toBeFalse()
->and($rows->first()->companyId)->toBe($company->id)
->and($rows->first()->releases)->toBe(1);
});
test('a domain without a mail path shows up even without any bounce', function () {
/** @var TestCase $this */
// Der Punkt der Vorabprüfung: sichtbar, bevor je eine Mail geschrieben wurde.
[$user] = submitterWithRelease(['email' => 'info@existiert-nicht.example']);
EmailDomainCheck::query()->create([
'domain' => 'existiert-nicht.example',
'state' => EmailDomainState::DomainMissing,
'detail' => 'Keine DNS-Einträge (NXDOMAIN)',
'checked_at' => now(),
'expires_at' => now()->addDay(),
]);
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
expect($rows)->toHaveCount(1)
->and($rows->first()->fromDomain)->toBeTrue()
->and($rows->first()->reason)->toBe('Domain existiert nicht')
->and(app(EmailSuppressionService::class)->isSuppressed('info@existiert-nicht.example'))->toBeFalse();
});
test('an unresolved domain is no finding and stays out of the report', function () {
/** @var TestCase $this */
[$user] = submitterWithRelease(['email' => 'info@offen.example']);
EmailDomainCheck::query()->create([
'domain' => 'offen.example',
'state' => EmailDomainState::Unresolved,
'checked_at' => now(),
'expires_at' => now()->addMinutes(15),
]);
expect(app(SubmitterContactReport::class)->brokenContacts($user))->toBeEmpty();
});
test('press contacts of the submitter are covered, working addresses are not listed', function () {
/** @var TestCase $this */
[$user, $company] = submitterWithRelease(['email' => 'geht@example.com']);
Contact::factory()->for($company)->create(['email' => 'kaputt@example.com']);
app(EmailSuppressionService::class)->suppress('kaputt@example.com', EmailSuppressionReason::HardBounce, 'import');
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
expect($rows)->toHaveCount(1)
->and($rows->first()->email)->toBe('kaputt@example.com');
});
test('automatic rejections are listed with their reason', function () {
/** @var TestCase $this */
[$user] = submitterWithRelease([], 'K1: Unzulässige Gesundheitswerbung');
$rejected = app(SubmitterContactReport::class)->rejectedReleases($user);
expect($rejected)->toHaveCount(1)
->and($rejected->first()->reason)->toBe('K1: Unzulässige Gesundheitswerbung');
});
test('policy rejections are reported but never suppress the address', function () {
/** @var TestCase $this */
[$user] = submitterWithRelease(['email' => 'presse@gefiltert.example']);
foreach (range(1, 4) as $i) {
app(EmailSuppressionService::class)->recordBounce(
'presse@gefiltert.example',
EmailBounceKind::Policy,
'imap',
'policy-'.$i,
['status' => '5.7.1', 'diagnostic' => '554 5.7.1 Relay access denied'],
);
}
$report = app(SubmitterContactReport::class);
expect($report->policyRejections($user))->toHaveCount(4)
->and(app(EmailSuppressionService::class)->isSuppressed('presse@gefiltert.example'))->toBeFalse()
->and($report->brokenContacts($user))->toBeEmpty();
});
test('the admin page renders the report for the submitter', function () {
/** @var TestCase $this */
test()->seed(RolesAndPermissionsSeeder::class);
[$submitter] = submitterWithRelease(['email' => 'presse@tot.example', 'name' => 'Tote Firma AG'], 'K4: Falsche Tatsachenbehauptung');
app(EmailSuppressionService::class)->recordBounce('presse@tot.example', EmailBounceKind::Hard, 'imap', 'm2');
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.reports.submitter-contacts', ['submitter' => $submitter->id]))
->assertSuccessful()
->assertSeeText('presse@tot.example')
->assertSeeText('Tote Firma AG')
->assertSeeText('K4: Falsche Tatsachenbehauptung');
});

View file

@ -0,0 +1,36 @@
<?php
namespace Tests\Support;
use App\Services\Mail\DnsResolver;
/**
* DNS aus einer Tabelle statt aus dem Netz (Abnahmekriterium: keine
* öffentlichen Resolver im Testlauf).
*
* `null` als Wert bedeutet Resolver-Fehler (Timeout/SERVFAIL), ein fehlender
* Eintrag bedeutet „Domain antwortet, hat diesen Record aber nicht".
*/
class FakeDnsResolver implements DnsResolver
{
/**
* @param array<string, array<string, list<array<string, mixed>>|null>> $zones
*/
public function __construct(private array $zones = []) {}
public function records(string $domain, string $type): ?array
{
$zone = $this->zones[mb_strtolower($domain)] ?? [];
if (! array_key_exists($type, $zone)) {
return [];
}
return $zone[$type];
}
public function set(string $domain, string $type, ?array $records): void
{
$this->zones[mb_strtolower($domain)][$type] = $records;
}
}