# Email (Shared · how the app sends mail)

**Context:** Cross-cutting concern · **Stack:** Laravel's built-in **Mail** (`Mail` facade + **Mailable** classes) for transactional email, plus the provider-agnostic **`Src\Common\Email\EmailSender`** contract for automation sends · **Used by:** passwordless sign-in (link + OTP), `/register` contact-key change notices, Profile (email change / password reset), event tickets, Zoom meeting notifications, funnel automation emails, Zoom meeting briefs.

## What it does
How the system sends email. There are **two sanctioned paths — pick by what you are sending**:

1. **Transactional email (a Mailable per email type)** — Laravel's `Mail` facade + one class in `app/Mail` rendering a template in `resources/views/emails`. Use this for user-triggered, one-off emails (sign-in links, OTPs, tickets).
2. **Automation / bulk-ish email — the `EmailSender` interface** (`Src\Common\Email\EmailSender`, the email twin of [`SmsSender`](/docs/modules_handbook/shared/sms/readMe.md)). A thin provider-agnostic contract — `send($to, $subject, $html, $toName): bool`, **never throws** (a failed send is a logged `false`) — so features never depend on a specific provider. The default binding is [`SmtpEmailSender`](/src/Common/Email/SmtpEmailSender.php) (sends through the app mailer); a deliverability-focused provider (e.g. GetResponse) slots in later by swapping the binding in `AppServiceProvider`. Use this for queued/automated sends where a failure must not break the job (funnel automation, Zoom meeting briefs).

Delivery credentials for path 2 (and the SMTP transport generally) can be managed in the database via [Messaging credentials](/docs/modules_handbook/shared/messaging-credentials/readMe.md) — DB values override `mail.*` config at boot, falling back to `.env`.

Two delivery patterns are in use for credentials, plus plain notifications:

- **One-time token link** — a random token is stashed in the **cache** with a short TTL and emailed as a plain URL; clicking it redeems (and burns) the token. Used by passwordless sign-in, where the account is find-or-created only when the link is clicked, so there is no user to sign a route for yet.
- **OTP code** — a 6-digit code is emailed and stashed in the **cache**; the user types it back and the server verifies it. Used where the user stays in-app (sign-in verification, Profile email change / password reset).
- **Notification** — no credential at all: a ticket link, a meeting summary, a security notice. Nothing to redeem, nothing to expire.

| Mailable | Pattern | Sent when | Lifetime |
|----------|---------|-----------|----------|
| `LoginLinkMail` | One-time token link | `/login` → sign in by email | 20 min |
| `VerificationCodeMail` | OTP code | Sign-in / registration email verification | 5 min |
| `VerifyEmailChangeMail` | OTP code | Profile → change email (code goes to the **new** address) | 10 min |
| `PasswordResetCodeMail` | OTP code | Profile → forgot current password | 10 min |
| `ContactKeyChangedMail` | Notification | A verified `/register` (or its auto-merge) **replaced** an email/phone on an account — values masked. Recipient is `$replaced['email'] ?? $user->email`: the **previous** address when the email itself was replaced (so the old owner hears about it), the **current** one when only the phone changed | — |
| `EventTicketMail` | Notification (link to the signed ticket page) | Event registration confirmed, physical event with `email_tickets` on | — |
| `ZoomMeetingNotificationMail` | Notification | A Zoom meeting is booked / updated for a lead | — |
| `AiGatewayStatementMail` | Notification (PDF attached) | An admin emails a white-label client its monthly AI Gateway statement (Manage → AI Gateway → Statements) | — |

> **The pattern that used to head this list — a signed "magic" link *emailed* — no longer exists.** `WelcomeSignInMail` (landing registration, 7-day signed `auth.magic` URL) was removed from the flow **2026-07-25** and the class + its two templates were **deleted 2026-09-11**: `RegisterLeadAction` emails nothing at all, and the sign-in credential is delivered over **WhatsApp** as the funnel welcome's `{{login_link}}` (`Src\Event\Support\FunnelWhatsappComposer::loginLink()` — the same signed `auth.magic` route, still 7 days, plus the `ch=wa` / `ph` / `s` params that make the click prove the phone).

> There is **no password-reset *link*** email. `/login` is passwordless (link or OTP) and the old forgot-password flow was removed 2026-07-17 — it side-stepped the dual-key verification gate. Admins keep a password sign-in at `/manage/login`, which has no email step at all.

## How it works
- **One Mailable per email** (`app/Mail/*`): a small class with a constructor (the data it needs) and `build()` returning `->subject(...)` plus a template. Send it with `Mail::to($address)->send(new SomeMail(...))`. Mailables are queueable (`Queueable`), so this can move to a queue later with no call-site change.
- **Two template styles are in play.** The older mailables render a **Markdown** template (`->markdown('emails.<name>')`) using the framework's `mail::message` / `mail::button` / `mail::panel` components, themed by `config/mail.php` (`markdown.theme`). The newer ones pair a **hand-written HTML view with a plain-text alternative** — `->view('emails.<name>')->text('emails.<name>-text')` — which renders more predictably across clients. Prefer the view + text pair for new emails.
- **Signed links** (validated by the `signed` middleware on the target route — no token table, the signature *is* the proof) are still how `auth.magic` and the public event-ticket page work; **email just isn't the carrier any more.** The two differ on expiry, deliberately: `auth.magic` is a `URL::temporarySignedRoute(...)` (7 days), while the ticket is a plain `URL::signedRoute('main.ticket.show', ...)` with **no expiry at all** — "a ticket must outlive its send time" (`EventRegistration`). The sign-in link is minted by `FunnelWhatsappComposer::loginLink()` and delivered over WhatsApp; `MagicLoginController` consumes it. `EventTicketMail` is the one mail that still *carries* a signed URL — the ticket page, not a session.
- **One-time token links** are minted in `PasswordlessAuth::sendEmailLink()`: a random token keyed under `auth:magic:{token}` in the cache for 20 minutes, carrying the email + portal, emailed as `/auth/login-link/{token}`. Redeeming it deletes the key, so a link works exactly once.
- **OTP codes** are generated as a 6-digit value, cached under a per-purpose key (`auth:otp:*`, `password_reset:{id}`, the profile email/phone keys) and checked with `hash_equals` plus an **attempt counter** (dropped after 5 wrong tries). The pending value (new email / phone) rides in the cache, not the request — see `PasswordlessAuth` and `HandlesProfile::sendEmailCode` / `sendPasswordCode`.
- **Sending is best-effort wherever the mail merely *announces* something that already happened.** `SendEventTicketAction` and `PasswordlessAuth::notifyContactKeyChanges()` wrap the send in `try/catch` + `report()` / `Log::warning`, and `NotifiesLeadOfZoomMeeting` returns `'failed'` instead of throwing — a dead SMTP socket must not roll back a registration, a verified sign-in or a booked meeting. Mails that *are* the credential (`LoginLinkMail`, the OTP mailables) are **not** swallowed: if they cannot go out, the caller must know.

## Configuration — primary + backup (failover), and the gotchas
[`config/mail.php`](/config/mail.php) is env-driven in Laravel's `mailers` shape (**since 2026-09-11** — it was the Laravel-6 flat `driver`/`host` shape before, which is why the old `MAIL_DRIVER` still appears in some `.env` files; **Laravel 13 reads `MAIL_MAILER`** and ignores `MAIL_DRIVER` entirely).

**The default mailer is `failover`**: the primary `smtp` mailer, then the `backup` mailer the moment the primary refuses the connection, times out or rejects the login. Symfony's `FailoverTransport` does the switching and logs `Transport "smtp" failed.` (with the exception) to `laravel.log` each time it happens — that line is how you find out the primary is down. It was added because the production Mailgun SMTP goes down now and then, and every login code rides email.

| `.env` | What it is |
|---|---|
| `MAIL_MAILER` | `failover` (default) · `smtp` pins the primary alone · `log` / `array` never send |
| `MAIL_HOST` / `PORT` / `USERNAME` / `PASSWORD` / `FROM_ADDRESS` / `FROM_NAME` | the **primary** — overridden at boot by the SMTP row saved on Messages → Settings → Delivery APIs ([Messaging credentials](/docs/modules_handbook/shared/messaging-credentials/readMe.md) writes `mail.mailers.smtp.*` + `mail.from.*`) |
| `MAIL_HOST_BACKUP` / `PORT_BACKUP` / `USERNAME_BACKUP` / `PASSWORD_BACKUP` / `FROM_ADDRESS_BACKUP` / `FROM_NAME_BACKUP` (+ `MAIL_MAILER_BACKUP`, `MAIL_ENCRYPTION_BACKUP`) | the **backup** — `.env` only, never the DB, so a bad save on the settings page cannot take the fallback down too. **The backup joins the chain only when `MAIL_HOST_BACKUP` is set**; without it `failover` behaves exactly like plain `smtp`. Production uses a Gmail account with an App Password |
| `MAIL_TIMEOUT` (default 20 s) | how long to wait for an SMTP server. Symfony's default is **60 s**, which is how long a login code would sit waiting on a hung primary before the backup got its turn — this is what makes the failover usable |

- **From under the failover.** The global `mail.from` stays on the message whichever transport carries it; Laravel only applies a mailer's own `from` when that mailer is addressed directly (`Mail::mailer('backup')`). A **Gmail** backup rewrites From to the authenticated account anyway, so `MAIL_FROM_ADDRESS_BACKUP` should simply *be* that Gmail address — the recipient sees it, and DKIM/SPF pass. A backup on a provider that *rejects* a foreign From (SES, a second Mailgun domain) would need a From swap on failover, which nothing does today.
- **Queue workers cache the resolved mailer** for the life of the process — a credential change on the settings page (or in `.env`) reaches Horizon only after a restart. The failover's `retry_after` (60 s) also lives per process: a worker skips a primary that just failed for a minute instead of paying the timeout on every job.
- **`MAIL_ENCRYPTION` is informational on Laravel 13** — STARTTLS is negotiated automatically on 587/2525 and port 465 means implicit TLS. It is kept because the Delivery APIs page displays it.
- **Local dev**: `MAIL_MAILER=log` writes every email to `storage/logs/laravel.log` instead of sending — use that, or a test inbox; never real users. A TLS/cert error on a real send is the same **cacert** issue as cURL (see the local-dev notes), not a code problem.
- **Tests** run on `MAIL_MAILER=array` (`phpunit.xml`, forced) so no test can reach a real server; [`tests/Feature/Mail/MailFailoverTest.php`](/tests/Feature/Mail/MailFailoverTest.php) pins the failover itself against a dead primary.

## Reference usage
The canonical consumers — copy these patterns for new emails:

- **Automation send via `EmailSender`** — [app/Jobs/Automation/SendFunnelEmailMessage.php](/app/Jobs/Automation/SendFunnelEmailMessage.php): type-hint the **interface** (never `SmtpEmailSender`) in the queued job's `handle()`, render the HTML with a Blade view, call `$email->send($to, $subject, $html, $toName)` and record the boolean on the send ledger — no try/catch needed, the driver never throws. Skip placeholder addresses with `LeadLinker::isPlaceholderEmail()` first.
- **One-time token link** — [app/Services/Auth/PasswordlessAuth.php](/app/Services/Auth/PasswordlessAuth.php) (`sendEmailLink`): random token → `Cache::put(...)` for `MAGIC_TTL_MINUTES` → email the plain URL; redeem deletes the key.
- **OTP code** — [app/Services/Auth/PasswordlessAuth.php](/app/Services/Auth/PasswordlessAuth.php) (`sendEmailOtp`) and [app/Http/Controllers/Concerns/HandlesProfile.php](/app/Http/Controllers/Concerns/HandlesProfile.php) (`sendEmailCode` / `sendPasswordCode` + their verify methods): generate → `Cache::put(...)` → `Mail::to(...)->send(...)`; verify with `hash_equals` + attempts.
- **Best-effort notification** — [app/Actions/SendEventTicketAction.php](/app/Actions/SendEventTicketAction.php): guard clauses first (wrong event mode, feature off, no address ⇒ silent return), then the send inside `try/catch` + `report()`.

## Related files
**Mailables**
- [app/Mail/LoginLinkMail.php](/app/Mail/LoginLinkMail.php) — passwordless one-time sign-in link.
- [app/Mail/VerificationCodeMail.php](/app/Mail/VerificationCodeMail.php) — sign-in / registration OTP.
- [app/Mail/VerifyEmailChangeMail.php](/app/Mail/VerifyEmailChangeMail.php) — email-change code.
- [app/Mail/PasswordResetCodeMail.php](/app/Mail/PasswordResetCodeMail.php) — password-reset code.
- [app/Mail/ContactKeyChangedMail.php](/app/Mail/ContactKeyChangedMail.php) — masked "your contact details were updated" security notice.
- [app/Mail/EventTicketMail.php](/app/Mail/EventTicketMail.php) — event ticket.
- [app/Mail/ZoomMeetingNotificationMail.php](/app/Mail/ZoomMeetingNotificationMail.php) — Zoom meeting notification.
- [app/Mail/AiGatewayStatementMail.php](/app/Mail/AiGatewayStatementMail.php) — AI Gateway client statement (PDF attached).
- ~~`WelcomeSignInMail`~~ — dead since 2026-07-25, **deleted 2026-09-11** (see the callout above).

**Templates**
- HTML + plain-text pairs: [login-link.blade.php](/resources/views/emails/login-link.blade.php) · [verification-code.blade.php](/resources/views/emails/verification-code.blade.php) · [contact-key-changed.blade.php](/resources/views/emails/contact-key-changed.blade.php) · [zoom-meeting-notification.blade.php](/resources/views/emails/zoom-meeting-notification.blade.php) · [ai-gateway-statement.blade.php](/resources/views/emails/ai-gateway-statement.blade.php) (each with a `-text` sibling).
- HTML only (no `-text` sibling): [event-ticket.blade.php](/resources/views/emails/event-ticket.blade.php) — `EventTicketMail::build()` calls `->view(...)` without `->text(...)`.
- Markdown: [verify-email-change.blade.php](/resources/views/emails/verify-email-change.blade.php) · [password-reset-code.blade.php](/resources/views/emails/password-reset-code.blade.php).

**EmailSender (automation path)**
- [src/Common/Email/EmailSender.php](/src/Common/Email/EmailSender.php) — the provider-agnostic contract.
- [src/Common/Email/SmtpEmailSender.php](/src/Common/Email/SmtpEmailSender.php) — default driver (app mailer; logs + returns false on failure).
- [app/Providers/AppServiceProvider.php](/app/Providers/AppServiceProvider.php) — the binding (swap here for another provider).

**Config**
- [config/mail.php](/config/mail.php) — the `failover` → `smtp` + `backup` mailers, `MAIL_TIMEOUT`, Markdown theme (env-driven; `MAIL_MAILER`). At boot, [MessagingCredentialProvider](/docs/modules_handbook/shared/messaging-credentials/readMe.md) overrides `mail.mailers.smtp.*` + `mail.from.*` with DB-stored values from Messages → Settings → Delivery APIs when they exist.
- [tests/Feature/Mail/MailFailoverTest.php](/tests/Feature/Mail/MailFailoverTest.php) — the failover against a dead primary, and the backup joining the chain only when configured.

**Consumers (where mail is sent)** — these are *all* of them; a repo-wide grep for `Mail::to(` in `app/` + `src/` returns exactly eight call sites, plus two `Mail::raw` ops digests.
- [app/Services/Auth/PasswordlessAuth.php](/app/Services/Auth/PasswordlessAuth.php) — sign-in link (`sendEmailLink`) + OTP (`sendEmailOtp`) + the contact-key change notice (`notifyContactKeyChanges`).
- [app/Http/Controllers/Concerns/HandlesProfile.php](/app/Http/Controllers/Concerns/HandlesProfile.php) — email-change & password-reset codes (Profile).
- [app/Actions/SendEventTicketAction.php](/app/Actions/SendEventTicketAction.php) — event ticket.
- [app/Http/Controllers/Concerns/NotifiesLeadOfZoomMeeting.php](/app/Http/Controllers/Concerns/NotifiesLeadOfZoomMeeting.php) — Zoom meeting notification.
- [app/Http/Controllers/Manage/AiGateway/StatementsController.php](/app/Http/Controllers/Manage/AiGateway/StatementsController.php) — AI Gateway client statement.
- `Mail::raw` to STAFF only: [src/Common/Services/OpsAlertService.php](/src/Common/Services/OpsAlertService.php) (`OPS_ALERT_MAIL_TO`) and [app/Console/Commands/ScanLogsForAlerts.php](/app/Console/Commands/ScanLogsForAlerts.php) (`OPS_LOG_ALERT_MAIL_TO`).

**See also:** [Sign-in / identity](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) (the passwordless gate these emails serve) · [Profile](/docs/modules_handbook/shared/profile/readMe.md) (email change + password reset) · [Funnel WhatsApp](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md) (`{{login_link}}` — where the sign-in credential went after the welcome email was removed) · [Landing & lead capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) (the registration that no longer emails) · [Media](/docs/modules_handbook/shared/media/readMe.md) (the *with*-a-custom-service counterpart).
