diff --git a/app/Http/Controllers/Web/NewsroomClaimController.php b/app/Http/Controllers/Web/NewsroomClaimController.php index 01082de7..b38a8b72 100644 --- a/app/Http/Controllers/Web/NewsroomClaimController.php +++ b/app/Http/Controllers/Web/NewsroomClaimController.php @@ -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 diff --git a/app/Services/Company/NewsroomClaimService.php b/app/Services/Company/NewsroomClaimService.php index 6adbf1a7..76693ecd 100644 --- a/app/Services/Company/NewsroomClaimService.php +++ b/app/Services/Company/NewsroomClaimService.php @@ -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 */ 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 + */ + 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); } /** diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index d8144028..fd8590c5 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -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 | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 9a949ac9..bcb1ac68 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -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). diff --git a/lang/de/static.php b/lang/de/static.php index 658ed6b3..c722105b 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -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.', diff --git a/lang/en/static.php b/lang/en/static.php index a2d0fb63..e296a9b9 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -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.', diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 2439d33a..f3dc4d71 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -181,6 +181,12 @@ :current="request()->routeIs('admin.newsletter.sync')" wire:navigate> {{ __('Newsletter Sync') }} + @can('users:manage') + + {{ __('Mail-Sperrliste') }} + + @endcan @endcan {{-- Content --}} diff --git a/resources/views/livewire/admin/companies/show.blade.php b/resources/views/livewire/admin/companies/show.blade.php index 26cb9e49..ac409387 100644 --- a/resources/views/livewire/admin/companies/show.blade.php +++ b/resources/views/livewire/admin/companies/show.blade.php @@ -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 {{ $company->portal?->label() ?? __('Unbekannt') }} ID {{ $company->id }} + @if ($unreachableEmails !== []) + {{-- Bounce-Suppression (Roadmap 1b.6): gesperrte Adressen --}} + {{ __('E-Mail nicht erreichbar') }} + @endif
@if ($logoUrl) @@ -296,7 +309,12 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
{{ __('E-Mail') }}
-
{{ $company->email ?: __('Keine E-Mail hinterlegt') }}
+
+ {{ $company->email ?: __('Keine E-Mail hinterlegt') }} + @if ($company->email && in_array(mb_strtolower(trim($company->email)), $unreachableEmails, true)) + {{ __('nicht erreichbar') }} + @endif +
{{ __('Telefon') }}
@@ -430,6 +448,9 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
+ @if ($contact->email && in_array(mb_strtolower(trim($contact->email)), $unreachableEmails, true)) + {{ __('nicht erreichbar') }} + @endif @if ($contact->email) diff --git a/resources/views/livewire/admin/mail/suppressions.blade.php b/resources/views/livewire/admin/mail/suppressions.blade.php new file mode 100644 index 00000000..928b124d --- /dev/null +++ b/resources/views/livewire/admin/mail/suppressions.blade.php @@ -0,0 +1,248 @@ +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')), + ]; + } +}; ?> + +
+ + + @if ($circuitOpenUntil) + {{ __('Versand angehalten bis :time', ['time' => $circuitOpenUntil->format('H:i')]) }} + @else + {{ __('Versand läuft') }} + @endif + @if ($imapConfigured) + {{ __('Postfach angebunden') }} + @else + {{ __('Kein Bounce-Postfach konfiguriert') }} + @endif + + + + @if (session('status')) +
+ + {{ session('status') }} +
+ @endif + +
+ + {{ __('Adressen ohne Zustellung') }} + + + {{ __('alle Quellen') }} + + + {{ $returnPath ? __('Rückläufer landen im Bounce-Postfach') : __('MAIL_RETURN_PATH nicht gesetzt') }} + +
+ + @if ($unparsed->isNotEmpty()) +
+
+ {{ __('Nicht lesbare Rückläufer') }} + {{ __('liegen im Postfach unter „Unparsed“') }} +
+
    + @foreach ($unparsed as $event) +
  • + {{ $event->occurred_at?->format('d.m. H:i') }} + {{ Str::after($event->description, 'Unparsed“: ') }} +
  • + @endforeach +
+
+ @endif + + @can('users:manage') +
+
+ {{ __('Adresse sperren') }} +
+
+ + + + + + + + + {{ __('Sperren') }} +
+
+ @endcan + +
+
+ {{ __('Adressen') }} + {{ __(':count Einträge', ['count' => $suppressions->total()]) }} +
+
+ + + + + + +
+ + + {{ __('Adresse') }} + {{ __('Grund') }} + {{ __('Rückläufer') }} + {{ __('Zuletzt') }} + {{ __('Status') }} + {{ __('Aktion') }} + + + @forelse ($suppressions as $suppression) + + + {{ $suppression->email }} + @if ($suppression->note) + {{ $suppression->note }} + @endif + + + {{ $suppression->reason->label() }} + {{ $suppression->source }} + + + {{ $suppression->hard_bounce_count }} {{ __('hart') }} · {{ $suppression->soft_bounce_count }} {{ __('weich') }} + + + {{ ($suppression->last_bounced_at ?? $suppression->suppressed_at)?->format('d.m.Y H:i') ?? '—' }} + + + @if ($suppression->isActive()) + {{ __('gesperrt') }} + @else + {{ __('freigegeben') }} + @endif + + + @can('users:manage') + @if ($suppression->isActive()) + + {{ __('Freigeben') }} + + @endif + @endcan + + + @empty + + +
+ {{ __('Keine Adressen in dieser Ansicht.') }} +
+
+
+ @endforelse +
+
+ @if ($suppressions->hasPages()) +
{{ $suppressions->links() }}
+ @endif +
+
diff --git a/resources/views/web/newsroom-claim.blade.php b/resources/views/web/newsroom-claim.blade.php index 1e31dccf..95b5c20a 100644 --- a/resources/views/web/newsroom-claim.blade.php +++ b/resources/views/web/newsroom-claim.blade.php @@ -31,7 +31,12 @@
@enderror - @if ($recipients->isEmpty() || session('newsroom-claim-none')) + @if ($unreachable || session('newsroom-claim-unreachable')) + {{-- Bounce-Suppression (1b.6): Adresse hinterlegt, aber tot. --}} +

+ {{ __('static.newsroom_claim.intro_unreachable', ['company' => $company->name]) }} +

+ @elseif ($recipients->isEmpty() || session('newsroom-claim-none'))

{{ __('static.newsroom_claim.intro_no_email', ['company' => $company->name]) }}

diff --git a/routes/admin.php b/routes/admin.php index 4bf61175..ff79025d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/MailSuppressionAdminTest.php b/tests/Feature/MailSuppressionAdminTest.php new file mode 100644 index 00000000..a06f4f38 --- /dev/null +++ b/tests/Feature/MailSuppressionAdminTest.php @@ -0,0 +1,79 @@ +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'); +});