affectedCustomers() as $customer) { $this->deliver($window, $customer, 'announcement', new MaintenanceAnnouncementMail($window, $customer)); } } /** Notify customers who actually received the announcement that it's cancelled. */ public function notifyCancellation(MaintenanceWindow $window): void { $ids = MaintenanceNotification::query() ->where('maintenance_window_id', $window->id) ->where('event', 'announcement') ->whereNotNull('sent_at') ->pluck('customer_id'); foreach (Customer::query()->whereIn('id', $ids)->get() as $customer) { $this->deliver($window, $customer, 'cancelled', new MaintenanceCancelledMail($window, $customer)); } } /** Queue one mail per (window, customer, event), idempotently and retryably. */ public function deliver(MaintenanceWindow $window, Customer $customer, string $event, Mailable $mail): void { $keys = ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event]; // firstOrCreate can still race two first-time inserts; the unique index // makes one lose — catch it and re-fetch so the operation stays idempotent. try { $delivery = MaintenanceNotification::query()->firstOrCreate($keys, ['email' => $customer->email]); $wasNew = $delivery->wasRecentlyCreated; } catch (UniqueConstraintViolationException) { $delivery = MaintenanceNotification::query()->where($keys)->firstOrFail(); $wasNew = false; } $priorUpdatedAt = $delivery->updated_at; // Claim under a row lock so two workers can't both pass a stale-retry // check and double-send. The winner stamps updated_at; the loser re-reads // a fresh updated_at and backs off. $claimed = DB::transaction(function () use ($delivery, $wasNew) { $row = MaintenanceNotification::query()->whereKey($delivery->id)->lockForUpdate()->first(); if ($row === null || $row->sent_at !== null) { return false; // already delivered — final } if (! $wasNew && $row->updated_at->gt(now()->subMinutes(self::RETRY_AFTER_MIN))) { return false; // still pending/in-flight — don't duplicate } $row->forceFill(['updated_at' => now()])->save(); return true; }); if (! $claimed) { return; } $mail->notificationId = $delivery->id; try { // Mail::to() alone resolves the DEFAULT mailer. Illuminate\Mail\Mailer::queue() // then runs `$view->mailer($this->name)->queue(...)` — that overwrites the // mailable's own purpose mailer (set in its constructor, e.g. 'cp_maintenance') // with the default's name before the job is even built. Naming the mailer here // makes that overwrite a no-op: $this->name on the resolved Mailer is already // $mail->mailer, so it writes back the same value. Mail::mailer($mail->mailer) ->to($customer->email) ->locale($customer->locale ?: config('app.locale')) ->queue($mail); } catch (\Throwable $e) { // Release the claim so a resend retries immediately. $wasNew ? $delivery->delete() : MaintenanceNotification::query()->whereKey($delivery->id)->update(['updated_at' => $priorUpdatedAt]); throw $e; } } }