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 d411e6ce7f
commit 215cbcbdbf
19 changed files with 1560 additions and 5 deletions

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