admin: Verlauf protokolliert jede versendete E-Mail

Neuer Listener RecordMailActivity am MessageSent-Event erfasst zentral
alle Versandwege (Mailables, Notifications, Fortify, Queue-Mails beim
realen Versand im Worker). Neuer ActivityType "E-Mail" mit Empfänger,
Betreff und Mailable-Klasse in den Metadaten; bekannte Empfänger werden
mit ihrem User verknüpft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-21 09:13:43 +00:00
parent e59e1320a3
commit 33e84afa2a
6 changed files with 165 additions and 74 deletions

View file

@ -20,6 +20,7 @@ enum ActivityType: string
case TerminationRequest = 'termination_request';
case NewsletterSubscription = 'newsletter_subscription';
case SubscriptionChange = 'subscription_change';
case MailSent = 'mail_sent';
public function label(): string
{
@ -35,6 +36,7 @@ enum ActivityType: string
self::TerminationRequest => 'Kündigung',
self::NewsletterSubscription => 'Newsletter',
self::SubscriptionChange => 'Abo',
self::MailSent => 'E-Mail',
};
}
@ -50,6 +52,7 @@ enum ActivityType: string
self::BookingPaid, self::CreditTopup => 'amber',
self::ContactRequest, self::NewsletterSubscription => 'cyan',
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red',
self::MailSent => 'purple',
};
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Listeners;
use App\Enums\ActivityType;
use App\Models\User;
use App\Services\ActivityLog;
use Illuminate\Mail\Events\MessageSent;
/**
* Protokolliert jede tatsächlich versendete E-Mail im Aktivitäts-Verlauf.
*
* Hängt zentral am MessageSent-Event des Mailers und erfasst damit alle
* Versandwege (Mailables, Notifications, Fortify). Bei Queue-Mails feuert
* das Event erst beim realen Versand im Worker — der Eintrag entsteht also
* zum echten Versandzeitpunkt.
*
* Die Methode heißt bewusst NICHT `handle`: Laravels Event-Discovery würde
* sie sonst zusätzlich zur manuellen Verdrahtung im AppServiceProvider
* registrieren (doppelte Einträge).
*/
class RecordMailActivity
{
public function __construct(private readonly ActivityLog $activityLog) {}
public function recordSent(MessageSent $event): void
{
$recipients = array_values(array_map(
fn ($address): string => $address->getAddress(),
$event->message->getTo() ?? [],
));
if ($recipients === []) {
return;
}
$subject = (string) $event->message->getSubject();
$user = User::query()->whereIn('email', $recipients)->first();
$this->activityLog->record(
ActivityType::MailSent,
sprintf(
'E-Mail „%s" an %s versendet.',
$subject !== '' ? $subject : __('(ohne Betreff)'),
implode(', ', $recipients),
),
user: $user,
actorLabel: $user?->name ?? $recipients[0],
meta: array_filter([
'to' => $recipients,
'subject' => $subject,
'mailable' => $event->data['__laravel_mailable'] ?? null,
'notification' => $event->data['__laravel_notification'] ?? null,
], fn ($value): bool => $value !== null && $value !== ''),
);
}
}

View file

@ -11,6 +11,7 @@ use App\Http\Middleware\LogSlowAdminRequests;
use App\Http\Middleware\SetEdition;
use App\Listeners\ActivateUserAfterVerification;
use App\Listeners\RecordAuthActivity;
use App\Listeners\RecordMailActivity;
use App\Listeners\SyncCompanyMembershipsOnLogin;
use App\Models\AdminPreset;
use App\Models\Category;
@ -38,6 +39,7 @@ use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\DB;
@ -129,6 +131,9 @@ class AppServiceProvider extends ServiceProvider
Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']);
Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']);
// Jede tatsächlich versendete E-Mail landet ebenfalls im Verlauf.
Event::listen(MessageSent::class, [RecordMailActivity::class, 'recordSent']);
// Framework-Auth-Mails (Passwort-Reset, E-Mail-Verifizierung) im
// pressekonto-CI rendern statt im Laravel-Standard-Markdown-Theme —
// gleiches Layout wie alle übrigen System-Mails (emails/layout).

View file

@ -96,8 +96,8 @@
</flux:navlist.group>
<flux:navlist.group :heading="__('Portale')" class="grid mb-4">
<flux:navlist.item icon="arrow-top-right-on-square"
:href="config('domains.domain_presseecho_url')" target="_blank">
<flux:navlist.item icon="arrow-top-right-on-square" :href="config('domains.domain_presseecho_url')"
target="_blank">
{{ __('presseecho') }}
</flux:navlist.item>
<flux:navlist.item icon="arrow-top-right-on-square"
@ -109,13 +109,78 @@
{{-- Content Management (Admin/Editor) --}}
@if ($canAdmin)
<flux:navlist.group :heading="__('Content')" class="grid mb-4">
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
<flux:navlist.item icon="newspaper"
:href="route('admin.press-releases.index', $reviewCount > 0 ? ['status' => 'review'] : [])"
:current="request()->routeIs('admin.press-releases.*')"
:badge="$reviewCount > 0 ? $reviewCount : null" badge-color="yellow" wire:navigate>
{{ __('Pressemitteilungen') }}
</flux:navlist.item>
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')"
:current="request()->routeIs('admin.companies.*')" wire:navigate>
{{ __('Firmen') }}
</flux:navlist.item>
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')"
:current="request()->routeIs('admin.contacts.*')" wire:navigate>
{{ __('Kontakte') }}
</flux:navlist.item>
@can('users:manage')
<flux:navlist.item icon="users" :href="route('admin.users.index')"
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
<flux:navlist.group :heading="__('Management')" class="grid mb-4">
@can('compliance:manage')
<flux:navlist.item icon="queue-list" :href="route('admin.reports.activity')"
:current="request()->routeIs('admin.reports.activity')" wire:navigate>
{{ __('Verlauf') }}
</flux:navlist.item>
{{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}}
<flux:navlist.item icon="chat-bubble-left-right" :href="route('admin.contact-messages.index')"
:current="request()->routeIs('admin.contact-messages.*')"
:badge="$contactOpenCount > 0 ? $contactOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anliegen') }}
</flux:navlist.item>
{{-- Recht & Compliance --}}
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
:current="request()->routeIs('admin.legal-requests.*')"
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anfragen') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
{{-- Billing --}}
@can('billing:manage')
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
{{ __('Legacy Rechnungen') }}
</flux:navlist.item>
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')"
:current="request()->routeIs('admin.payments.index')" wire:navigate>
{{ __('Zahlungen') }}
</flux:navlist.item>
<flux:navlist.item icon="rectangle-stack" :href="route('admin.payments.plans')"
:current="request()->routeIs('admin.payments.plans')" wire:navigate>
{{ __('Tarife & Pakete') }}
</flux:navlist.item>
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')"
:current="request()->routeIs('admin.coupons.*')" wire:navigate>
{{ __('Gutscheine') }}
</flux:navlist.item>
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')"
:current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
{{ __('Newsletter Sync') }}
</flux:navlist.item>
</flux:navlist.group>
@endcan
{{-- Content --}}
<flux:navlist.group :heading="__('Content')" class="grid mb-4">
@can('categories:manage')
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
:current="request()->routeIs('admin.categories.*')" wire:navigate>
@ -129,77 +194,13 @@
</flux:navlist.item>
@endcan
</flux:navlist.group>
{{-- CRM --}}
<flux:navlist.group :heading="__('CRM')" class="grid mb-4">
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')"
:current="request()->routeIs('admin.companies.*')" wire:navigate>
{{ __('Firmen') }}
</flux:navlist.item>
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')"
:current="request()->routeIs('admin.contacts.*')" wire:navigate>
{{ __('Kontakte') }}
</flux:navlist.item>
</flux:navlist.group>
@can('compliance:manage')
{{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}}
<flux:navlist.group :heading="__('Kontakt')" class="grid mb-4">
<flux:navlist.item icon="chat-bubble-left-right" :href="route('admin.contact-messages.index')"
:current="request()->routeIs('admin.contact-messages.*')"
:badge="$contactOpenCount > 0 ? $contactOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anliegen') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Recht & Compliance --}}
<flux:navlist.group :heading="__('Recht & Compliance')" class="grid mb-4">
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
:current="request()->routeIs('admin.legal-requests.*')"
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anfragen') }}
</flux:navlist.item>
</flux:navlist.group>
@endcan
{{-- Billing --}}
@can('billing:manage')
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
{{ __('Legacy Rechnungen') }}
</flux:navlist.item>
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')"
:current="request()->routeIs('admin.payments.index')" wire:navigate>
{{ __('Zahlungen') }}
</flux:navlist.item>
<flux:navlist.item icon="rectangle-stack" :href="route('admin.payments.plans')"
:current="request()->routeIs('admin.payments.plans')" wire:navigate>
{{ __('Tarife & Pakete') }}
</flux:navlist.item>
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')"
:current="request()->routeIs('admin.coupons.*')" wire:navigate>
{{ __('Gutscheine') }}
</flux:navlist.item>
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')"
:current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
{{ __('Newsletter Sync') }}
</flux:navlist.item>
</flux:navlist.group>
@endcan
{{-- Administration --}}
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
<flux:navlist.group :heading="__('Settings')" class="grid mb-4">
<flux:navlist.item icon="cog" :href="route('admin.presets.index')"
:current="request()->routeIs('admin.presets.*')" wire:navigate>
{{ __('Voreinstellungen') }}
</flux:navlist.item>
@can('users:manage')
<flux:navlist.item icon="users" :href="route('admin.users.index')"
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
@endcan
@can('roles:manage')
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
:current="request()->routeIs('admin.roles.*')" wire:navigate>
@ -210,10 +211,7 @@
{{-- Reports --}}
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
<flux:navlist.item icon="queue-list" :href="route('admin.reports.activity')"
:current="request()->routeIs('admin.reports.activity')" wire:navigate>
{{ __('Verlauf') }}
</flux:navlist.item>
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
:current="request()->routeIs('admin.reports.slow-requests')" wire:navigate>
{{ __('Performance') }}

View file

@ -65,7 +65,7 @@ new #[Layout('components.layouts.app'), Title('Verlauf')] class extends Componen
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Reports')"
:title="__('Verlauf')"
:description="__('Chronologisches Protokoll der Systemereignisse: Registrierungen, Anmeldungen, Pressemitteilungen, Zahlungen und Formular-Eingänge.')"
:description="__('Chronologisches Protokoll der Systemereignisse: Registrierungen, Anmeldungen, Pressemitteilungen, Zahlungen, Formular-Eingänge und versendete E-Mails.')"
/>
{{-- ============== FILTER-PANEL ============== --}}

View file

@ -11,6 +11,7 @@ use App\Models\WithdrawalRequest;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Livewire\Volt\Volt;
@ -111,3 +112,30 @@ test('the activity report page lists events and filters by type', function () {
->assertSee('Kontaktanfrage „Testthema“')
->assertDontSee('hat sich angemeldet');
});
test('a sent mail is recorded and linked to the recipient user', function () {
$user = User::factory()->create(['email' => 'empfaenger@example.com']);
Mail::raw('Inhalt', function ($message) {
$message->to('empfaenger@example.com')->subject('Willkommen bei pressekonto');
});
$event = ActivityEvent::query()->where('type', ActivityType::MailSent->value)->sole();
expect($event->description)->toContain('Willkommen bei pressekonto')
->and($event->description)->toContain('empfaenger@example.com')
->and($event->user_id)->toBe($user->id)
->and($event->meta['to'])->toBe(['empfaenger@example.com'])
->and($event->meta['subject'])->toBe('Willkommen bei pressekonto');
});
test('a mail to an unknown address is recorded with the address as actor label', function () {
Mail::raw('Inhalt', function ($message) {
$message->to('extern@example.com')->subject('Externe Info');
});
$event = ActivityEvent::query()->where('type', ActivityType::MailSent->value)->sole();
expect($event->user_id)->toBeNull()
->and($event->actor_label)->toBe('extern@example.com');
});