presseportale/app/Jobs/SendNewsletterCampaignMail.php
Kevin Adametz eeb1cde48d newsletter: jeden Empfänger genau einmal, atomarer Start, Wiederanlauf (Audit Block 2, Deploy B)
F05: chunkById hinter der created_at-Sortierung der Vorschau las ab der
zweiten Seite Empfänger doppelt und ließ die übrigen aus. Auf der Dev-
Kopie erreichte eine Kampagne an 59.705 Nutzer 500 Adressen, davon 480
doppelt. Die Sortierung wird vor dem ID-Chunking verworfen (E4: die
Versandreihenfolge hat keine fachliche Bedeutung), ein Unique-Index auf
(Kampagne, User) fängt jede weitere Doppelung ab; die Migration räumt
vorhandene Doppelungen vorher weg (auf der Dev-Kopie 470 Zeilen).

F14: Der Start übernimmt den Entwurf per bedingtem UPDATE draft→sending;
nur der Gewinner friert Empfänger ein (insertOrIgnore) und plant Jobs
aus dem Protokoll ein. Abbruch beim Einfrieren gibt den Entwurf frei.

F15: Der Versandjob beansprucht die Zeile pending→sending, schreibt
Empfängerstatus, Zähler und Kampagnenabschluss in einer Transaktion und
gibt den Claim bei Versandfehler zurück. Tries und Backoff am Job.
Zusatzbefund: Adressen auf der Sperrliste galten als „gesendet“, weil der
Rückgabewert von Mail::send nicht ausgewertet wurde — jetzt Status
„suppressed“ ohne Zähler.

Neu: php artisan newsletter:resume {kampagne} [--failed]
[--stuck-minutes=30] plant ausstehende Empfänger einer laufenden
Kampagne neu ein; der Circuit-Breaker-Hinweis nennt ihn statt
queue:retry, das Newsletter-Empfänger nicht nachholt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00

196 lines
6.8 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 App\Services\Mail\EmailSuppressionService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
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.
*
* Gegen Doppelversand und schiefe Zähler (Audit F15): Der Job übernimmt
* die Zeile per bedingtem UPDATE pending → sending; verliert er den Claim,
* ist ein anderer Worker dran. Empfängerstatus, Kampagnenzähler und
* Kampagnenabschluss werden in einer Transaktion geschrieben. Schlägt der
* Mailversand fehl, geht der Claim an „pending“ zurück und der Retry darf
* neu zugreifen. Bleibt eine Zeile in „sending“, ist der Worker nach der
* Mailserver-Übergabe abgestürzt: Zustellung unklar, kein automatischer
* Neuversand — `newsletter:resume --stuck-minutes` entscheidet das bewusst.
*/
class SendNewsletterCampaignMail implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly int $recipientId) {}
/**
* Wartezeit in Sekunden vor dem zweiten und dritten Versuch.
*
* @return list<int>
*/
public function backoff(): array
{
return [30, 120];
}
public function handle(): void
{
if (! $this->claim()) {
return;
}
$recipient = NewsletterCampaignRecipient::query()
->with(['campaign', 'user'])
->find($this->recipientId);
if ($recipient === null || $recipient->campaign === null) {
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;
}
try {
$sent = Mail::to($recipient->email)->send(new NewsletterCampaignMail($campaign, $user));
} catch (\Throwable $exception) {
$this->release();
throw $exception;
}
// Ohne SentMessage hat das Versand-Gate die Mail gestrichen. Die
// Sperrliste bestätigt das — der Rückgabewert allein trägt nicht,
// die Mail-Attrappe der Tests liefert ebenfalls null.
if ($sent === null && app(EmailSuppressionService::class)->isSuppressed($recipient->email)) {
$this->conclude($recipient, NewsletterRecipientStatus::Suppressed, null, 'Adresse auf der Sperrliste.');
return;
}
$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 || ! in_array($recipient->status->value, NewsletterRecipientStatus::openValues(), true)) {
return;
}
$error = $recipient->status === NewsletterRecipientStatus::Sending
? 'Zustellung unklar (Fehler nach Übergabe an den Mailserver): '.$exception->getMessage()
: $exception->getMessage();
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', $error);
}
/**
* Bedingter Claim pending → sending. Genau ein Worker gewinnt.
*/
private function claim(): bool
{
return NewsletterCampaignRecipient::query()
->whereKey($this->recipientId)
->where('status', NewsletterRecipientStatus::Pending->value)
->update([
'status' => NewsletterRecipientStatus::Sending->value,
'updated_at' => now(),
]) === 1;
}
/**
* Claim zurückgeben, wenn der Versand nicht stattgefunden hat.
*/
private function release(): void
{
NewsletterCampaignRecipient::query()
->whereKey($this->recipientId)
->where('status', NewsletterRecipientStatus::Sending->value)
->update([
'status' => NewsletterRecipientStatus::Pending->value,
'updated_at' => now(),
]);
}
/**
* Abschluss in einer Transaktion: Empfängerstatus, Kampagnenzähler und
* — wenn keine offene Zeile mehr da ist — Kampagnenabschluss. Die
* Kampagnenzeile wird zuerst gesperrt, damit zwei gleichzeitig
* endende Jobs den Abschluss nicht gegenseitig übersehen.
*/
private function conclude(
NewsletterCampaignRecipient $recipient,
NewsletterRecipientStatus $status,
?string $campaignCounter,
?string $error = null,
): void {
DB::transaction(function () use ($recipient, $status, $campaignCounter, $error): void {
$campaign = NewsletterCampaign::query()
->whereKey($recipient->newsletter_campaign_id)
->lockForUpdate()
->first();
$recipient->update([
'status' => $status->value,
'error' => $error !== null ? Str::limit($error, 480) : null,
'sent_at' => $status === NewsletterRecipientStatus::Sent ? now() : null,
]);
if ($campaign === null) {
return;
}
if ($campaignCounter !== null) {
NewsletterCampaign::query()->whereKey($campaign->id)->increment($campaignCounter);
}
$hasOpen = $campaign->recipients()
->whereIn('status', NewsletterRecipientStatus::openValues())
->exists();
if (! $hasOpen) {
NewsletterCampaign::query()
->whereKey($campaign->id)
->where('status', NewsletterCampaignStatus::Sending->value)
->update([
'status' => NewsletterCampaignStatus::Sent->value,
'sent_at' => now(),
'updated_at' => now(),
]);
}
});
}
}