presseportale/app/Jobs/SendNewsletterCampaignMail.php
Kevin Adametz 215cbcbdbf 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>
2026-07-21 09:26:57 +00:00

105 lines
3.4 KiB
PHP

<?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(),
]);
}
}
}