Skip to content

Transactional Email Copies

Operators can add extra recipients (“watchers”) to key transactional emails without code changes. Each watcher receives their own individual copy (its own To:), never a CC/BCC on the customer’s message.

  • An operator asks “can X also receive the payment-confirmation emails?” — yes, via System → Emails, no deploy needed
  • Adding a new transactional email that should support watcher copies — register it in the TransactionalEmail enum, implement ConfigurableCopyEmail on the notification, and call TransactionalEmailCopier::fanOut() after the primary send

Notifications documents what each email/bell notification says and when it fires to its primary recipients. This system adds a configurable layer of extra recipients per email key on top of those primary sends. In-app-only (database channel) notifications and personal/security emails (magic links, password resets) are deliberately excluded from the registry — copying them would leak login tokens.

Class Role
App\Enums\TransactionalEmail Registry of copyable email keys (10 cases: booking_payment_confirmed, booking_submitted_to_volare, booking_submitted_to_supplier, quotation_requested, booking_main_contact_captured, checkout_continuation, allotment_exhausted, stop_sale_created_to_volare, trip_access_shared, balance_payment_request)
EmailCopyService Resolves the active recipients for a key (EmailConfiguration + active EmailCopyRecipient rows; lowercased, de-duplicated; empty when the config row is missing or toggled off)
TransactionalEmailCopier fanOut(Notification&ConfigurableCopyEmail) — sends each recipient their own clone of the notification through the standard notification mail path, so any template style renders identically to the primary send. Called right after the primary send at each dispatch site
TransactionalEmailBatchSender Sends an already-rendered subject+HTML to a recipient list. In production (mail.default = resend) it uses Resend’s batch endpoint — one HTTP call per 100 messages — to dodge per-send rate limits; any other mailer (SMTP/Mailpit locally) falls back to individual sends
TransactionalEmailSampleFactory Builds a preview notification for a key from the most recent representative real record (e.g. booking-submitted samples the latest booking with passengers, checkout continuation one with a resumption token) so admin previews never render misleading empty data
SendBookingPaymentCopiesJob Queued job carrying only the payment-confirmation copies, off the payment thread — a Resend outage can never affect the customer’s own receipt or its idempotency stamp (both handled inline in PaymentService)

System → Emails (backend/app/Filament/Resources/EmailConfigurations/, slug emails). There is no create page: the set of emails is fixed by the enum registry and reconciled on the list page (missing keys created, stale ones pruned). Per email: an is_active toggle (off = no copies sent) and a repeater of recipient addresses, each with its own active toggle. The edit page’s “Send preview” uses the sample factory above.

Trip-Access Copies (Sanitized) and Supplier Fallback

Section titled “Trip-Access Copies (Sanitized) and Supplier Fallback”

Two behaviors added in PR #2195, both verified in code:

  • Trip access (trip_access_shared) is the one registered email whose copies are sanitized: the per-passenger email carries a personal magic-link CTA, but watchers get TripAccessNotification::asInternalCopy() — same content, no access link, plain-text token discarded — and only one copy per booking, not per passenger (TripShareService::fanOutInternalCopy()). The admin preview is sanitized the same way.
  • Supplier email fallback: when a booking is submitted, suppliers with panel users are notified through them; a supplier without panel users now falls back to its operational suppliers.email address instead of being skipped silently, and a supplier with no reachable address at all is reported so the gap is visible (BookingStatusService::notifySuppliersOfSubmittedBooking()).
  • Copies are individual messages, never CC/BCC — recipients don’t see each other
  • A disabled configuration row (or none) means zero copies; the primary send is unaffected
  • Copy failures must never break the primary flow: payment copies run in a queued job; booking-submitted fan-outs are wrapped so errors are reported, not thrown
  • Security-sensitive emails stay out of the registry; trip access participates only via its sanitized internal copy
  • Notifications — primary sends and templates
  • Trip Access — the magic-link email being sanitized
  • Source: backend/app/Services/Email/EmailCopyService.php
  • Source: backend/app/Services/Email/TransactionalEmailCopier.php
  • Source: backend/app/Services/Email/TransactionalEmailBatchSender.php
  • Source: backend/app/Services/Email/TransactionalEmailSampleFactory.php
  • Source: backend/app/Jobs/SendBookingPaymentCopiesJob.php
  • Source: backend/app/Filament/Resources/EmailConfigurations/EmailConfigurationResource.php