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:
parent
fddfd8a61c
commit
ada41cb067
34 changed files with 1985 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
27
app/Services/Mail/BrokenContact.php
Normal file
27
app/Services/Mail/BrokenContact.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
21
app/Services/Mail/DnsResolver.php
Normal file
21
app/Services/Mail/DnsResolver.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
179
app/Services/Mail/MailDomainChecker.php
Normal file
179
app/Services/Mail/MailDomainChecker.php
Normal 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));
|
||||
}
|
||||
}
|
||||
19
app/Services/Mail/RejectedRelease.php
Normal file
19
app/Services/Mail/RejectedRelease.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
254
app/Services/Mail/SubmitterContactReport.php
Normal file
254
app/Services/Mail/SubmitterContactReport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
16
app/Services/Mail/SubmitterSummary.php
Normal file
16
app/Services/Mail/SubmitterSummary.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
29
app/Services/Mail/SystemDnsResolver.php
Normal file
29
app/Services/Mail/SystemDnsResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue