presseportale/app/Services/Mail/SubmitterContactReport.php
Kevin Adametz f581e1eb3a mail: Kontakterreichbarkeit - Formpruefung, Domainpruefung, Einlieferer-Bericht
Vier Punkte aus kontakterreichbarkeit-mx-bounces-2026-09-15.md, ohne
Veroeffentlichungssperre:

- PlausibleEmailDomain: Domain ohne Endung oder mit Tippfehler-TLD ist
  ein 422 an der Legacy-API, deterministisch ohne DNS.
- email_domain_checks + MailDomainChecker ueber DnsResolver-Schnittstelle
  (RFC 5321 impliziter MX, RFC 7505 Null MX, Unresolved ist kein Befund),
  Observer an Company/Contact, Job nach Commit, taeglicher Refresh,
  mail:check-domains fuer den Bestand. Nie eine Adresssperre aus DNS.
- Admin > Reports > Einlieferer-Bericht: tote Adressen aus Sperrliste
  und Domainbefund, automatisch abgelehnte Meldungen mit Grund,
  Text zum Kopieren und CSV. Versendet nichts.
- EmailBounceKind::Policy: 5.7.x wird protokolliert, zaehlt aber nicht
  auf den Schwellwert. Statuscode entscheidet vor Diagnosetext.

Messung vorab: 6 von 877 API-Domains ohne Zustellweg, eine davon fuer
66 der 151 Ruecklaeufer verantwortlich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:22:39 +00:00

254 lines
9.6 KiB
PHP

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