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>
254 lines
9.6 KiB
PHP
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;
|
|
}
|
|
}
|