From f581e1eb3ad47f6669f20369bfdd4c96adc8f253 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 16 Sep 2026 09:22:39 +0000 Subject: [PATCH] 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) --- app/Console/Commands/CheckMailDomains.php | 114 +++++++ app/Enums/EmailBounceKind.php | 10 + app/Enums/EmailDomainState.php | 71 ++++ .../Api/Legacy/LegacyCompanyController.php | 3 +- .../Api/Legacy/LegacyContactController.php | 3 +- app/Jobs/CheckEmailDomain.php | 43 +++ app/Models/EmailDomainCheck.php | 57 ++++ app/Observers/EmailDomainObserver.php | 57 ++++ app/Providers/AppServiceProvider.php | 9 + app/Rules/PlausibleEmailDomain.php | 114 +++++++ app/Services/Mail/BounceMailProcessor.php | 48 ++- app/Services/Mail/BrokenContact.php | 27 ++ app/Services/Mail/DnsResolver.php | 21 ++ app/Services/Mail/EmailSuppressionService.php | 7 +- app/Services/Mail/MailDomainChecker.php | 179 ++++++++++ app/Services/Mail/RejectedRelease.php | 19 ++ app/Services/Mail/SubmitterContactReport.php | 254 ++++++++++++++ app/Services/Mail/SubmitterSummary.php | 16 + app/Services/Mail/SystemDnsResolver.php | 29 ++ config/bounces.php | 9 + ...00000_create_email_domain_checks_table.php | 42 +++ dev/2026-09-01/umsetzung-protokoll.md | 74 +++++ .../components/layouts/app/sidebar.blade.php | 4 + .../livewire/admin/companies/show.blade.php | 11 + .../reports/submitter-contacts.blade.php | 310 ++++++++++++++++++ routes/admin.php | 4 + routes/console.php | 10 + tests/Feature/Admin/AdminKiCheckTest.php | 2 +- tests/Feature/BounceMailProcessingTest.php | 23 +- tests/Feature/MailDomainCheckTest.php | 151 +++++++++ tests/Feature/PlausibleEmailDomainTest.php | 69 ++++ tests/Feature/PressReleaseReclassifyTest.php | 6 +- tests/Feature/SubmitterContactReportTest.php | 178 ++++++++++ tests/Support/FakeDnsResolver.php | 36 ++ 34 files changed, 1985 insertions(+), 25 deletions(-) create mode 100644 app/Console/Commands/CheckMailDomains.php create mode 100644 app/Enums/EmailDomainState.php create mode 100644 app/Jobs/CheckEmailDomain.php create mode 100644 app/Models/EmailDomainCheck.php create mode 100644 app/Observers/EmailDomainObserver.php create mode 100644 app/Rules/PlausibleEmailDomain.php create mode 100644 app/Services/Mail/BrokenContact.php create mode 100644 app/Services/Mail/DnsResolver.php create mode 100644 app/Services/Mail/MailDomainChecker.php create mode 100644 app/Services/Mail/RejectedRelease.php create mode 100644 app/Services/Mail/SubmitterContactReport.php create mode 100644 app/Services/Mail/SubmitterSummary.php create mode 100644 app/Services/Mail/SystemDnsResolver.php create mode 100644 database/migrations/2026_09_16_100000_create_email_domain_checks_table.php create mode 100644 resources/views/livewire/admin/reports/submitter-contacts.blade.php create mode 100644 tests/Feature/MailDomainCheckTest.php create mode 100644 tests/Feature/PlausibleEmailDomainTest.php create mode 100644 tests/Feature/SubmitterContactReportTest.php create mode 100644 tests/Support/FakeDnsResolver.php diff --git a/app/Console/Commands/CheckMailDomains.php b/app/Console/Commands/CheckMailDomains.php new file mode 100644 index 00000000..a9012575 --- /dev/null +++ b/app/Console/Commands/CheckMailDomains.php @@ -0,0 +1,114 @@ +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 + */ + 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 + */ + 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(); + } +} diff --git a/app/Enums/EmailBounceKind.php b/app/Enums/EmailBounceKind.php index 481ff48b..1f4c926d 100644 --- a/app/Enums/EmailBounceKind.php +++ b/app/Enums/EmailBounceKind.php @@ -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', }; } } diff --git a/app/Enums/EmailDomainState.php b/app/Enums/EmailDomainState.php new file mode 100644 index 00000000..1f2f3cdf --- /dev/null +++ b/app/Enums/EmailDomainState.php @@ -0,0 +1,71 @@ + 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', + }; + } +} diff --git a/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php index fb6d7ecf..d4e4e6ab 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php @@ -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) { diff --git a/app/Http/Controllers/Api/Legacy/LegacyContactController.php b/app/Http/Controllers/Api/Legacy/LegacyContactController.php index d3925284..cbe69299 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyContactController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyContactController.php @@ -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); diff --git a/app/Jobs/CheckEmailDomain.php b/app/Jobs/CheckEmailDomain.php new file mode 100644 index 00000000..6f6eb73d --- /dev/null +++ b/app/Jobs/CheckEmailDomain.php @@ -0,0 +1,43 @@ + + */ + 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; + } +} diff --git a/app/Models/EmailDomainCheck.php b/app/Models/EmailDomainCheck.php new file mode 100644 index 00000000..d319b4b4 --- /dev/null +++ b/app/Models/EmailDomainCheck.php @@ -0,0 +1,57 @@ + + */ + 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 $query + */ + public function scopeBroken(Builder $query): void + { + $query->whereIn('state', [ + EmailDomainState::NullMx->value, + EmailDomainState::NoMailPath->value, + EmailDomainState::DomainMissing->value, + EmailDomainState::BrokenMx->value, + ]); + } +} diff --git a/app/Observers/EmailDomainObserver.php b/app/Observers/EmailDomainObserver.php new file mode 100644 index 00000000..fffdebce --- /dev/null +++ b/app/Observers/EmailDomainObserver.php @@ -0,0 +1,57 @@ +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(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c26f7273..1e78fc91 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/app/Rules/PlausibleEmailDomain.php b/app/Rules/PlausibleEmailDomain.php new file mode 100644 index 00000000..9f0e31d5 --- /dev/null +++ b/app/Rules/PlausibleEmailDomain.php @@ -0,0 +1,114 @@ +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}(?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)); + } +} diff --git a/app/Services/Mail/BounceMailProcessor.php b/app/Services/Mail/BounceMailProcessor.php index 1c0adf69..4c30f2e2 100644 --- a/app/Services/Mail/BounceMailProcessor.php +++ b/app/Services/Mail/BounceMailProcessor.php @@ -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 diff --git a/app/Services/Mail/BrokenContact.php b/app/Services/Mail/BrokenContact.php new file mode 100644 index 00000000..ff9ff134 --- /dev/null +++ b/app/Services/Mail/BrokenContact.php @@ -0,0 +1,27 @@ +>|null null = Resolver-Fehler + * (Timeout/SERVFAIL), [] = Domain + * antwortet, hat aber keinen + * solchen Record + */ + public function records(string $domain, string $type): ?array; +} diff --git a/app/Services/Mail/EmailSuppressionService.php b/app/Services/Mail/EmailSuppressionService.php index 794b031a..96911ecf 100644 --- a/app/Services/Mail/EmailSuppressionService.php +++ b/app/Services/Mail/EmailSuppressionService.php @@ -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; } diff --git a/app/Services/Mail/MailDomainChecker.php b/app/Services/Mail/MailDomainChecker.php new file mode 100644 index 00000000..72dedb92 --- /dev/null +++ b/app/Services/Mail/MailDomainChecker.php @@ -0,0 +1,179 @@ +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 $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)); + } +} diff --git a/app/Services/Mail/RejectedRelease.php b/app/Services/Mail/RejectedRelease.php new file mode 100644 index 00000000..5726217b --- /dev/null +++ b/app/Services/Mail/RejectedRelease.php @@ -0,0 +1,19 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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 `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 $companies + * @return Collection + */ + 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; + } +} diff --git a/app/Services/Mail/SubmitterSummary.php b/app/Services/Mail/SubmitterSummary.php new file mode 100644 index 00000000..f16a4fad --- /dev/null +++ b/app/Services/Mail/SubmitterSummary.php @@ -0,0 +1,16 @@ + 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); + } +} diff --git a/config/bounces.php b/config/bounces.php index f9d596dd..97136938 100644 --- a/config/bounces.php +++ b/config/bounces.php @@ -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), diff --git a/database/migrations/2026_09_16_100000_create_email_domain_checks_table.php b/database/migrations/2026_09_16_100000_create_email_domain_checks_table.php new file mode 100644 index 00000000..baf2fedd --- /dev/null +++ b/database/migrations/2026_09_16_100000_create_email_domain_checks_table.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 3733321d..33d37da4 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -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. diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index f3dc4d71..0b3f76df 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -226,6 +226,10 @@ :current="request()->routeIs('admin.reports.slow-requests')" wire:navigate> {{ __('Performance') }} + + {{ __('Einlieferer-Bericht') }} + @can('settings:manage') diff --git a/resources/views/livewire/admin/companies/show.blade.php b/resources/views/livewire/admin/companies/show.blade.php index ac409387..0c19a3df 100644 --- a/resources/views/livewire/admin/companies/show.blade.php +++ b/resources/views/livewire/admin/companies/show.blade.php @@ -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 --}} {{ __('E-Mail nicht erreichbar') }} @endif + @if ($domainCheck?->state->isBroken()) + {{-- Kontakterreichbarkeit: die Domain hat keinen Zustellweg --}} + {{ $domainCheck->state->label() }} + @endif
@if ($logoUrl) diff --git a/resources/views/livewire/admin/reports/submitter-contacts.blade.php b/resources/views/livewire/admin/reports/submitter-contacts.blade.php new file mode 100644 index 00000000..8b2a2deb --- /dev/null +++ b/resources/views/livewire/admin/reports/submitter-contacts.blade.php @@ -0,0 +1,310 @@ +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), + ]; + } +}; ?> + +
+ + +
+
+ {{ __('Auswahl') }} +
+
+ + + @foreach ($this->submitters as $option) + + @endforeach + + + + + + + + + + +
+
+ +
+ + {{ __(':count Meldungen betroffen', ['count' => $contacts->sum(fn ($row) => $row->releases)]) }} + + + {{ __('automatische Prüfung') }} + + + {{ __('Empfänger filtert — keine Sperre') }} + +
+ +
+
+ {{ __('Nicht erreichbare Adressen') }} + @if ($contacts->isNotEmpty()) + {{ __('CSV') }} + @endif +
+ + + {{ __('Adresse') }} + {{ __('Firma') }} + {{ __('Befund') }} + {{ __('Meldungen') }} + {{ __('Zuletzt') }} + + + @forelse ($contacts as $row) + + + {{ $row->email }} + + + {{ $row->company }} + + + {{ $row->reason }} + {{ $row->fromDomain ? __('DNS-Prüfung der Domain') : __('Rückläufer') }}@if ($row->detail) · {{ $row->detail }}@endif + + + {{ $row->releases }} + + + {{ $row->lastEvent?->format('d.m.Y') ?? '—' }} + + + @empty + + +
+ {{ __('Keine unzustellbaren Adressen in diesem Zeitraum.') }} +
+
+
+ @endforelse +
+
+
+ +
+
+ {{ __('Abgelehnte Meldungen') }} + @if ($rejected->isNotEmpty()) + {{ __('CSV') }} + @endif +
+
    + @forelse ($rejected as $release) +
  • +
    + {{ $release->title }} + {{ $release->company }} + {{ $release->rejectedAt?->format('d.m.Y') }} +
    +

    {{ $release->reason }}

    +
  • + @empty +
  • + {{ __('Keine automatischen Ablehnungen in diesem Zeitraum.') }} +
  • + @endforelse +
+
+ + @if ($policy->isNotEmpty()) +
+
+ {{ __('Richtlinien-Ablehnungen') }} + {{ __('Empfängerserver hat abgelehnt — die Adresse bleibt frei') }} +
+
    + @foreach ($policy as $bounce) +
  • + {{ $bounce->received_at?->format('d.m.') }} + {{ $bounce->email }} + {{ $bounce->status }} · {{ Str::limit($bounce->diagnostic, 90) }} +
  • + @endforeach +
+
+ @endif + +
+
+ {{ __('Bericht zum Kopieren') }} + {{ __('für die Mail an den Einlieferer') }} +
+
+ +
+
+
diff --git a/routes/admin.php b/routes/admin.php index ff79025d..f72750ff 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); }); diff --git a/routes/console.php b/routes/console.php index 4a25c314..d6bb9606 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ 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') diff --git a/tests/Feature/Admin/AdminKiCheckTest.php b/tests/Feature/Admin/AdminKiCheckTest.php index 00ae60ac..fda18312 100644 --- a/tests/Feature/Admin/AdminKiCheckTest.php +++ b/tests/Feature/Admin/AdminKiCheckTest.php @@ -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 () { diff --git a/tests/Feature/BounceMailProcessingTest.php b/tests/Feature/BounceMailProcessingTest.php index c50ec585..23c6cff9 100644 --- a/tests/Feature/BounceMailProcessingTest.php +++ b/tests/Feature/BounceMailProcessingTest.php @@ -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 : 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(); +}); diff --git a/tests/Feature/MailDomainCheckTest.php b/tests/Feature/MailDomainCheckTest.php new file mode 100644 index 00000000..a09040b4 --- /dev/null +++ b/tests/Feature/MailDomainCheckTest.php @@ -0,0 +1,151 @@ +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(); +}); diff --git a/tests/Feature/PlausibleEmailDomainTest.php b/tests/Feature/PlausibleEmailDomainTest.php new file mode 100644 index 00000000..3ad5180f --- /dev/null +++ b/tests/Feature/PlausibleEmailDomainTest.php @@ -0,0 +1,69 @@ + $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(); +}); diff --git a/tests/Feature/PressReleaseReclassifyTest.php b/tests/Feature/PressReleaseReclassifyTest.php index a435eb07..5b8f3ad0 100644 --- a/tests/Feature/PressReleaseReclassifyTest.php +++ b/tests/Feature/PressReleaseReclassifyTest.php @@ -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); }); diff --git a/tests/Feature/SubmitterContactReportTest.php b/tests/Feature/SubmitterContactReportTest.php new file mode 100644 index 00000000..49098588 --- /dev/null +++ b/tests/Feature/SubmitterContactReportTest.php @@ -0,0 +1,178 @@ +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'); +}); diff --git a/tests/Support/FakeDnsResolver.php b/tests/Support/FakeDnsResolver.php new file mode 100644 index 00000000..1cec10e1 --- /dev/null +++ b/tests/Support/FakeDnsResolver.php @@ -0,0 +1,36 @@ +>|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; + } +}