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:
Kevin Adametz 2026-09-15 14:01:07 +00:00
parent f9c97ccf2f
commit e7e12b9436
12 changed files with 426 additions and 9 deletions

View file

@ -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

View file

@ -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);
}
/**

View file

@ -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 |

View file

@ -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).

View file

@ -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.',

View file

@ -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.',

View file

@ -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 --}}

View file

@ -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">

View 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>

View file

@ -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>

View file

@ -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');

View 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');
});