diff --git a/app/Console/Commands/MailCircuitCommand.php b/app/Console/Commands/MailCircuitCommand.php index bf38e2a4..910e0dc8 100644 --- a/app/Console/Commands/MailCircuitCommand.php +++ b/app/Console/Commands/MailCircuitCommand.php @@ -18,7 +18,7 @@ class MailCircuitCommand extends Command { if ($this->option('reset')) { $breaker->reset(); - $this->info('Circuit Breaker zurückgesetzt — der Versand läuft wieder. Liegengebliebene Mails: php artisan queue:retry all'); + $this->info('Circuit Breaker zurückgesetzt — der Versand läuft wieder. Liegengebliebene Mails: php artisan queue:retry all — laufende Newsletter-Kampagnen: php artisan newsletter:resume {kampagne} --failed'); return self::SUCCESS; } diff --git a/app/Console/Commands/ResumeNewsletterCampaign.php b/app/Console/Commands/ResumeNewsletterCampaign.php new file mode 100644 index 00000000..ec5f3b61 --- /dev/null +++ b/app/Console/Commands/ResumeNewsletterCampaign.php @@ -0,0 +1,94 @@ +find((int) $this->argument('campaign')); + + if ($campaign === null) { + $this->error('Kampagne nicht gefunden.'); + + return self::FAILURE; + } + + if ($campaign->status !== NewsletterCampaignStatus::Sending) { + $this->error(sprintf('Kampagne #%d ist „%s“, nicht „%s“ — nichts zu tun.', $campaign->id, $campaign->status->label(), NewsletterCampaignStatus::Sending->label())); + + return self::FAILURE; + } + + $stuckMinutes = max(0, (int) $this->option('stuck-minutes')); + $stuck = 0; + + if ($stuckMinutes > 0) { + $stuck = $campaign->recipients() + ->where('status', NewsletterRecipientStatus::Sending->value) + ->where('updated_at', '<', now()->subMinutes($stuckMinutes)) + ->update([ + 'status' => NewsletterRecipientStatus::Pending->value, + 'error' => null, + 'updated_at' => now(), + ]); + } + + $failed = 0; + + if ($this->option('failed')) { + $failed = DB::transaction(function () use ($campaign): int { + $reset = $campaign->recipients() + ->where('status', NewsletterRecipientStatus::Failed->value) + ->update([ + 'status' => NewsletterRecipientStatus::Pending->value, + 'error' => null, + 'updated_at' => now(), + ]); + + if ($reset > 0) { + NewsletterCampaign::query()->whereKey($campaign->id)->decrement('failed_count', $reset); + } + + return $reset; + }); + } + + $dispatched = CampaignSender::dispatchPending($campaign); + + $this->info(sprintf( + 'Kampagne #%d: %d Empfänger neu eingeplant (%d hängende zurückgesetzt, %d fehlgeschlagene zurückgesetzt).', + $campaign->id, + $dispatched, + $stuck, + $failed, + )); + + return self::SUCCESS; + } +} diff --git a/app/Enums/NewsletterRecipientStatus.php b/app/Enums/NewsletterRecipientStatus.php index fa061e56..0c43fbbc 100644 --- a/app/Enums/NewsletterRecipientStatus.php +++ b/app/Enums/NewsletterRecipientStatus.php @@ -4,23 +4,33 @@ namespace App\Enums; /** * Versandstatus eines einzelnen Newsletter-Empfängers (Protokoll). + * + * Sending ist der Claim des Workers: Bleibt eine Zeile dauerhaft darin, + * ist der Worker zwischen Mailserver-Übergabe und Protokollierung + * abgestürzt — die Zustellung ist dann unklar; `newsletter:resume` + * setzt solche Zeilen bewusst zurück. * SkippedOptOut greift, wenn sich der User zwischen Versandstart und - * tatsächlichem Versand im Worker abgemeldet hat. + * tatsächlichem Versand im Worker abgemeldet hat. Suppressed, wenn das + * Versand-Gate die Adresse wegen der Sperrliste gestrichen hat. */ enum NewsletterRecipientStatus: string { case Pending = 'pending'; + case Sending = 'sending'; case Sent = 'sent'; case Failed = 'failed'; case SkippedOptOut = 'skipped_opt_out'; + case Suppressed = 'suppressed'; public function label(): string { return match ($this) { self::Pending => 'Ausstehend', + self::Sending => 'Wird versendet', self::Sent => 'Versendet', self::Failed => 'Fehlgeschlagen', self::SkippedOptOut => 'Übersprungen (abgemeldet)', + self::Suppressed => 'Gesperrt (Sperrliste)', }; } @@ -31,9 +41,22 @@ enum NewsletterRecipientStatus: string { return match ($this) { self::Pending => 'zinc', + self::Sending => 'blue', self::Sent => 'green', self::Failed => 'red', self::SkippedOptOut => 'amber', + self::Suppressed => 'orange', }; } + + /** + * Status, in denen der Versand noch aussteht oder läuft — solange eine + * Kampagne solche Zeilen hat, ist sie nicht abgeschlossen. + * + * @return list + */ + public static function openValues(): array + { + return [self::Pending->value, self::Sending->value]; + } } diff --git a/app/Jobs/SendNewsletterCampaignMail.php b/app/Jobs/SendNewsletterCampaignMail.php index 5cd04610..e76fece9 100644 --- a/app/Jobs/SendNewsletterCampaignMail.php +++ b/app/Jobs/SendNewsletterCampaignMail.php @@ -8,8 +8,10 @@ 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; @@ -20,20 +22,45 @@ use Illuminate\Support\Str; * * 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 + */ + 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->status !== NewsletterRecipientStatus::Pending) { + if ($recipient === null || $recipient->campaign === null) { return; } @@ -52,7 +79,22 @@ class SendNewsletterCampaignMail implements ShouldQueue return; } - Mail::to($recipient->email)->send(new NewsletterCampaignMail($campaign, $user)); + 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'); } @@ -64,42 +106,91 @@ class SendNewsletterCampaignMail implements ShouldQueue { $recipient = NewsletterCampaignRecipient::query()->find($this->recipientId); - if ($recipient === null || $recipient->status !== NewsletterRecipientStatus::Pending) { + if ($recipient === null || ! in_array($recipient->status->value, NewsletterRecipientStatus::openValues(), true)) { return; } - $this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', $exception->getMessage()); + $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 $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, - ]); + DB::transaction(function () use ($recipient, $status, $campaignCounter, $error): void { + $campaign = NewsletterCampaign::query() + ->whereKey($recipient->newsletter_campaign_id) + ->lockForUpdate() + ->first(); - $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(), + $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(), + ]); + } + }); } } diff --git a/app/Services/Mail/MailCircuitBreaker.php b/app/Services/Mail/MailCircuitBreaker.php index 9cdd8f8b..9eb9a19f 100644 --- a/app/Services/Mail/MailCircuitBreaker.php +++ b/app/Services/Mail/MailCircuitBreaker.php @@ -14,7 +14,8 @@ use Illuminate\Support\Facades\Cache; * Empfängerproblem, sondern ein Absenderproblem — und jede weitere Mail in * dem Zustand macht es schlimmer. Ab `threshold` Ablehnungen in * `window_minutes` ist der Kreis für `pause_minutes` offen: Queue-Mails - * schlagen fehl (und lassen sich danach mit `queue:retry` nachholen), + * schlagen fehl (und lassen sich danach mit `queue:retry` bzw. für + * Newsletter mit `newsletter:resume --failed` nachholen), * synchrone Mails werden still verworfen, der Admin bekommt genau eine Mail. * * Betriebs-Alerts gehen immer durch — sonst erführe niemand vom Stopp. @@ -47,7 +48,7 @@ class MailCircuitBreaker sprintf('Mailversand angehalten bis %s: %d Relay-Ablehnungen in %d Minuten', $until->format('H:i'), $count, $windowMinutes), [ 'last_rejection' => $detail, - 'hint' => 'Absenderruf/Relay prüfen. Fehlgeschlagene Queue-Mails danach mit `php artisan queue:retry all` nachholen; `php artisan mail:circuit --reset` hebt die Pause vorzeitig auf.', + 'hint' => 'Absenderruf/Relay prüfen. Fehlgeschlagene Queue-Mails danach mit `php artisan queue:retry all` nachholen, laufende Newsletter-Kampagnen mit `php artisan newsletter:resume {kampagne} --failed`; `php artisan mail:circuit --reset` hebt die Pause vorzeitig auf.', ], ); } diff --git a/app/Services/Newsletter/CampaignSender.php b/app/Services/Newsletter/CampaignSender.php index 0e9f80c7..75873475 100644 --- a/app/Services/Newsletter/CampaignSender.php +++ b/app/Services/Newsletter/CampaignSender.php @@ -15,6 +15,13 @@ use App\Models\User; * dort wird der Opt-out-Stand erneut geprüft, damit eine Abmeldung zwischen * Versandstart und Versand sofort greift. * + * Der Start ist atomar (Audit F14): Nur der Aufruf, der den Entwurf per + * bedingtem UPDATE auf „sending“ stellt, legt Empfänger an und plant Jobs + * ein. Empfänger werden mit insertOrIgnore gegen den Unique-Index + * (Kampagne, User) geschrieben, die Jobs anschließend aus dem Protokoll + * heraus eingeplant — ein Wiederanlauf nach Abbruch (`newsletter:resume`) + * arbeitet auf demselben Stand. + * * Große Listen werden gedrosselt: Die Jobs erhalten gestaffelte Delays in * Wellen laut config('newsletter.campaign'), damit das SMTP-Limit des * Providers nicht reißt und die Domain nicht auf Blocklisten landet. @@ -28,15 +35,108 @@ class CampaignSender */ public function start(NewsletterCampaign $campaign): int { - if (! $campaign->isDraft()) { + if (! $this->claimDraft($campaign)) { return 0; } - $total = 0; + try { + $this->freezeRecipients($campaign); + } catch (\Throwable $exception) { + // Abbruch beim Einfrieren: Entwurf bleibt startbar, bereits + // angelegte Zeilen werden beim nächsten Start übersprungen. + $this->releaseDraft($campaign); + throw $exception; + } + + $total = $campaign->recipients()->count(); + + if ($total === 0) { + $this->releaseDraft($campaign); + + return 0; + } + + $campaign->forceFill(['recipients_total' => $total])->save(); + + self::dispatchPending($campaign); + + return $total; + } + + /** + * Plant für jeden ausstehenden Empfänger einen Versand-Job ein, mit + * Drossel-Delay in Wellen. Liest aus dem Protokoll, nicht aus dem + * Speicher — so nutzt der Wiederanlauf denselben Weg. + * + * @return int Anzahl der eingeplanten Jobs. + */ + public static function dispatchPending(NewsletterCampaign $campaign): int + { + $batchSize = max(1, (int) config('newsletter.campaign.batch_size')); + $intervalSeconds = max(1, (int) config('newsletter.campaign.batch_interval_minutes')) * 60; + $position = 0; + + $campaign->recipients() + ->toBase() + ->select('id') + ->where('status', NewsletterRecipientStatus::Pending->value) + ->chunkById(500, function ($rows) use ($batchSize, $intervalSeconds, &$position): void { + foreach ($rows as $row) { + SendNewsletterCampaignMail::dispatch((int) $row->id) + ->delay(intdiv($position, $batchSize) * $intervalSeconds); + $position++; + } + }); + + return $position; + } + + /** + * Bedingtes UPDATE draft → sending: Genau ein Aufrufer gewinnt, auch bei + * zwei parallel geladenen Model-Instanzen desselben Entwurfs. + */ + private function claimDraft(NewsletterCampaign $campaign): bool + { + $claimed = NewsletterCampaign::query() + ->whereKey($campaign->id) + ->where('status', NewsletterCampaignStatus::Draft->value) + ->update([ + 'status' => NewsletterCampaignStatus::Sending->value, + 'updated_at' => now(), + ]); + + if ($claimed !== 1) { + return false; + } + + $campaign->forceFill(['status' => NewsletterCampaignStatus::Sending->value])->syncOriginal(); + + return true; + } + + private function releaseDraft(NewsletterCampaign $campaign): void + { + NewsletterCampaign::query() + ->whereKey($campaign->id) + ->where('status', NewsletterCampaignStatus::Sending->value) + ->update(['status' => NewsletterCampaignStatus::Draft->value]); + + $campaign->forceFill(['status' => NewsletterCampaignStatus::Draft->value])->syncOriginal(); + } + + /** + * Schreibt die Empfängerliste laut Filter ins Protokoll. Die Filter- + * Sortierung ist eine UI-Sortierung der Vorschau (E4) — vor dem + * ID-Chunking wird sie verworfen, sonst liest chunkById ab der zweiten + * Seite Empfänger doppelt und lässt andere aus (Audit F05). + */ + private function freezeRecipients(NewsletterCampaign $campaign): void + { $this->recipients->query($campaign->filters ?? []) ->select(['id', 'email']) - ->chunkById(500, function ($users) use ($campaign, &$total): void { + ->reorder('id') + ->chunkById(500, function ($users) use ($campaign): void { $rows = $users->map(fn (User $user): array => [ 'newsletter_campaign_id' => $campaign->id, 'user_id' => $user->id, @@ -46,35 +146,8 @@ class CampaignSender 'updated_at' => now(), ])->all(); - $campaign->recipients()->insert($rows); - $total += count($rows); + $campaign->recipients()->insertOrIgnore($rows); }); - - if ($total === 0) { - return 0; - } - - $campaign->update([ - 'status' => NewsletterCampaignStatus::Sending->value, - 'recipients_total' => $total, - ]); - - $batchSize = max(1, (int) config('newsletter.campaign.batch_size')); - $intervalSeconds = max(1, (int) config('newsletter.campaign.batch_interval_minutes')) * 60; - $position = 0; - - $campaign->recipients() - ->toBase() - ->select('id') - ->chunkById(500, function ($rows) use ($batchSize, $intervalSeconds, &$position): void { - foreach ($rows as $row) { - SendNewsletterCampaignMail::dispatch((int) $row->id) - ->delay(intdiv($position, $batchSize) * $intervalSeconds); - $position++; - } - }); - - return $total; } /** diff --git a/database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php b/database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php new file mode 100644 index 00000000..0d2b0d9f --- /dev/null +++ b/database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php @@ -0,0 +1,49 @@ +unique(['newsletter_campaign_id', 'user_id'], 'newsletter_recipients_campaign_user_unique'); + }); + } + + public function down(): void + { + Schema::table('newsletter_campaign_recipients', function (Blueprint $table): void { + $table->dropUnique('newsletter_recipients_campaign_user_unique'); + }); + } +}; diff --git a/resources/views/livewire/admin/newsletter/campaigns/show.blade.php b/resources/views/livewire/admin/newsletter/campaigns/show.blade.php index b029fe22..6b6b00a3 100644 --- a/resources/views/livewire/admin/newsletter/campaigns/show.blade.php +++ b/resources/views/livewire/admin/newsletter/campaigns/show.blade.php @@ -51,6 +51,11 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte 'recipients' => $recipients, 'filterSummary' => app(CampaignRecipients::class)->describe($campaign->filters ?? []), 'recipientStatuses' => NewsletterRecipientStatus::cases(), + // Gesperrte Adressen zählen in keinen Kampagnenzähler — sie + // werden aus dem Protokoll abgeleitet. + 'suppressedCount' => $campaign->recipients() + ->where('status', NewsletterRecipientStatus::Suppressed->value) + ->count(), ]; } }; ?> @@ -84,7 +89,7 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte {{ $campaign->sent_at ? __('abgeschlossen :date', ['date' => $campaign->sent_at->format('d.m.Y H:i')]) : __('läuft…') }} - {{ __('abgemeldet') }} + {{ __('abgemeldet') }}{{ $suppressedCount > 0 ? ' · '.__(':count gesperrt', ['count' => number_format($suppressedCount)]) : '' }} {{ __('siehe Protokoll') }} diff --git a/tests/Feature/Admin/NewsletterCampaignResilienceTest.php b/tests/Feature/Admin/NewsletterCampaignResilienceTest.php new file mode 100644 index 00000000..ab19dbd9 --- /dev/null +++ b/tests/Feature/Admin/NewsletterCampaignResilienceTest.php @@ -0,0 +1,353 @@ + die angelegten User-IDs + */ +function shuffledNewsletterUsers(int $count): array +{ + $base = now()->subYears(2); + + // 7919 ist prim und teilerfremd zu $count → eine Permutation von 0..$count-1. + $users = User::factory() + ->count($count) + ->sequence(fn ($sequence) => ['created_at' => $base->copy()->addMinutes(($sequence->index * 7919) % $count)]) + ->create(); + + return $users->pluck('id')->all(); +} + +function sendingCampaignWithRecipient(User $user): array +{ + $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, + ]); + + return [$campaign, $recipient]; +} + +test('chunking across the 500 boundary reaches every eligible user exactly once', function () { + Queue::fake(); + + $ids = shuffledNewsletterUsers(1001); + + // Default-Filter: Sortierung created_at desc — die UI-Sortierung der + // Vorschau, nicht die Versandreihenfolge (E4). + $campaign = NewsletterCampaign::factory()->create(['filters' => []]); + + $total = app(CampaignSender::class)->start($campaign); + + expect($total)->toBe(1001); + + $recipientUserIds = $campaign->recipients()->pluck('user_id')->all(); + + sort($ids); + sort($recipientUserIds); + + expect($recipientUserIds)->toBe($ids) + ->and($campaign->recipients()->count())->toBe(1001) + ->and($campaign->refresh()->recipients_total)->toBe(1001); + + Queue::assertPushed(SendNewsletterCampaignMail::class, 1001); +}); + +test('the unique index migration removes existing duplicates and keeps the oldest row', function () { + /** @var TestCase $this */ + $migration = 'database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php'; + + $this->artisan('migrate:rollback', ['--path' => $migration])->assertSuccessful(); + + $user = User::factory()->create(); + $campaign = NewsletterCampaign::factory()->create(); + + $kept = $campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email, 'status' => NewsletterRecipientStatus::Sent->value]); + $campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email]); + $campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email]); + // Zeilen ohne User bleiben unangetastet. + $campaign->recipients()->create(['user_id' => null, 'email' => 'a@example.test']); + $campaign->recipients()->create(['user_id' => null, 'email' => 'b@example.test']); + + $this->artisan('migrate', ['--path' => $migration])->assertSuccessful(); + + expect($campaign->recipients()->where('user_id', $user->id)->pluck('id')->all())->toBe([$kept->id]) + ->and($campaign->recipients()->whereNull('user_id')->count())->toBe(2); +}); + +test('a campaign cannot hold the same user twice', function () { + $user = User::factory()->create(); + $campaign = NewsletterCampaign::factory()->create(); + + $campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email]); + + expect(fn () => $campaign->recipients()->create(['user_id' => $user->id, 'email' => $user->email])) + ->toThrow(UniqueConstraintViolationException::class); + + // Protokoll-Zeilen ohne User (gelöschte Accounts) bleiben mehrfach möglich. + $campaign->recipients()->create(['user_id' => null, 'email' => 'a@example.test']); + $campaign->recipients()->create(['user_id' => null, 'email' => 'b@example.test']); + + expect($campaign->recipients()->count())->toBe(3); +}); + +test('two concurrent starts of the same draft schedule the campaign only once', function () { + Queue::fake(); + + User::factory()->count(3)->create(); + $campaign = NewsletterCampaign::factory()->create(); + + $first = NewsletterCampaign::query()->findOrFail($campaign->id); + $second = NewsletterCampaign::query()->findOrFail($campaign->id); + + $results = [ + app(CampaignSender::class)->start($first), + app(CampaignSender::class)->start($second), + ]; + + sort($results); + + expect($results)->toBe([0, 3]) + ->and($campaign->recipients()->count())->toBe(3) + ->and($campaign->recipients()->distinct()->count('user_id'))->toBe(3) + ->and($campaign->refresh()->status)->toBe(NewsletterCampaignStatus::Sending) + ->and($campaign->recipients_total)->toBe(3); + + Queue::assertPushed(SendNewsletterCampaignMail::class, 3); +}); + +test('a start after an aborted start keeps existing recipients and schedules every pending one', function () { + Queue::fake(); + + $users = User::factory()->count(3)->create(); + $campaign = NewsletterCampaign::factory()->create(); + + // Rest eines abgebrochenen Starts: die Kampagne ist noch Entwurf, ein + // Empfänger steht aber schon im Protokoll. + $campaign->recipients()->create([ + 'user_id' => $users[0]->id, + 'email' => $users[0]->email, + 'status' => NewsletterRecipientStatus::Pending->value, + ]); + + expect(app(CampaignSender::class)->start($campaign))->toBe(3) + ->and($campaign->recipients()->count())->toBe(3) + ->and($campaign->recipients()->where('user_id', $users[0]->id)->count())->toBe(1); + + Queue::assertPushed(SendNewsletterCampaignMail::class, 3); +}); + +test('a start without matching recipients leaves the campaign as a draft', function () { + Queue::fake(); + + $campaign = NewsletterCampaign::factory()->create(); + + expect(app(CampaignSender::class)->start($campaign))->toBe(0) + ->and($campaign->refresh()->status)->toBe(NewsletterCampaignStatus::Draft); + + Queue::assertNothingPushed(); +}); + +test('the send job retries with backoff', function () { + $job = new SendNewsletterCampaignMail(1); + + expect($job->tries)->toBe(3) + ->and($job->backoff())->toBe([30, 120]); +}); + +test('running the send job twice for one recipient sends exactly one mail', function () { + Mail::fake(); + + $user = User::factory()->create(); + [$campaign, $recipient] = sendingCampaignWithRecipient($user); + + (new SendNewsletterCampaignMail($recipient->id))->handle(); + (new SendNewsletterCampaignMail($recipient->id))->handle(); + + Mail::assertSent(NewsletterCampaignMail::class, 1); + + expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Sent) + ->and($recipient->sent_at)->not->toBeNull(); + + $campaign->refresh(); + expect($campaign->sent_count)->toBe(1) + ->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent); +}); + +test('a recipient already claimed by another worker is not sent again', function () { + Mail::fake(); + + $user = User::factory()->create(); + [$campaign, $recipient] = sendingCampaignWithRecipient($user); + + // Claim eines parallel laufenden Workers. + DB::table('newsletter_campaign_recipients') + ->where('id', $recipient->id) + ->update(['status' => NewsletterRecipientStatus::Sending->value]); + + (new SendNewsletterCampaignMail($recipient->id))->handle(); + + Mail::assertNothingSent(); + + expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Sending) + ->and($campaign->refresh()->sent_count)->toBe(0) + ->and($campaign->status)->toBe(NewsletterCampaignStatus::Sending); +}); + +test('a transport failure releases the claim so the retry can send', function () { + $user = User::factory()->create(); + [$campaign, $recipient] = sendingCampaignWithRecipient($user); + + Mail::shouldReceive('to')->once()->andThrow(new RuntimeException('SMTP weg')); + + expect(fn () => (new SendNewsletterCampaignMail($recipient->id))->handle()) + ->toThrow(RuntimeException::class, 'SMTP weg'); + + expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Pending) + ->and($campaign->refresh()->sent_count)->toBe(0) + ->and($campaign->failed_count)->toBe(0); + + // Nach dem letzten Versuch wird der Empfänger als fehlgeschlagen protokolliert. + (new SendNewsletterCampaignMail($recipient->id))->failed(new RuntimeException('SMTP weg')); + + expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Failed) + ->and($recipient->error)->toBe('SMTP weg') + ->and($campaign->refresh()->failed_count)->toBe(1) + ->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent); +}); + +test('a suppressed address ends as suppressed, not as sent', function () { + // Bewusst ohne Mail::fake(): das Sperrlisten-Gate hängt am + // MessageSending-Event, das die Attrappe nicht auslöst. + $user = User::factory()->create(['email' => 'gesperrt@example.test']); + [$campaign, $recipient] = sendingCampaignWithRecipient($user); + + app(EmailSuppressionService::class)->suppress($user->email, EmailSuppressionReason::HardBounce, 'test'); + + (new SendNewsletterCampaignMail($recipient->id))->handle(); + + expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Suppressed) + ->and($recipient->sent_at)->toBeNull(); + + $campaign->refresh(); + expect($campaign->sent_count)->toBe(0) + ->and($campaign->failed_count)->toBe(0) + ->and($campaign->skipped_count)->toBe(0) + ->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent); +}); + +test('newsletter:resume re-schedules pending recipients and optionally failed ones', function () { + /** @var TestCase $this */ + Queue::fake(); + + $users = User::factory()->count(3)->create(); + $campaign = NewsletterCampaign::factory()->create([ + 'status' => NewsletterCampaignStatus::Sending->value, + 'recipients_total' => 3, + 'failed_count' => 1, + ]); + + foreach ([NewsletterRecipientStatus::Pending, NewsletterRecipientStatus::Pending, NewsletterRecipientStatus::Failed] as $index => $status) { + $campaign->recipients()->create([ + 'user_id' => $users[$index]->id, + 'email' => $users[$index]->email, + 'status' => $status->value, + 'error' => $status === NewsletterRecipientStatus::Failed ? 'SMTP weg' : null, + ]); + } + + $this->artisan('newsletter:resume', ['campaign' => $campaign->id]) + ->expectsOutputToContain('2 Empfänger neu eingeplant') + ->assertSuccessful(); + + Queue::assertPushed(SendNewsletterCampaignMail::class, 2); + + $this->artisan('newsletter:resume', ['campaign' => $campaign->id, '--failed' => true]) + ->assertSuccessful(); + + Queue::assertPushed(SendNewsletterCampaignMail::class, 5); + + expect($campaign->recipients()->where('status', NewsletterRecipientStatus::Failed->value)->count())->toBe(0) + ->and($campaign->recipients()->where('status', NewsletterRecipientStatus::Pending->value)->count())->toBe(3) + ->and($campaign->recipients()->whereNotNull('error')->count())->toBe(0) + ->and($campaign->refresh()->failed_count)->toBe(0); +}); + +test('newsletter:resume frees recipients stuck in sending after a crashed worker', function () { + /** @var TestCase $this */ + Queue::fake(); + + $users = User::factory()->count(2)->create(); + $campaign = NewsletterCampaign::factory()->create([ + 'status' => NewsletterCampaignStatus::Sending->value, + 'recipients_total' => 2, + ]); + + $stale = $campaign->recipients()->create([ + 'user_id' => $users[0]->id, + 'email' => $users[0]->email, + 'status' => NewsletterRecipientStatus::Sending->value, + ]); + $fresh = $campaign->recipients()->create([ + 'user_id' => $users[1]->id, + 'email' => $users[1]->email, + 'status' => NewsletterRecipientStatus::Sending->value, + ]); + + DB::table('newsletter_campaign_recipients')->where('id', $stale->id)->update(['updated_at' => now()->subMinutes(45)]); + DB::table('newsletter_campaign_recipients')->where('id', $fresh->id)->update(['updated_at' => now()->subMinutes(5)]); + + $this->artisan('newsletter:resume', ['campaign' => $campaign->id])->assertSuccessful(); + + Queue::assertPushed(SendNewsletterCampaignMail::class, 1); + Queue::assertPushed(SendNewsletterCampaignMail::class, fn (SendNewsletterCampaignMail $job) => $job->recipientId === $stale->id); + + expect($stale->refresh()->status)->toBe(NewsletterRecipientStatus::Pending) + ->and($fresh->refresh()->status)->toBe(NewsletterRecipientStatus::Sending); +}); + +test('newsletter:resume refuses campaigns that are not sending', function () { + /** @var TestCase $this */ + Queue::fake(); + + $draft = NewsletterCampaign::factory()->create(); + + $this->artisan('newsletter:resume', ['campaign' => $draft->id])->assertFailed(); + + Queue::assertNothingPushed(); +}); + +test('the recipient protocol shows a stuck recipient as sending', function () { + $recipient = new NewsletterCampaignRecipient(['status' => NewsletterRecipientStatus::Sending->value]); + + expect($recipient->status->label())->toBe('Wird versendet'); +});