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