admin: Mail-Sperrliste, Markierung nicht erreichbarer Newsrooms (Roadmap 1b.6, Schritt 4)
Admin ▸ Mail-Sperrliste mit Freigabe, manueller Sperre, nicht lesbaren Rückläufern und Breaker-Zustand. Firmenseite markiert gesperrte Firmen-E-Mail und Pressekontakte. Die Claim-Seite zählt gesperrte Adressen nicht mehr als Empfänger und erklärt, warum kein Link geht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9c97ccf2f
commit
e7e12b9436
12 changed files with 426 additions and 9 deletions
|
|
@ -42,6 +42,7 @@ class NewsroomClaimController extends Controller
|
|||
return view('web.newsroom-claim', [
|
||||
'company' => $company,
|
||||
'recipients' => $this->claims->maskedRecipients($company),
|
||||
'unreachable' => $this->claims->isUnreachable($company),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ class NewsroomClaimController extends Controller
|
|||
RateLimiter::hit($this->companyKey($company->getKey()), 86400);
|
||||
|
||||
if ($this->claims->recipients($company)->isEmpty()) {
|
||||
return back()->with('newsroom-claim-none', true);
|
||||
return back()->with($this->claims->isUnreachable($company) ? 'newsroom-claim-unreachable' : 'newsroom-claim-none', true);
|
||||
}
|
||||
|
||||
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Enums\ConversionSource;
|
|||
use App\Models\Company;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
|
|
@ -26,7 +27,10 @@ use Illuminate\Support\Str;
|
|||
*/
|
||||
class NewsroomClaimService
|
||||
{
|
||||
public function __construct(private readonly ContactAccessService $contactAccess) {}
|
||||
public function __construct(
|
||||
private readonly ContactAccessService $contactAccess,
|
||||
private readonly EmailSuppressionService $suppressions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Firma zu einem Slug auf diesen Portalen — auch ohne veröffentlichte
|
||||
|
|
@ -48,16 +52,42 @@ class NewsroomClaimService
|
|||
/**
|
||||
* Empfänger des Anmeldelinks: die Firmen-E-Mail, sonst die Adressen der
|
||||
* Pressekontakte. Bewusst nicht beides — ein Klick eines Fremden soll
|
||||
* nicht fünf Postfächer treffen.
|
||||
* nicht fünf Postfächer treffen. Gesperrte Adressen (Bounce-Suppression,
|
||||
* 1b.6) zählen nicht: Ein Newsroom, dessen einzige Adresse tot ist, ist
|
||||
* nicht beanspruchbar — siehe isUnreachable().
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public function recipients(Company $company): Collection
|
||||
{
|
||||
$candidates = $this->candidateRecipients($company);
|
||||
|
||||
if ($candidates === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$suppressed = $this->suppressions->suppressedAmong($candidates);
|
||||
|
||||
return collect(array_values(array_filter($candidates, fn (string $email): bool => ! in_array($email, $suppressed, true))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Es gäbe Adressen, aber alle sind gesperrt.
|
||||
*/
|
||||
public function isUnreachable(Company $company): bool
|
||||
{
|
||||
return $this->candidateRecipients($company) !== [] && $this->recipients($company)->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function candidateRecipients(Company $company): array
|
||||
{
|
||||
$companyEmail = Company::emailKey($company->email);
|
||||
|
||||
if ($companyEmail !== null) {
|
||||
return collect([$companyEmail]);
|
||||
return [$companyEmail];
|
||||
}
|
||||
|
||||
$emails = [];
|
||||
|
|
@ -70,7 +100,7 @@ class NewsroomClaimService
|
|||
}
|
||||
}
|
||||
|
||||
return collect(array_values($emails));
|
||||
return array_values($emails);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
|
|||
| ~~1b.3~~ | Kostenlose Profilpflege: Beschreibung, Logo, Bilder, Pressekontakte — **15.09.2026**: Beschreibung durch die Rot-Ebene, reiner Text ohne Links, Stufe 3 der Leiter wird hier geschrieben; **Bilder zurückgestellt** (BALD-Platzhalter) bis die Bild-Kennzeichnung angepasst ist | M | 1b.2 |
|
||||
| ~~1b.4~~ | Hinweis "Diesen Newsroom verwalten" auf der Profilseite und in der Veröffentlichungsmail — **gebaut 15.09.2026** (E4: Hinweis unten in der Mail; Herkunft `mail_publish` läuft bis auf das Token durch) | S | 1b.2 |
|
||||
| ~~1b.5~~ | Rückbau: fällt ein Newsroom durch Löschung oder Depublizierung auf null Meldungen, verschwindet die öffentliche Seite mit — **15.09.2026**: alle Admin-Wege (archivieren, Status ändern, löschen) leeren jetzt den Seiten-Cache | S | 1b.1 |
|
||||
| 1b.6 | Nicht erreichbare Newsrooms aus der Bounce-Suppression im Admin markieren, nicht beanspruchbar, aus allen Mailstrecken ausgenommen | S | Bounce-Suppression live |
|
||||
| ~~1b.6~~ | Nicht erreichbare Newsrooms aus der Bounce-Suppression im Admin markieren, nicht beanspruchbar, aus allen Mailstrecken ausgenommen — **gebaut 15.09.2026** inkl. der Suppression selbst: zentrale Sperrliste, Versand-Gate, SMTP-Pfad + Circuit Breaker, IMAP-Abholung mit DSN-Parser, Admin ▸ Mail-Sperrliste. Live, sobald `bounce@pressekonto.com` angelegt und in der `.env` eingetragen ist | S | Bounce-Suppression live |
|
||||
|
||||
**Leitplanken Profilpflege**
|
||||
|
||||
|
|
@ -221,7 +221,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
|
|||
|
||||
| Vorgang | Blockiert | Grund |
|
||||
|---|---|---|
|
||||
| Bounce-Suppression produktiv | 1b.6, 1d.3, 3.2, jede weitere Mailwelle | Zustellbarkeit; ein beschädigter Absenderruf trifft zuerst die Magic Links |
|
||||
| Bounce-Suppression produktiv — **Code fertig 15.09.2026**, produktiv mit Postfach `bounce@pressekonto.com` + `MAIL_RETURN_PATH` + `BOUNCE_IMAP_*` | 1b.6, 1d.3, 3.2, jede weitere Mailwelle | Zustellbarkeit; ein beschädigter Absenderruf trifft zuerst die Magic Links |
|
||||
| connektar `ai_declaration` verpflichtend, ~Oktober 2026 | nichts direkt | Termin steht, gehört in die Planung |
|
||||
| Anwaltstermin, Fünf-Punkte-Agenda | 3.5 | Syndikationsvertrag und Art. 50 Abs. 4 |
|
||||
|
||||
|
|
|
|||
|
|
@ -1088,3 +1088,26 @@ Zustellbestätigung, Freitext, Abwesenheit).
|
|||
`BOUNCE_IMAP_HOST=w01eb4f6.kasserver.com`, `BOUNCE_IMAP_USERNAME`/`PASSWORD`
|
||||
des Postfachs bounce@pressekonto.com, `MAIL_RETURN_PATH=bounce@pressekonto.com`.
|
||||
Erster Lauf von Hand: `php artisan mail:fetch-bounces --dry-run`.
|
||||
|
||||
**Schritt 4 — 1b.6 selbst** (15.09.2026)
|
||||
|
||||
- **Admin ▸ Mail-Sperrliste** (`/admin/mail/sperrliste`, Recht
|
||||
`users:manage`): gesperrte/freigegebene Adressen mit Grund, Quelle,
|
||||
Zählern; Freigabe mit Rückfrage; manuelle Sperre; die letzten nicht
|
||||
lesbaren Rückläufer (liegen im Postfach unter „Unparsed"); Zustand des
|
||||
Circuit Breakers und ob Return-Path/Postfach konfiguriert sind.
|
||||
- **Firmenseite im Admin:** Badge „E-Mail nicht erreichbar" im Kopf
|
||||
(verlinkt in die Sperrliste), Markierung an der Firmen-E-Mail und an
|
||||
jedem gesperrten Pressekontakt.
|
||||
- **Claim-Seite:** Gesperrte Adressen sind keine Empfänger mehr. Gibt es
|
||||
Adressen, aber alle sind gesperrt, sagt die Seite, dass die hinterlegte
|
||||
Adresse nicht erreichbar ist, und verschickt nichts. Die Meldungsseite
|
||||
zeigt dieselben Empfänger (gleiche Quelle).
|
||||
- Aus allen Mailstrecken ausgenommen sind gesperrte Adressen bereits seit
|
||||
Schritt 1 über das Versand-Gate.
|
||||
|
||||
Tests: `tests/Feature/MailSuppressionAdminTest.php` (3).
|
||||
|
||||
**Damit ist 1b.6 komplett — und Block 1b.** Offen bleibt nur das
|
||||
Anlegen des Postfachs und die Live-Konfiguration (siehe Deploy-Hinweise
|
||||
zu Schritt 1 und 3).
|
||||
|
|
|
|||
|
|
@ -543,6 +543,7 @@ return [
|
|||
'eyebrow' => 'Newsroom',
|
||||
'breadcrumb' => 'Verwalten',
|
||||
'intro' => 'Der Newsroom von :company gehört dem Unternehmen selbst. Wer die hinterlegte Firmen-E-Mail liest, kann Profil, Logo und Pressekontakte pflegen — kostenlos und ohne Passwort. Wir senden einen Anmeldelink an:',
|
||||
'intro_unreachable' => 'Die für den Newsroom von :company hinterlegte E-Mail-Adresse ist nicht mehr erreichbar — unsere Mails kommen dort als unzustellbar zurück. Ein Anmeldelink kann deshalb nicht verschickt werden. Bitte wenden Sie sich über das Kontaktformular an uns; wir hinterlegen dann eine aktuelle Adresse.',
|
||||
'intro_no_email' => 'Für den Newsroom von :company ist keine E-Mail-Adresse hinterlegt. Bitte wenden Sie sich über das Kontaktformular an uns.',
|
||||
'submit' => 'Anmeldelink senden',
|
||||
'flash_sent' => 'Falls die hinterlegte Adresse erreichbar ist, haben wir einen Anmeldelink verschickt. Er ist 15 Minuten gültig.',
|
||||
|
|
|
|||
|
|
@ -542,6 +542,7 @@ return [
|
|||
'eyebrow' => 'Newsroom',
|
||||
'breadcrumb' => 'Manage',
|
||||
'intro' => 'The newsroom of :company belongs to the company itself. Whoever reads the registered company e-mail can maintain profile, logo and press contacts — free of charge and without a password. We will send a login link to:',
|
||||
'intro_unreachable' => 'The e-mail address registered for the newsroom of :company is no longer reachable — our mails bounce there. A login link therefore cannot be sent. Please contact us via the contact form and we will register a current address.',
|
||||
'intro_no_email' => 'No e-mail address is registered for the newsroom of :company. Please contact us via the contact form.',
|
||||
'submit' => 'Send login link',
|
||||
'flash_sent' => 'If the registered address is reachable, we have sent a login link. It is valid for 15 minutes.',
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@
|
|||
:current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
|
||||
{{ __('Newsletter Sync') }}
|
||||
</flux:navlist.item>
|
||||
@can('users:manage')
|
||||
<flux:navlist.item icon="no-symbol" :href="route('admin.mail.suppressions')"
|
||||
:current="request()->routeIs('admin.mail.suppressions')" wire:navigate>
|
||||
{{ __('Mail-Sperrliste') }}
|
||||
</flux:navlist.item>
|
||||
@endcan
|
||||
</flux:navlist.group>
|
||||
@endcan
|
||||
{{-- Content --}}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
'filteredContactsTotal' => 0,
|
||||
'contactLookupResults' => collect(),
|
||||
'recentPressReleases' => collect(),
|
||||
'unreachableEmails' => [],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -166,8 +167,16 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
->get(['id', 'company_id', 'first_name', 'last_name', 'email']);
|
||||
}
|
||||
|
||||
// Bounce-Suppression (Roadmap 1b.6): Firmen-E-Mail und Kontaktadressen,
|
||||
// die nicht erreichbar sind — als Markierung im Kopf und an der Zeile.
|
||||
$unreachableEmails = app(\App\Services\Mail\EmailSuppressionService::class)->suppressedAmong(array_values(array_filter([
|
||||
$company->email,
|
||||
...$filteredContacts->pluck('email')->all(),
|
||||
])));
|
||||
|
||||
return [
|
||||
'company' => $company,
|
||||
'unreachableEmails' => $unreachableEmails,
|
||||
'filteredContacts' => $filteredContacts,
|
||||
'filteredContactsTotal' => $filteredContactsTotal,
|
||||
'contactLookupResults' => $contactLookupResults,
|
||||
|
|
@ -210,6 +219,10 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
@endif
|
||||
<span class="badge hub">{{ $company->portal?->label() ?? __('Unbekannt') }}</span>
|
||||
<span class="badge hub">ID {{ $company->id }}</span>
|
||||
@if ($unreachableEmails !== [])
|
||||
{{-- 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
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
@if ($logoUrl)
|
||||
|
|
@ -296,7 +309,12 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
<dl class="p-5 space-y-2.5 text-[12.5px]">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-[color:var(--color-ink-3)]">{{ __('E-Mail') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)] text-right break-all">{{ $company->email ?: __('Keine E-Mail hinterlegt') }}</dd>
|
||||
<dd class="text-[color:var(--color-ink)] text-right break-all">
|
||||
{{ $company->email ?: __('Keine E-Mail hinterlegt') }}
|
||||
@if ($company->email && in_array(mb_strtolower(trim($company->email)), $unreachableEmails, true))
|
||||
<span class="badge err ml-1">{{ __('nicht erreichbar') }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-[color:var(--color-ink-3)]">{{ __('Telefon') }}</dt>
|
||||
|
|
@ -430,6 +448,9 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
<div class="text-[12px] text-[color:var(--color-ink-3)] mt-0.5">
|
||||
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
|
||||
</div>
|
||||
@if ($contact->email && in_array(mb_strtolower(trim($contact->email)), $unreachableEmails, true))
|
||||
<span class="badge err mr-1">{{ __('nicht erreichbar') }}</span>
|
||||
@endif
|
||||
@if ($contact->email)
|
||||
<a href="mailto:{{ $contact->email }}"
|
||||
class="text-[12px] text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)] mt-0.5 inline-block">
|
||||
|
|
|
|||
248
resources/views/livewire/admin/mail/suppressions.blade.php
Normal file
248
resources/views/livewire/admin/mail/suppressions.blade.php
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\EmailSuppressionReason;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\EmailBounce;
|
||||
use App\Models\EmailSuppression;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\Mail\MailCircuitBreaker;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/**
|
||||
* Admin ▸ Mail-Sperrliste (Roadmap 1b.6): nicht erreichbare Adressen aus
|
||||
* Rückläufern, SMTP-Ablehnungen und Import — mit Freigabe, manueller
|
||||
* Sperre, den letzten Rückläufern und den nicht lesbaren Mails, die im
|
||||
* Postfach unter „Unparsed" auf einen Menschen warten.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Mail-Sperrliste')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $view = 'active';
|
||||
|
||||
public string $newEmail = '';
|
||||
|
||||
public string $newNote = '';
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedView(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function release(int $id): void
|
||||
{
|
||||
$this->authorize('users:manage');
|
||||
|
||||
$suppression = EmailSuppression::query()->findOrFail($id);
|
||||
app(EmailSuppressionService::class)->release($suppression, auth()->user(), 'Freigabe im Admin');
|
||||
|
||||
session()->flash('status', __(':email freigegeben — der nächste Rückläufer sperrt sie wieder.', ['email' => $suppression->email]));
|
||||
}
|
||||
|
||||
public function suppress(): void
|
||||
{
|
||||
$this->authorize('users:manage');
|
||||
|
||||
$validated = $this->validate([
|
||||
'newEmail' => ['required', 'email', 'max:190'],
|
||||
'newNote' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
app(EmailSuppressionService::class)->suppress($validated['newEmail'], EmailSuppressionReason::Manual, 'admin', $validated['newNote'] ?: 'manuell im Admin');
|
||||
|
||||
$this->reset('newEmail', 'newNote');
|
||||
session()->flash('status', __('Adresse gesperrt.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$query = EmailSuppression::query()
|
||||
->when($this->view === 'active', fn ($q) => $q->active())
|
||||
->when($this->view === 'released', fn ($q) => $q->whereNotNull('released_at'))
|
||||
->when(trim($this->search) !== '', fn ($q) => $q->whereLike('email', '%'.trim($this->search).'%'))
|
||||
->orderByDesc('suppressed_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
return [
|
||||
'suppressions' => $query->paginate(50),
|
||||
'activeCount' => EmailSuppression::query()->active()->count(),
|
||||
'bouncesThisWeek' => EmailBounce::query()->where('received_at', '>=', now()->subDays(7))->count(),
|
||||
'unparsed' => ActivityEvent::query()
|
||||
->where('type', ActivityType::MailSuppressed->value)
|
||||
->where('description', 'like', 'Rückläufer nicht lesbar%')
|
||||
->latest('occurred_at')
|
||||
->limit(10)
|
||||
->get(),
|
||||
'circuitOpenUntil' => app(MailCircuitBreaker::class)->openUntil(),
|
||||
'returnPath' => (string) config('bounces.return_path'),
|
||||
'imapConfigured' => filled(config('imap.accounts.bounces.host')),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · E-Mail')"
|
||||
:title="__('Mail-Sperrliste')"
|
||||
:description="__('Adressen, die nicht erreichbar sind: aus Rückläufern (Bounce-Postfach), SMTP-Ablehnungen und der Bounce-Liste. Gesperrte Adressen bekommen keine Mail mehr — Magic Links, Benachrichtigungen, Newsletter — und ihre Newsrooms sind nicht beanspruchbar.')"
|
||||
>
|
||||
<x-slot:meta>
|
||||
@if ($circuitOpenUntil)
|
||||
<span class="badge err dot">{{ __('Versand angehalten bis :time', ['time' => $circuitOpenUntil->format('H:i')]) }}</span>
|
||||
@else
|
||||
<span class="badge ok dot">{{ __('Versand läuft') }}</span>
|
||||
@endif
|
||||
@if ($imapConfigured)
|
||||
<span class="badge hub">{{ __('Postfach angebunden') }}</span>
|
||||
@else
|
||||
<span class="badge warn">{{ __('Kein Bounce-Postfach konfiguriert') }}</span>
|
||||
@endif
|
||||
</x-slot:meta>
|
||||
</x-portal.page-header>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<x-portal.stat-card variant="primary" :label="__('Gesperrt')" :value="number_format($activeCount)">
|
||||
<x-slot:meta>{{ __('Adressen ohne Zustellung') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="warn" :label="__('Rückläufer 7 Tage')" :value="number_format($bouncesThisWeek)">
|
||||
<x-slot:meta>{{ __('alle Quellen') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Return-Path')" :value="$returnPath ?: '—'">
|
||||
<x-slot:meta>{{ $returnPath ? __('Rückläufer landen im Bounce-Postfach') : __('MAIL_RETURN_PATH nicht gesetzt') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
</section>
|
||||
|
||||
@if ($unparsed->isNotEmpty())
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Nicht lesbare Rückläufer') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('liegen im Postfach unter „Unparsed“') }}</span>
|
||||
</div>
|
||||
<ul class="p-5 space-y-2 text-[12.5px]">
|
||||
@foreach ($unparsed as $event)
|
||||
<li class="flex items-baseline gap-3">
|
||||
<span class="font-mono text-[11px] text-[color:var(--color-ink-4)] flex-shrink-0">{{ $event->occurred_at?->format('d.m. H:i') }}</span>
|
||||
<span class="text-[color:var(--color-ink-2)]">{{ Str::after($event->description, 'Unparsed“: ') }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
@can('users:manage')
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Adresse sperren') }}</span>
|
||||
</div>
|
||||
<form wire:submit="suppress" class="p-5 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<flux:field class="flex-1">
|
||||
<flux:input wire:model="newEmail" type="email" :label="__('E-Mail')" placeholder="presse@beispiel.de" />
|
||||
<flux:error name="newEmail" />
|
||||
</flux:field>
|
||||
<flux:field class="flex-1">
|
||||
<flux:input wire:model="newNote" :label="__('Notiz')" placeholder="{{ __('z. B. Rückläufer von Hand geprüft') }}" />
|
||||
<flux:error name="newNote" />
|
||||
</flux:field>
|
||||
<flux:button type="submit" variant="primary" icon="no-symbol">{{ __('Sperren') }}</flux:button>
|
||||
</form>
|
||||
</article>
|
||||
@endcan
|
||||
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Adressen') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __(':count Einträge', ['count' => $suppressions->total()]) }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex flex-col gap-4 sm:flex-row border-b border-[color:var(--color-bg-rule)]">
|
||||
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" :placeholder="__('Adresse suchen…')" class="w-full sm:w-80" />
|
||||
<flux:select wire:model.live="view" class="w-full sm:w-56">
|
||||
<option value="active">{{ __('Gesperrt') }}</option>
|
||||
<option value="released">{{ __('Freigegeben') }}</option>
|
||||
<option value="all">{{ __('Alle') }}</option>
|
||||
</flux:select>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Adresse') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Grund') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Rückläufer') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Zuletzt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($suppressions as $suppression)
|
||||
<flux:table.row :key="$suppression->id">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)] break-all">{{ $suppression->email }}</span>
|
||||
@if ($suppression->note)
|
||||
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ $suppression->note }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $suppression->reason->label() }}</span>
|
||||
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">{{ $suppression->source }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $suppression->hard_bounce_count }} {{ __('hart') }} · {{ $suppression->soft_bounce_count }} {{ __('weich') }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ ($suppression->last_bounced_at ?? $suppression->suppressed_at)?->format('d.m.Y H:i') ?? '—' }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if ($suppression->isActive())
|
||||
<span class="badge err dot">{{ __('gesperrt') }}</span>
|
||||
@else
|
||||
<span class="badge ok dot">{{ __('freigegeben') }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@can('users:manage')
|
||||
@if ($suppression->isActive())
|
||||
<flux:button size="sm" variant="filled" icon="lock-open" wire:click="release({{ $suppression->id }})" wire:confirm="{{ __('Adresse freigeben? Sie bekommt dann wieder Mails, bis der nächste Rückläufer kommt.') }}">
|
||||
{{ __('Freigeben') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
@endcan
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="6">
|
||||
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Keine Adressen in dieser Ansicht.') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@if ($suppressions->hasPages())
|
||||
<div class="p-4 border-t border-[color:var(--color-bg-rule)]">{{ $suppressions->links() }}</div>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -31,7 +31,12 @@
|
|||
</div>
|
||||
@enderror
|
||||
|
||||
@if ($recipients->isEmpty() || session('newsroom-claim-none'))
|
||||
@if ($unreachable || session('newsroom-claim-unreachable'))
|
||||
{{-- Bounce-Suppression (1b.6): Adresse hinterlegt, aber tot. --}}
|
||||
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-6">
|
||||
{{ __('static.newsroom_claim.intro_unreachable', ['company' => $company->name]) }}
|
||||
</p>
|
||||
@elseif ($recipients->isEmpty() || session('newsroom-claim-none'))
|
||||
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-6">
|
||||
{{ __('static.newsroom_claim.intro_no_email', ['company' => $company->name]) }}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
// Interner Newsletter: Kampagnen an Bestands-User (Filter + Protokoll).
|
||||
// Massenversand an die User-Datenbank → nur mit users:manage (admin).
|
||||
Route::middleware('can:users:manage')->group(function (): void {
|
||||
// Mail-Sperrliste (Bounce-Suppression, Roadmap 1b.6)
|
||||
Volt::route('admin/mail/sperrliste', 'admin.mail.suppressions')->name('admin.mail.suppressions');
|
||||
Volt::route('admin/newsletter', 'admin.newsletter.campaigns.index')->name('admin.newsletter.campaigns.index');
|
||||
Volt::route('admin/newsletter/abmeldungen', 'admin.newsletter.unsubscribes')->name('admin.newsletter.unsubscribes');
|
||||
Volt::route('admin/newsletter/create', 'admin.newsletter.campaigns.edit')->name('admin.newsletter.campaigns.create');
|
||||
|
|
|
|||
79
tests/Feature/MailSuppressionAdminTest.php
Normal file
79
tests/Feature/MailSuppressionAdminTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\EmailSuppressionReason;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\EmailSuppression;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Roadmap 1b.6, Schritt 4: nicht erreichbare Newsrooms im Admin markiert,
|
||||
* nicht beanspruchbar, Freigabe im Admin.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function suppressionAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
test()->actingAs($admin);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('a newsroom whose only address bounced is not claimable and says why', function () {
|
||||
/** @var TestCase $this */
|
||||
Company::factory()->create(['portal' => 'businessportal24', 'slug' => 'tot-gmbh', 'email' => 'presse@tot.example', 'name' => 'Tot GmbH']);
|
||||
app(EmailSuppressionService::class)->suppress('presse@tot.example', EmailSuppressionReason::HardBounce, 'imap');
|
||||
|
||||
$this->get('https://businessportal24.test/de/newsroom/tot-gmbh/verwalten')
|
||||
->assertOk()
|
||||
->assertSeeText('nicht mehr erreichbar')
|
||||
->assertDontSeeText('Anmeldelink senden');
|
||||
|
||||
$this->post('https://businessportal24.test/de/newsroom/tot-gmbh/verwalten')->assertSessionHas('newsroom-claim-unreachable');
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('the admin company page marks unreachable company and contact addresses', function () {
|
||||
/** @var TestCase $this */
|
||||
suppressionAdmin();
|
||||
$company = Company::factory()->create(['email' => 'presse@tot.example']);
|
||||
Contact::factory()->for($company)->create(['email' => 'kontakt@tot.example', 'portal' => $company->portal->value]);
|
||||
app(EmailSuppressionService::class)->suppress('presse@tot.example', EmailSuppressionReason::HardBounce, 'imap');
|
||||
app(EmailSuppressionService::class)->suppress('kontakt@tot.example', EmailSuppressionReason::HardBounce, 'imap');
|
||||
|
||||
LivewireVolt::test('admin.companies.show', ['id' => $company->id])
|
||||
->assertSee('E-Mail nicht erreichbar')
|
||||
->call('setTab', 'contacts')
|
||||
->assertSee('kontakt@tot.example')
|
||||
->assertSeeInOrder(['nicht erreichbar', 'kontakt@tot.example']);
|
||||
});
|
||||
|
||||
test('the suppression list shows, releases and manually suppresses addresses', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = suppressionAdmin();
|
||||
app(EmailSuppressionService::class)->suppress('presse@tot.example', EmailSuppressionReason::HardBounce, 'imap');
|
||||
|
||||
$component = LivewireVolt::test('admin.mail.suppressions')
|
||||
->assertSee('presse@tot.example')
|
||||
->assertSee('Postfach existiert nicht');
|
||||
|
||||
$component->call('release', EmailSuppression::query()->firstOrFail()->id);
|
||||
expect(app(EmailSuppressionService::class)->isSuppressed('presse@tot.example'))->toBeFalse()
|
||||
->and(EmailSuppression::query()->first()->released_by_user_id)->toBe($admin->id);
|
||||
|
||||
$component->set('newEmail', 'Neu@Tot.example')->set('newNote', 'von Hand')->call('suppress')->assertHasNoErrors();
|
||||
expect(app(EmailSuppressionService::class)->isSuppressed('neu@tot.example'))->toBeTrue();
|
||||
|
||||
$this->get(route('admin.mail.suppressions'))->assertOk()->assertSeeText('Mail-Sperrliste');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue