admin: internes Newsletter-Modul mit Filtern, Protokoll und Abmeldung

Neues Modul unter Admin ▸ Newsletter (nur users:manage): Kampagnen mit
Betreff + leicht formatiertem Text (Purifier-Profil newsletter: fett/
kursiv, Listen, Links) im zentralen Mail-Layout. Empfänger-Filter auf
der User-Datenbank: Portal, Legacy-Vertrag (Bestandsschutz), Login
innerhalb/außerhalb X Monaten, Sortierung nach Login/Erstellung — mit
Live-Zähler und Vorschau im Editor.

Versand friert die Empfängerliste als Protokoll ein (newsletter_
campaign_recipients) und stellt pro Empfänger einen Queue-Job ein;
der Opt-out wird im Worker erneut geprüft. Jede Mail trägt den
signierten One-Click-Abmelde-Link (neuer EmailNotificationType::
Newsletter, bestehende Unsubscribe-Infrastruktur inkl. RFC 8058);
die Abmeldung greift sofort für alle künftigen Auswahlen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-21 09:26:57 +00:00
parent 33e84afa2a
commit f327853541
19 changed files with 1560 additions and 5 deletions

View file

@ -5,11 +5,11 @@ namespace App\Enums;
/**
* Abbestellbare Benachrichtigungs-Mails (Opt-out je Typ).
*
* Umfasst bewusst nur Benachrichtigungen zum PM-Lebenszyklus — keine
* System-/Transaktionsmails (Passwort-Reset, Magic-Link, Kündigungs-/
* Widerrufsbestätigungen), die rechtlich bzw. funktional zwingend sind.
* Jeder Case entspricht genau einer Mailable-Klasse; vor dem Versand wird
* geprüft, ob der Empfänger den Typ abbestellt hat
* Umfasst Benachrichtigungen zum PM-Lebenszyklus sowie den internen
* Newsletter — keine System-/Transaktionsmails (Passwort-Reset, Magic-Link,
* Kündigungs-/Widerrufsbestätigungen), die rechtlich bzw. funktional
* zwingend sind. Jeder Case entspricht genau einer Mailable-Klasse; vor dem
* Versand wird geprüft, ob der Empfänger den Typ abbestellt hat
* (User::wantsEmailNotification()).
*/
enum EmailNotificationType: string
@ -17,6 +17,7 @@ enum EmailNotificationType: string
case PressReleasePublished = 'press_release_published';
case PressReleaseRejected = 'press_release_rejected';
case PressReleaseDepublished = 'press_release_depublished';
case Newsletter = 'newsletter';
public function label(): string
{
@ -24,6 +25,7 @@ enum EmailNotificationType: string
self::PressReleasePublished => 'Pressemitteilung veröffentlicht',
self::PressReleaseRejected => 'Pressemitteilung abgelehnt',
self::PressReleaseDepublished => 'Pressemitteilung depubliziert',
self::Newsletter => 'Newsletter & Plattform-Infos',
};
}
@ -36,6 +38,7 @@ enum EmailNotificationType: string
self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.',
self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde — inklusive Ablehnungsgrund.',
self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.',
self::Newsletter => 'Gelegentliche Informationen zur Plattform: Neuigkeiten, neue Funktionen und wichtige Hinweise zu Ihrem Konto.',
};
}
@ -48,6 +51,7 @@ enum EmailNotificationType: string
self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.',
self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Ablehnung Ihrer eingereichten Pressemitteilungen informiert.',
self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.',
self::Newsletter => 'Sie haben ein Benutzerkonto bei pressekonto bzw. den Portalen presseecho und businessportal24 und erhalten daher gelegentlich Informationen zur Plattform.',
};
}
@ -60,6 +64,7 @@ enum EmailNotificationType: string
self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen',
self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen',
self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen',
self::Newsletter => 'Newsletter und Plattform-Informationen',
};
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Enums;
/**
* Lebenszyklus einer internen Newsletter-Kampagne (Admin ▸ Newsletter).
* Draft ist editierbar; mit dem Versandstart wird die Empfängerliste
* eingefroren (Protokoll) und die Kampagne unveränderlich.
*/
enum NewsletterCampaignStatus: string
{
case Draft = 'draft';
case Sending = 'sending';
case Sent = 'sent';
public function label(): string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Sending => 'Wird versendet',
self::Sent => 'Versendet',
};
}
/**
* Flux-Badge-Farbe in den Kampagnen-Übersichten.
*/
public function badgeColor(): string
{
return match ($this) {
self::Draft => 'zinc',
self::Sending => 'amber',
self::Sent => 'green',
};
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Enums;
/**
* Versandstatus eines einzelnen Newsletter-Empfängers (Protokoll).
* SkippedOptOut greift, wenn sich der User zwischen Versandstart und
* tatsächlichem Versand im Worker abgemeldet hat.
*/
enum NewsletterRecipientStatus: string
{
case Pending = 'pending';
case Sent = 'sent';
case Failed = 'failed';
case SkippedOptOut = 'skipped_opt_out';
public function label(): string
{
return match ($this) {
self::Pending => 'Ausstehend',
self::Sent => 'Versendet',
self::Failed => 'Fehlgeschlagen',
self::SkippedOptOut => 'Übersprungen (abgemeldet)',
};
}
/**
* Flux-Badge-Farbe in der Protokoll-Tabelle.
*/
public function badgeColor(): string
{
return match ($this) {
self::Pending => 'zinc',
self::Sent => 'green',
self::Failed => 'red',
self::SkippedOptOut => 'amber',
};
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace App\Jobs;
use App\Enums\EmailNotificationType;
use App\Enums\NewsletterCampaignStatus;
use App\Enums\NewsletterRecipientStatus;
use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Models\NewsletterCampaignRecipient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* Versendet eine Newsletter-Kampagne an genau einen Empfänger (Protokoll-
* Zeile). Pro Empfänger ein Job, damit Fehler einzeln nachvollziehbar
* bleiben und Retries keinen Doppelversand an die übrige Liste auslösen.
*
* Der Opt-out wird hier — zum tatsächlichen Versandzeitpunkt — erneut
* geprüft: Wer sich nach Versandstart abmeldet, wird übersprungen.
*/
class SendNewsletterCampaignMail implements ShouldQueue
{
use Queueable;
public function __construct(public readonly int $recipientId) {}
public function handle(): void
{
$recipient = NewsletterCampaignRecipient::query()
->with(['campaign', 'user'])
->find($this->recipientId);
if ($recipient === null || $recipient->status !== NewsletterRecipientStatus::Pending) {
return;
}
$campaign = $recipient->campaign;
$user = $recipient->user;
if ($user === null || ! $user->is_active) {
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', 'Benutzer gelöscht oder deaktiviert.');
return;
}
if (! $user->wantsEmailNotification(EmailNotificationType::Newsletter)) {
$this->conclude($recipient, NewsletterRecipientStatus::SkippedOptOut, 'skipped_count');
return;
}
Mail::to($recipient->email)->send(new NewsletterCampaignMail($campaign, $user));
$this->conclude($recipient, NewsletterRecipientStatus::Sent, 'sent_count');
}
/**
* Endgültiger Fehlschlag nach allen Versuchen des Workers.
*/
public function failed(\Throwable $exception): void
{
$recipient = NewsletterCampaignRecipient::query()->find($this->recipientId);
if ($recipient === null || $recipient->status !== NewsletterRecipientStatus::Pending) {
return;
}
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', $exception->getMessage());
}
private function conclude(
NewsletterCampaignRecipient $recipient,
NewsletterRecipientStatus $status,
string $campaignCounter,
?string $error = null,
): void {
$recipient->update([
'status' => $status->value,
'error' => $error !== null ? Str::limit($error, 480) : null,
'sent_at' => $status === NewsletterRecipientStatus::Sent ? now() : null,
]);
$campaign = NewsletterCampaign::query()->find($recipient->newsletter_campaign_id);
if ($campaign === null) {
return;
}
$campaign->increment($campaignCounter);
$hasPending = $campaign->recipients()
->where('status', NewsletterRecipientStatus::Pending->value)
->exists();
if (! $hasPending && $campaign->status === NewsletterCampaignStatus::Sending) {
$campaign->update([
'status' => NewsletterCampaignStatus::Sent->value,
'sent_at' => now(),
]);
}
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Mail;
use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\NewsletterCampaign;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Eine interne Newsletter-Kampagne an einen einzelnen Empfänger.
* Der Kampagnen-Text (leichtes HTML) wird ins zentrale Mail-Layout
* eingesetzt; der Abmelde-Link kommt aus dem Unsubscribable-Trait
* (signiert, One-Click, RFC 8058).
*/
class NewsletterCampaignMail extends Mailable
{
use Queueable, SerializesModels, Unsubscribable;
public function __construct(
public readonly NewsletterCampaign $campaign,
public readonly User $user,
) {
$this->configureUnsubscribe($user, EmailNotificationType::Newsletter);
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->campaign->subject,
);
}
public function content(): Content
{
return new Content(view: 'emails.newsletter.campaign');
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Models;
use App\Enums\NewsletterCampaignStatus;
use App\Services\Newsletter\CampaignRecipients;
use Database\Factories\NewsletterCampaignFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\HtmlString;
use Mews\Purifier\Facades\Purifier;
/**
* Interne Newsletter-Kampagne (Admin ▸ Newsletter): Betreff + leicht
* formatierter Text an gefilterte Bestands-User. `filters` hält die
* Empfänger-Auswahl als Snapshot ({@see CampaignRecipients}),
* die Empfänger-Zeilen bilden das Versand-Protokoll.
*/
class NewsletterCampaign extends Model
{
/** @use HasFactory<NewsletterCampaignFactory> */
use HasFactory;
protected $fillable = [
'subject',
'body',
'filters',
'status',
'created_by_user_id',
'recipients_total',
'sent_count',
'failed_count',
'skipped_count',
'sent_at',
];
protected function casts(): array
{
return [
'filters' => 'array',
'status' => NewsletterCampaignStatus::class,
'sent_at' => 'datetime',
];
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function recipients(): HasMany
{
return $this->hasMany(NewsletterCampaignRecipient::class);
}
public function isDraft(): bool
{
return $this->status === NewsletterCampaignStatus::Draft;
}
/**
* Versandfertiger, gesäuberter Inhalt: nur leichtes HTML laut
* Purifier-Profil `newsletter` (fett/kursiv, Listen, Links).
*/
public function sanitizedBody(): HtmlString
{
return new HtmlString((string) Purifier::clean($this->body ?? '', 'newsletter'));
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Enums\NewsletterRecipientStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Protokoll-Eintrag: ein Empfänger einer Newsletter-Kampagne. Die E-Mail
* wird als Snapshot gespeichert, damit das Protokoll auch nach Adress-
* Änderung oder User-Löschung nachvollziehbar bleibt.
*/
class NewsletterCampaignRecipient extends Model
{
protected $fillable = [
'newsletter_campaign_id',
'user_id',
'email',
'status',
'error',
'sent_at',
];
protected function casts(): array
{
return [
'status' => NewsletterRecipientStatus::class,
'sent_at' => 'datetime',
];
}
public function campaign(): BelongsTo
{
return $this->belongsTo(NewsletterCampaign::class, 'newsletter_campaign_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -0,0 +1,139 @@
<?php
namespace App\Services\Newsletter;
use App\Enums\EmailNotificationType;
use App\Enums\UserPaymentOptionStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
/**
* Empfänger-Auswahl für interne Newsletter-Kampagnen (Admin ▸ Newsletter).
*
* Grundmenge sind alle aktiven User mit E-Mail-Adresse, die den Newsletter
* nicht abbestellt haben. Darauf setzen die Kampagnen-Filter auf:
* Portal, Legacy-Vertrag (Bestandsschutz-Zahlungsoption) und der
* Login-Zeitraum (eingeloggt innerhalb X Monaten bzw. dessen Komplement:
* länger nicht oder noch nie eingeloggt).
*/
class CampaignRecipients
{
public const LOGIN_ANY = 'any';
public const LOGIN_WITHIN = 'within';
public const LOGIN_NOT_WITHIN = 'not_within';
public const SORT_FIELDS = ['created_at', 'last_login_at'];
/**
* Filter-Defaults; unbekannte Werte fallen auf diese zurück.
*
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
*/
public static function defaultFilters(): array
{
return [
'portal' => '',
'legacy_contract' => false,
'login' => self::LOGIN_ANY,
'login_months' => 6,
'sort' => 'created_at',
'direction' => 'desc',
];
}
/**
* Bringt einen (z. B. aus der UI oder dem JSON-Snapshot kommenden)
* Filter-Satz in eine validierte, vollständige Form.
*
* @param array<string, mixed> $filters
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
*/
public function normalize(array $filters): array
{
$defaults = self::defaultFilters();
$portal = (string) ($filters['portal'] ?? '');
$login = (string) ($filters['login'] ?? $defaults['login']);
$months = (int) ($filters['login_months'] ?? $defaults['login_months']);
$sort = (string) ($filters['sort'] ?? $defaults['sort']);
$direction = (string) ($filters['direction'] ?? $defaults['direction']);
return [
'portal' => in_array($portal, ['presseecho', 'businessportal24', 'both'], true) ? $portal : '',
'legacy_contract' => (bool) ($filters['legacy_contract'] ?? false),
'login' => in_array($login, [self::LOGIN_ANY, self::LOGIN_WITHIN, self::LOGIN_NOT_WITHIN], true) ? $login : self::LOGIN_ANY,
'login_months' => max(1, min(120, $months)),
'sort' => in_array($sort, self::SORT_FIELDS, true) ? $sort : $defaults['sort'],
'direction' => $direction === 'asc' ? 'asc' : 'desc',
];
}
/**
* Menschenlesbare Zusammenfassung eines Filter-Satzes für die Admin-UI.
*
* @param array<string, mixed> $filters
* @return list<string>
*/
public function describe(array $filters): array
{
$filters = $this->normalize($filters);
$parts = ['Aktive User mit E-Mail, Newsletter nicht abbestellt'];
if ($filters['portal'] !== '') {
$parts[] = 'Portal: '.$filters['portal'];
}
if ($filters['legacy_contract']) {
$parts[] = 'Nur User mit Legacy-Vertrag (Bestandsschutz)';
}
if ($filters['login'] === self::LOGIN_WITHIN) {
$parts[] = sprintf('In den letzten %d Monaten eingeloggt', $filters['login_months']);
} elseif ($filters['login'] === self::LOGIN_NOT_WITHIN) {
$parts[] = sprintf('Seit %d Monaten nicht eingeloggt (oder noch nie)', $filters['login_months']);
}
$parts[] = sprintf(
'Sortiert nach %s (%s)',
$filters['sort'] === 'last_login_at' ? 'letztem Login' : 'Erstellungsdatum',
$filters['direction'] === 'asc' ? 'älteste zuerst' : 'neueste zuerst',
);
return $parts;
}
/**
* @param array<string, mixed> $filters
* @return Builder<User>
*/
public function query(array $filters): Builder
{
$filters = $this->normalize($filters);
$loginCutoff = now()->subMonths($filters['login_months']);
return User::query()
->where('is_active', true)
->whereNotNull('email')
->whereDoesntHave('emailNotificationOptOuts', fn (Builder $query) => $query
->where('type', EmailNotificationType::Newsletter->value))
->when($filters['portal'] !== '', fn (Builder $query) => $query
->where('portal', $filters['portal']))
->when($filters['legacy_contract'], fn (Builder $query) => $query
->whereHas('userPaymentOptions', fn (Builder $inner) => $inner
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->where(fn (Builder $until) => $until
->whereNull('grandfathered_until')
->orWhere('grandfathered_until', '>=', now()))))
->when($filters['login'] === self::LOGIN_WITHIN, fn (Builder $query) => $query
->where('last_login_at', '>=', $loginCutoff))
->when($filters['login'] === self::LOGIN_NOT_WITHIN, fn (Builder $query) => $query
->where(fn (Builder $inner) => $inner
->whereNull('last_login_at')
->orWhere('last_login_at', '<', $loginCutoff)))
->orderBy($filters['sort'], $filters['direction'])
->orderBy('id');
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Services\Newsletter;
use App\Enums\NewsletterCampaignStatus;
use App\Enums\NewsletterRecipientStatus;
use App\Jobs\SendNewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Models\User;
/**
* Startet den Versand einer Newsletter-Kampagne: friert die Empfängerliste
* laut Filter als Protokoll-Zeilen ein (Status pending) und stellt pro
* Empfänger einen Queue-Job ein. Der tatsächliche Versand läuft im Worker;
* dort wird der Opt-out-Stand erneut geprüft, damit eine Abmeldung zwischen
* Versandstart und Versand sofort greift.
*/
class CampaignSender
{
public function __construct(private readonly CampaignRecipients $recipients) {}
/**
* @return int Anzahl der eingeplanten Empfänger.
*/
public function start(NewsletterCampaign $campaign): int
{
if (! $campaign->isDraft()) {
return 0;
}
$total = 0;
$this->recipients->query($campaign->filters ?? [])
->select(['id', 'email'])
->chunkById(500, function ($users) use ($campaign, &$total): void {
$rows = $users->map(fn (User $user): array => [
'newsletter_campaign_id' => $campaign->id,
'user_id' => $user->id,
'email' => $user->email,
'status' => NewsletterRecipientStatus::Pending->value,
'created_at' => now(),
'updated_at' => now(),
])->all();
$campaign->recipients()->insert($rows);
$total += count($rows);
});
if ($total === 0) {
return 0;
}
$campaign->update([
'status' => NewsletterCampaignStatus::Sending->value,
'recipients_total' => $total,
]);
$campaign->recipients()
->select(['id'])
->chunkById(500, function ($recipients): void {
foreach ($recipients as $recipient) {
SendNewsletterCampaignMail::dispatch($recipient->id);
}
});
return $total;
}
}

View file

@ -51,6 +51,23 @@ return [
'tel' => true,
],
],
/*
* Interner Newsletter (Admin ▸ Newsletter): leichter Text mit
* fett/kursiv, Listen und Links — passend zur reduzierten
* flux:editor-Toolbar des Kampagnen-Editors. Kein Heading/Bild/
* Blockquote; das Grundgerüst kommt aus dem Mail-Layout.
*/
'newsletter' => [
'HTML.Doctype' => 'HTML 4.01 Transitional',
'HTML.Allowed' => 'p,br,strong,em,b,i,u,ul,ol,li,a[href|title]',
'AutoFormat.AutoParagraph' => true,
'AutoFormat.RemoveEmpty' => true,
'URI.AllowedSchemes' => [
'http' => true,
'https' => true,
'mailto' => true,
],
],
'test' => [
'Attr.EnableID' => 'true',
],

View file

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Enums\NewsletterCampaignStatus;
use App\Models\NewsletterCampaign;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<NewsletterCampaign>
*/
class NewsletterCampaignFactory extends Factory
{
public function definition(): array
{
return [
'subject' => fake()->sentence(6),
'body' => '<p>'.fake()->paragraph().'</p>',
'filters' => [],
'status' => NewsletterCampaignStatus::Draft->value,
];
}
public function sent(): static
{
return $this->state(fn (): array => [
'status' => NewsletterCampaignStatus::Sent->value,
'sent_at' => now(),
]);
}
}

View file

@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Internes Newsletter-Modul (Admin ▸ Newsletter): Kampagnen an Bestands-User
* auf Basis der hinterlegten E-Mail-Adressen, mit Empfänger-Protokoll.
*
* newsletter_campaigns: Betreff + leicht formatierter HTML-Text (Purifier-
* Profil `newsletter`) + Empfänger-Filter als JSON-Snapshot.
* newsletter_campaign_recipients: ein Protokoll-Eintrag pro Empfänger mit
* Versandstatus — welche Mail an wen ging, wann, und ob sie z. B. wegen
* Abmeldung übersprungen wurde.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('newsletter_campaigns', function (Blueprint $table): void {
$table->id();
$table->string('subject', 190);
$table->text('body');
$table->json('filters')->nullable();
$table->string('status', 20)->default('draft')->index();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->unsignedInteger('recipients_total')->default(0);
$table->unsignedInteger('sent_count')->default(0);
$table->unsignedInteger('failed_count')->default(0);
$table->unsignedInteger('skipped_count')->default(0);
$table->timestamp('sent_at')->nullable();
$table->timestamps();
});
Schema::create('newsletter_campaign_recipients', function (Blueprint $table): void {
$table->id();
$table->foreignId('newsletter_campaign_id')->constrained('newsletter_campaigns')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('email');
$table->string('status', 20)->default('pending');
$table->string('error', 500)->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamps();
$table->index(['newsletter_campaign_id', 'status'], 'newsletter_recipients_campaign_status_idx');
});
}
public function down(): void
{
Schema::dropIfExists('newsletter_campaign_recipients');
Schema::dropIfExists('newsletter_campaigns');
}
};

View file

@ -129,6 +129,10 @@
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
<flux:navlist.item icon="paper-airplane" :href="route('admin.newsletter.campaigns.index')"
:current="request()->routeIs('admin.newsletter.campaigns.*')" wire:navigate>
{{ __('Newsletter') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>

View file

@ -0,0 +1,7 @@
<x-emails.layout :title="$campaign->subject" :preheader="$campaign->subject">
<p>Hallo {{ $user->name }},</p>
{!! $campaign->sanitizedBody() !!}
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
</x-emails.layout>

View file

@ -0,0 +1,345 @@
<?php
use App\Enums\NewsletterCampaignStatus;
use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Services\Newsletter\CampaignRecipients;
use App\Services\Newsletter\CampaignSender;
use Flux\Flux;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Mews\Purifier\Facades\Purifier;
/*
* Kampagnen-Editor: Betreff + leicht formatierter Text (fett/kursiv, Listen,
* Links) und die Empfänger-Filter. Empfängerzahl und Vorschau aktualisieren
* live beim Ändern der Filter. Versand friert die Liste als Protokoll ein.
*/
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
{
#[Locked]
public ?int $id = null;
public string $subject = '';
public string $body = '';
public string $filterPortal = '';
public bool $filterLegacyContract = false;
public string $filterLogin = CampaignRecipients::LOGIN_ANY;
public int $filterLoginMonths = 6;
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public function mount(?int $id = null): void
{
$this->id = $id;
if ($id === null) {
return;
}
$campaign = NewsletterCampaign::query()->findOrFail($id);
if (! $campaign->isDraft()) {
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
return;
}
$filters = app(CampaignRecipients::class)->normalize($campaign->filters ?? []);
$this->subject = $campaign->subject;
$this->body = $campaign->body;
$this->filterPortal = $filters['portal'];
$this->filterLegacyContract = $filters['legacy_contract'];
$this->filterLogin = $filters['login'];
$this->filterLoginMonths = $filters['login_months'];
$this->sortField = $filters['sort'];
$this->sortDirection = $filters['direction'];
}
public function saveDraft(): void
{
$campaign = $this->persist();
Flux::toast(text: __('Entwurf gespeichert.'), variant: 'success');
if ($this->id === null) {
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
}
}
public function sendTest(): void
{
$campaign = $this->persist();
Mail::to(auth()->user()->email)->send(new NewsletterCampaignMail($campaign, auth()->user()));
Flux::toast(
text: __('Test-Mail an :email versendet.', ['email' => auth()->user()->email]),
variant: 'success',
);
if ($this->id === null) {
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
}
}
public function send(): void
{
$campaign = $this->persist();
$total = app(CampaignSender::class)->start($campaign);
if ($total === 0) {
Flux::toast(text: __('Keine Empfänger für die gewählten Filter — nichts versendet.'), variant: 'warning');
Flux::modal('confirm-campaign-send')->close();
return;
}
Flux::toast(
text: __(':count Mails eingeplant. Der Versand läuft über die Queue.', ['count' => $total]),
variant: 'success',
);
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
}
private function persist(): NewsletterCampaign
{
$this->validate([
'subject' => ['required', 'string', 'max:190'],
'body' => ['required', 'string', 'max:20000'],
]);
$attributes = [
'subject' => trim($this->subject),
// Bereits beim Speichern auf leichtes HTML reduzieren — der
// Entwurf enthält damit nie mehr, als die Mail rendern darf.
'body' => (string) Purifier::clean($this->body, 'newsletter'),
'filters' => $this->filters(),
];
if ($this->id !== null) {
$campaign = NewsletterCampaign::query()->findOrFail($this->id);
abort_unless($campaign->isDraft(), 403);
$campaign->update($attributes);
return $campaign;
}
$campaign = NewsletterCampaign::query()->create($attributes + [
'status' => NewsletterCampaignStatus::Draft->value,
'created_by_user_id' => auth()->id(),
]);
$this->id = $campaign->id;
return $campaign;
}
/**
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
*/
private function filters(): array
{
return app(CampaignRecipients::class)->normalize([
'portal' => $this->filterPortal,
'legacy_contract' => $this->filterLegacyContract,
'login' => $this->filterLogin,
'login_months' => $this->filterLoginMonths,
'sort' => $this->sortField,
'direction' => $this->sortDirection,
]);
}
public function with(): array
{
$recipients = app(CampaignRecipients::class);
$query = $recipients->query($this->filters());
return [
'recipientCount' => (clone $query)->count(),
'recipientPreview' => $query->limit(8)->get(['id', 'name', 'email', 'last_login_at', 'created_at']),
'filterSummary' => $recipients->describe($this->filters()),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Newsletter')"
:title="$id === null ? __('Neue Kampagne') : __('Kampagne bearbeiten')"
:description="__('Der Text wird in das zentrale Mail-Layout eingesetzt; jede Mail enthält automatisch den Abmelde-Link des Empfängers.')"
>
<x-slot:actions>
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
<div class="grid gap-6 xl:grid-cols-[1fr_380px]">
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
<flux:badge size="sm" color="zinc">{{ __('Entwurf') }}</flux:badge>
</div>
<div class="p-5 space-y-5">
<flux:field>
<flux:label>{{ __('Betreff') }}</flux:label>
<flux:input wire:model="subject" :placeholder="__('z. B. Ihr Presseportal ist umgezogen — das ist neu')" maxlength="190" />
<flux:error name="subject" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }}</flux:label>
<flux:description>
{{ __('Leichte Formatierung (fett, kursiv, Listen, Links). Anrede und Abmelde-Hinweis ergänzt das Mail-Layout automatisch.') }}
</flux:description>
<flux:editor wire:model="body"
toolbar="bold italic underline | bullet ordered | link | undo redo"
placeholder="{{ __('Hier den Newsletter-Text schreiben…') }}" />
<flux:error name="body" />
</flux:field>
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] pt-4">
<flux:button variant="filled" icon="document-check" wire:click="saveDraft">
{{ __('Als Entwurf speichern') }}
</flux:button>
<flux:button variant="filled" icon="beaker" wire:click="sendTest">
{{ __('Test-Mail an mich') }}
</flux:button>
<flux:spacer />
<flux:modal.trigger name="confirm-campaign-send">
<flux:button variant="primary" icon="paper-airplane">
{{ __('Jetzt senden') }}
</flux:button>
</flux:modal.trigger>
</div>
</div>
</article>
{{-- ============== EMPFÄNGER ============== --}}
<div class="space-y-6">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Empfänger-Filter') }}</span>
<flux:badge size="sm" color="blue">{{ number_format($recipientCount, 0, ',', '.') }} {{ __('Empfänger') }}</flux:badge>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model.live="filterPortal">
<option value="">{{ __('Alle Portale') }}</option>
<option value="presseecho">presseecho</option>
<option value="businessportal24">businessportal24</option>
<option value="both">{{ __('Beide (both)') }}</option>
</flux:select>
</flux:field>
<flux:field variant="inline">
<flux:checkbox wire:model.live="filterLegacyContract" />
<flux:label>{{ __('Nur User mit Legacy-Vertrag (Bestandsschutz)') }}</flux:label>
</flux:field>
<flux:field>
<flux:label>{{ __('Letzter Login') }}</flux:label>
<flux:select wire:model.live="filterLogin">
<option value="any">{{ __('Alle — unabhängig vom Login') }}</option>
<option value="within">{{ __('In den letzten X Monaten eingeloggt') }}</option>
<option value="not_within">{{ __('Seit X Monaten nicht eingeloggt (oder nie)') }}</option>
</flux:select>
</flux:field>
@if ($filterLogin !== 'any')
<flux:field>
<flux:label>{{ __('Zeitraum X in Monaten') }}</flux:label>
<flux:input type="number" min="1" max="120" wire:model.live.debounce.400ms="filterLoginMonths" />
<flux:description>{{ __('z. B. 6 oder 12 Monate') }}</flux:description>
</flux:field>
@endif
<div class="grid grid-cols-2 gap-3">
<flux:field>
<flux:label>{{ __('Sortierung') }}</flux:label>
<flux:select wire:model.live="sortField">
<option value="created_at">{{ __('Erstellungsdatum') }}</option>
<option value="last_login_at">{{ __('Letzter Login') }}</option>
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Reihenfolge') }}</flux:label>
<flux:select wire:model.live="sortDirection">
<option value="desc">{{ __('Neueste zuerst') }}</option>
<option value="asc">{{ __('Älteste zuerst') }}</option>
</flux:select>
</flux:field>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 text-[12px] text-[color:var(--color-ink-3)]">
<ul class="list-disc ps-4 space-y-0.5">
@foreach ($filterSummary as $line)
<li>{{ $line }}</li>
@endforeach
</ul>
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Vorschau der Empfänger') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('erste :count', ['count' => $recipientPreview->count()]) }}</span>
</div>
<div class="p-5 space-y-2">
@forelse ($recipientPreview as $user)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2">
<div class="text-[12.5px] font-semibold text-[color:var(--color-ink)] truncate">{{ $user->name }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">{{ $user->email }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] tabular-nums">
{{ __('Login:') }} {{ $user->last_login_at?->format('d.m.Y') ?? __('nie') }}
· {{ __('Erstellt:') }} {{ $user->created_at?->format('d.m.Y') ?? '—' }}
</div>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine Empfänger für die gewählten Filter.') }}
</div>
@endforelse
</div>
</article>
</div>
</div>
{{-- ============== SENDEN-MODAL ============== --}}
<flux:modal name="confirm-campaign-send" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Kampagne jetzt versenden?') }}</flux:heading>
<flux:subheading>
{{ __('Die Mail geht an :count Empfänger laut aktueller Filter. Die Empfängerliste wird als Protokoll eingefroren; der Versand läuft über die Queue und lässt sich nicht zurückholen.', ['count' => number_format($recipientCount, 0, ',', '.')]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="send">{{ __('Senden') }}</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,118 @@
<?php
use App\Models\NewsletterCampaign;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Newsletter')] class extends Component
{
use WithPagination;
public function with(): array
{
return [
'campaigns' => NewsletterCampaign::query()
->with('createdBy:id,name')
->orderByDesc('created_at')
->paginate(25),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Newsletter')"
:title="__('Newsletter-Kampagnen')"
:description="__('Interne Mailings an Bestands-User auf Basis der hinterlegten E-Mail-Adressen — mit Empfänger-Filtern, Versand-Protokoll und Abmelde-Link in jeder Mail.')"
>
<x-slot:actions>
<flux:button variant="primary" icon="plus" href="{{ route('admin.newsletter.campaigns.create') }}" wire:navigate>
{{ __('Neue Kampagne') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
{{-- ============== KAMPAGNEN ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Kampagnen') }}</span>
<span class="badge hub">{{ number_format($campaigns->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
<flux:table.column>{{ __('Betreff') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Empfänger') }}</flux:table.column>
<flux:table.column>{{ __('Angelegt von') }}</flux:table.column>
<flux:table.column></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($campaigns as $campaign)
<flux:table.row :key="$campaign->id">
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
{{ $campaign->created_at->format('d.m.Y H:i') }}
</span>
</flux:table.cell>
<flux:table.cell>
<a href="{{ route('admin.newsletter.campaigns.show', $campaign->id) }}" wire:navigate
class="text-[12.5px] font-semibold text-[color:var(--color-ink)] hover:underline">
{{ $campaign->subject }}
</a>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$campaign->status->badgeColor()">{{ $campaign->status->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
@if ($campaign->isDraft())
<span class="text-[12px] text-[color:var(--color-ink-3)]">—</span>
@else
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
{{ number_format($campaign->sent_count, 0, ',', '.') }} / {{ number_format($campaign->recipients_total, 0, ',', '.') }}
</span>
@if ($campaign->failed_count > 0)
<flux:badge size="sm" color="red">{{ $campaign->failed_count }} {{ __('Fehler') }}</flux:badge>
@endif
@if ($campaign->skipped_count > 0)
<flux:badge size="sm" color="amber">{{ $campaign->skipped_count }} {{ __('abgemeldet') }}</flux:badge>
@endif
@endif
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ $campaign->createdBy?->name ?? '—' }}</span>
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap text-right">
@if ($campaign->isDraft())
<flux:button size="xs" variant="filled" icon="pencil" href="{{ route('admin.newsletter.campaigns.edit', $campaign->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@else
<flux:button size="xs" variant="filled" icon="eye" href="{{ route('admin.newsletter.campaigns.show', $campaign->id) }}" wire:navigate>
{{ __('Protokoll') }}
</flux:button>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Kampagnen. Legen Sie die erste Kampagne an, um Bestands-User zu informieren.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if ($campaigns->hasPages())
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
{{ $campaigns->links() }}
</div>
@endif
</article>
</div>

View file

@ -0,0 +1,198 @@
<?php
use App\Enums\NewsletterRecipientStatus;
use App\Models\NewsletterCampaign;
use App\Services\Newsletter\CampaignRecipients;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
/*
* Kampagnen-Detail: Inhalt, Filter-Zusammenfassung und das Versand-Protokoll
* (welche Mail ging an welchen Empfänger, mit Status und Fehlern).
*/
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
{
use WithPagination;
#[Locked]
public int $id;
public string $statusFilter = '';
public function mount(int $id): void
{
$this->id = $id;
}
public function updated(string $property): void
{
if ($property === 'statusFilter') {
$this->resetPage();
}
}
public function with(): array
{
$campaign = NewsletterCampaign::query()
->with('createdBy:id,name')
->findOrFail($this->id);
$recipients = $campaign->recipients()
->with('user:id,name')
->when($this->statusFilter !== '', fn ($query) => $query->where('status', $this->statusFilter))
->orderBy('id')
->paginate(50);
return [
'campaign' => $campaign,
'recipients' => $recipients,
'filterSummary' => app(CampaignRecipients::class)->describe($campaign->filters ?? []),
'recipientStatuses' => NewsletterRecipientStatus::cases(),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Newsletter')"
:title="$campaign->subject"
:description="__('Angelegt am :date:by', ['date' => $campaign->created_at->format('d.m.Y H:i'), 'by' => $campaign->createdBy ? ' von '.$campaign->createdBy->name : ''])"
>
<x-slot:actions>
@if ($campaign->isDraft())
<flux:button variant="primary" icon="pencil" href="{{ route('admin.newsletter.campaigns.edit', $campaign->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Empfänger')" :value="number_format($campaign->recipients_total)">
<x-slot:meta>{{ $campaign->status->label() }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Versendet')" :value="number_format($campaign->sent_count)">
<x-slot:meta>{{ $campaign->sent_at ? __('abgeschlossen :date', ['date' => $campaign->sent_at->format('d.m.Y H:i')]) : __('läuft…') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Übersprungen')" :value="number_format($campaign->skipped_count)">
<x-slot:meta>{{ __('abgemeldet') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Fehlgeschlagen')" :value="number_format($campaign->failed_count)">
<x-slot:meta>{{ __('siehe Protokoll') }}</x-slot:meta>
</x-portal.stat-card>
</section>
<div class="grid gap-6 xl:grid-cols-2">
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
<flux:badge size="sm" :color="$campaign->status->badgeColor()">{{ $campaign->status->label() }}</flux:badge>
</div>
<div class="p-5">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mb-3">
{{ __('Betreff') }}: {{ $campaign->subject }}
</div>
<div class="pr-content max-w-[640px] text-[13px]">
{!! $campaign->sanitizedBody() !!}
</div>
</div>
</article>
{{-- ============== FILTER ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Empfänger-Auswahl') }}</span>
</div>
<div class="p-5">
<ul class="list-disc ps-4 space-y-1 text-[12.5px] text-[color:var(--color-ink-2)]">
@foreach ($filterSummary as $line)
<li>{{ $line }}</li>
@endforeach
</ul>
<p class="mt-3 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Die Liste wurde beim Versandstart eingefroren. Abmeldungen nach dem Start werden beim Versand übersprungen und im Protokoll vermerkt.') }}
</p>
</div>
</article>
</div>
{{-- ============== PROTOKOLL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Versand-Protokoll') }}</span>
<div class="flex items-center gap-2">
<flux:select wire:model.live="statusFilter" size="sm">
<option value="">{{ __('Alle Status') }}</option>
@foreach ($recipientStatuses as $status)
<option value="{{ $status->value }}">{{ $status->label() }}</option>
@endforeach
</flux:select>
<span class="badge hub">{{ number_format($recipients->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
</div>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Empfänger') }}</flux:table.column>
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Versendet am') }}</flux:table.column>
<flux:table.column>{{ __('Fehler') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($recipients as $recipient)
<flux:table.row :key="$recipient->id">
<flux:table.cell class="whitespace-nowrap">
@if ($recipient->user)
<a href="{{ route('admin.users.show', $recipient->user->id) }}" wire:navigate
class="text-[12.5px] text-[color:var(--color-ink)] hover:underline">
{{ $recipient->user->name }}
</a>
@else
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Gelöschter User') }}</span>
@endif
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $recipient->email }}</span>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$recipient->status->badgeColor()">{{ $recipient->status->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
{{ $recipient->sent_at?->format('d.m.Y H:i:s') ?? '—' }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $recipient->error ?? '' }}</span>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
{{ $campaign->isDraft()
? __('Noch kein Versand — die Kampagne ist ein Entwurf.')
: __('Keine Einträge für diesen Filter.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if ($recipients->hasPages())
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
{{ $recipients->links() }}
</div>
@endif
</article>
</div>

View file

@ -101,6 +101,15 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
Volt::route('admin/users/{id}', 'admin.users.show')->name('admin.users.show');
Volt::route('admin/users/{id}/edit', 'admin.users.edit')->name('admin.users.edit');
// 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 {
Volt::route('admin/newsletter', 'admin.newsletter.campaigns.index')->name('admin.newsletter.campaigns.index');
Volt::route('admin/newsletter/create', 'admin.newsletter.campaigns.edit')->name('admin.newsletter.campaigns.create');
Volt::route('admin/newsletter/{id}', 'admin.newsletter.campaigns.show')->where('id', '[0-9]+')->name('admin.newsletter.campaigns.show');
Volt::route('admin/newsletter/{id}/edit', 'admin.newsletter.campaigns.edit')->where('id', '[0-9]+')->name('admin.newsletter.campaigns.edit');
});
Volt::route('admin/roles', 'admin.roles.index')->name('admin.roles.index');
Volt::route('admin/roles/create', 'admin.roles.create')->name('admin.roles.create');
Volt::route('admin/roles/{id}/edit', 'admin.roles.edit')->name('admin.roles.edit');

View file

@ -0,0 +1,223 @@
<?php
use App\Enums\EmailNotificationType;
use App\Enums\NewsletterCampaignStatus;
use App\Enums\NewsletterRecipientStatus;
use App\Enums\Portal;
use App\Enums\UserPaymentOptionStatus;
use App\Jobs\SendNewsletterCampaignMail;
use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Newsletter\CampaignRecipients;
use App\Services\Newsletter\CampaignSender;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Internes Newsletter-Modul (Admin ▸ Newsletter): Empfänger-Filter,
* Versand mit Protokoll und die sofort greifende Abmeldung.
*/
function newsletterAdmin(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
test('the recipient filters cover legacy contracts and login windows', function () {
$recipients = app(CampaignRecipients::class);
$legacyUser = User::factory()->create(['name' => 'Legacy Lisa', 'last_login_at' => now()->subMonths(2)]);
UserPaymentOption::factory()->create([
'user_id' => $legacyUser->id,
'payment_option_id' => PaymentOption::factory()->create()->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'stripe_subscription_id' => null,
]);
$recentUser = User::factory()->create(['name' => 'Recent Rita', 'last_login_at' => now()->subMonth()]);
$dormantUser = User::factory()->create(['name' => 'Dormant Doris', 'last_login_at' => now()->subMonths(18)]);
$neverLoggedIn = User::factory()->create(['name' => 'Nie Nora', 'last_login_at' => null]);
$inactiveUser = User::factory()->create(['is_active' => false, 'last_login_at' => now()->subMonth()]);
$unsubscribed = User::factory()->create(['name' => 'Abgemeldet Anna', 'last_login_at' => now()->subMonth()]);
$unsubscribed->optOutOfEmailNotification(EmailNotificationType::Newsletter);
// Grundmenge: aktiv, mit E-Mail, nicht abgemeldet.
$all = $recipients->query([])->pluck('id');
expect($all)->toContain($legacyUser->id, $recentUser->id, $dormantUser->id, $neverLoggedIn->id)
->not->toContain($inactiveUser->id)
->not->toContain($unsubscribed->id);
// Nur Legacy-Verträge (Bestandsschutz).
expect($recipients->query(['legacy_contract' => true])->pluck('id')->all())
->toBe([$legacyUser->id]);
// In den letzten 6 Monaten eingeloggt.
$within = $recipients->query(['login' => 'within', 'login_months' => 6])->pluck('id');
expect($within)->toContain($legacyUser->id, $recentUser->id)
->not->toContain($dormantUser->id)
->not->toContain($neverLoggedIn->id);
// Komplement: seit 6 Monaten nicht (oder noch nie) eingeloggt.
$notWithin = $recipients->query(['login' => 'not_within', 'login_months' => 6])->pluck('id');
expect($notWithin)->toContain($dormantUser->id, $neverLoggedIn->id)
->not->toContain($recentUser->id);
// Sortierung nach letztem Login, älteste zuerst (NULL zuerst).
$sorted = $recipients->query(['sort' => 'last_login_at', 'direction' => 'asc'])->pluck('id')->all();
expect($sorted[0])->toBe($neverLoggedIn->id)
->and(end($sorted))->toBe($recentUser->id);
});
test('the campaign mail carries the body, salutation and a signed unsubscribe link', function () {
$user = User::factory()->create(['name' => 'Empfang Emil']);
$campaign = NewsletterCampaign::factory()->create([
'subject' => 'Unser Portal ist umgezogen',
'body' => '<p>Hallo <strong>zusammen</strong>,<script>alert(1)</script> es gibt Neuigkeiten.</p>',
]);
$html = (new NewsletterCampaignMail($campaign, $user))->render();
expect($html)
->toContain('Hallo Empfang Emil')
->toContain('<strong>zusammen</strong>')
->not->toContain('<script>')
->toContain('Warum erhalten Sie diese E-Mail?')
->toContain('e-mail-benachrichtigungen/abmelden/'.$user->id.'/'.EmailNotificationType::Newsletter->value)
->toContain('signature=');
});
test('sending a campaign mails every recipient and freezes the protocol', function () {
Mail::fake();
$userA = User::factory()->create();
$userB = User::factory()->create();
$optedOut = User::factory()->create();
$optedOut->optOutOfEmailNotification(EmailNotificationType::Newsletter);
$campaign = NewsletterCampaign::factory()->create();
// Sync-Queue im Test: start() versendet direkt.
$total = app(CampaignSender::class)->start($campaign);
expect($total)->toBe(2);
Mail::assertSent(NewsletterCampaignMail::class, 2);
Mail::assertNotSent(NewsletterCampaignMail::class, fn (NewsletterCampaignMail $mail) => $mail->user->is($optedOut));
$campaign->refresh();
expect($campaign->status)->toBe(NewsletterCampaignStatus::Sent)
->and($campaign->recipients_total)->toBe(2)
->and($campaign->sent_count)->toBe(2)
->and($campaign->failed_count)->toBe(0)
->and($campaign->sent_at)->not->toBeNull();
$emails = $campaign->recipients()->pluck('email');
expect($emails)->toContain($userA->email, $userB->email)
->and($campaign->recipients()->where('status', NewsletterRecipientStatus::Sent->value)->count())->toBe(2);
// Ein zweiter Start ist wirkungslos (kein Doppelversand).
expect(app(CampaignSender::class)->start($campaign))->toBe(0);
});
test('an opt-out between send start and delivery is skipped and recorded', function () {
Mail::fake();
$user = User::factory()->create();
$campaign = NewsletterCampaign::factory()->create([
'status' => NewsletterCampaignStatus::Sending->value,
'recipients_total' => 1,
]);
$recipient = $campaign->recipients()->create([
'user_id' => $user->id,
'email' => $user->email,
'status' => NewsletterRecipientStatus::Pending->value,
]);
// Abmeldung nach Versandstart, bevor der Worker den Job verarbeitet.
$user->optOutOfEmailNotification(EmailNotificationType::Newsletter);
(new SendNewsletterCampaignMail($recipient->id))->handle();
Mail::assertNothingSent();
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::SkippedOptOut);
$campaign->refresh();
expect($campaign->skipped_count)->toBe(1)
->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent);
});
test('the newsletter unsubscribe link records the opt-out immediately', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$url = URL::signedRoute('email-preferences.unsubscribe', [
'user' => $user->id,
'type' => EmailNotificationType::Newsletter->value,
]);
$this->get($url)->assertOk()->assertSee('abgemeldet');
expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse();
// Abgemeldete User fallen sofort aus jeder Empfänger-Auswahl.
expect(app(CampaignRecipients::class)->query([])->pluck('id'))->not->toContain($user->id);
});
test('the campaign editor saves drafts and sends via the queue', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
$admin = newsletterAdmin();
$this->actingAs($admin);
User::factory()->create(['portal' => Portal::Presseecho, 'last_login_at' => now()->subMonth()]);
LivewireVolt::test('admin.newsletter.campaigns.edit')
->set('subject', 'Willkommen auf der neuen Plattform')
->set('body', '<p>Es gibt <em>Neuigkeiten</em>.</p>')
->call('saveDraft')
->assertHasNoErrors();
$campaign = NewsletterCampaign::query()->sole();
expect($campaign->isDraft())->toBeTrue()
->and($campaign->created_by_user_id)->toBe($admin->id);
LivewireVolt::test('admin.newsletter.campaigns.edit', ['id' => $campaign->id])
->call('send')
->assertHasNoErrors();
$campaign->refresh();
expect($campaign->status)->toBe(NewsletterCampaignStatus::Sent)
->and($campaign->recipients_total)->toBeGreaterThanOrEqual(2); // Empfänger + Admin selbst
Mail::assertSent(NewsletterCampaignMail::class, $campaign->recipients_total);
});
test('the campaign pages require the users manage permission', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$editor = User::factory()->create(['is_active' => true]);
$editor->assignRole('editor');
$this->actingAs($editor);
$this->get(route('admin.newsletter.campaigns.index'))->assertForbidden();
$admin = newsletterAdmin();
$this->actingAs($admin);
$this->get(route('admin.newsletter.campaigns.index'))->assertOk();
});