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.
When to Use
Section titled “When to Use”- 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
TransactionalEmailenum, implementConfigurableCopyEmailon the notification, and callTransactionalEmailCopier::fanOut()after the primary send
How It Differs from Notifications
Section titled “How It Differs from Notifications”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.
Components
Section titled “Components”| 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) |
Admin Configuration
Section titled “Admin Configuration”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 getTripAccessNotification::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.emailaddress instead of being skipped silently, and a supplier with no reachable address at all is reported so the gap is visible (BookingStatusService::notifySuppliersOfSubmittedBooking()).
Business Rules
Section titled “Business Rules”- 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
Related
Section titled “Related”- 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