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