# Email architecture — two streams, one central email service

Implemented 2026-09-22 from Nash's specification. This page is the operating
guide; the rule it enforces is **marketing unsubscribe is category-specific,
hard bounce is delivery-wide.**

## Streams and Mail Agents

| Stream | Sender | ZeptoMail Mail Agent | Env | Webhook |
|---|---|---|---|---|
| transactional | info@heavyhaulagent.com | HHA Transactional | `ZEPTOMAIL_TRANSACTIONAL_TOKEN`, `EMAIL_FROM_TRANSACTIONAL` | `/api/webhooks/zeptomail/transactional` |
| updates | updates@heavyhaulagent.com | HHA Updates | `ZEPTOMAIL_UPDATES_TOKEN`, `EMAIL_FROM_UPDATES` | `/api/webhooks/zeptomail/updates` |

With only the legacy `ZEPTOMAIL_SEND_TOKEN` + `EMAIL_FROM` set, both streams
use that one agent (today's setup). `EMAIL_PROVIDER` unset = nothing leaves the
server; every message is still logged as `recorded_not_delivered`.

## The central sender — `src/lib/email/send.ts`

`sendPlatformEmail({ templateKey, to, data, userId?, campaignId? })`:

1. Template (admin-managed copy, `/admin/email-templates`) → stream, category, required.
2. Recipient context: verified delivery address on the profile, category
   preferences, suppressions, delivery state.
3. Pre-send decision (`src/lib/domain/email-policy.ts`, §31):
   global suppression / hard bounce → never; updates stream → marketing
   suppression, campaign suppression, category preference; transactional →
   ignore marketing opt-outs, honour optional-category preferences, required
   categories always go.
4. Render (rows with missing variables omitted), updates stream gets the
   unsubscribe + preference-center footer and `List-Unsubscribe` headers.
5. Send through the stream's Mail Agent with our `client_reference`
   (`hha_<uuid>`), log to `email_messages`.

Every sender in the app goes through it: company relationship emails,
broker verification requests, paperwork requests, pilot campaigns (own
renderer, same adapter, same policy check), invitations, address verification.

## Categories (preferences)

Transactional — info@: Account & Security (required), Company verification
(required), Billing, Trip notifications, Permit notifications, Route
notifications. Updates — updates@: Product updates, News & announcements,
Marketing & promotions, Pilot job alerts & invitations, Freight broker
invitations, Carrier invitations. Each shipped template is mapped in
`TEMPLATE_CATEGORY`; admins can change a template's category in the manager,
and §28 rejects a marketing category on the transactional stream or a
"required" updates template.

Users manage categories under Settings → Email; recipients without an
account use the preference center link in every updates email.
"Unsubscribe from all updates" turns every updates category off and never
touches transactional mail.

## Delivery suppression vs preference

`email_suppressions` scope `global` (hard bounce, repeated soft bounce,
admin block, invalid address) stops everything, transactional included.
Scope `marketing` (spam complaint, user unsubscribe) stops the updates stream
only. `expires_at` makes soft-bounce suppression temporary;
`manually_overridden_by` lifts one.

Bounce rules (`email_settings`, configurable): hard bounce → global, at once.
Soft bounce → counted inside a 14-day window; the 3rd suppresses delivery for
7 days. Spam complaint → marketing suppression at once; on the transactional
agent it is also flagged for review. Delivered → soft-bounce counter reset.

An account whose delivery address is suppressed sees a banner on every page
and can enter a new address under Settings → Email; a verification email
goes to the new address and the switch happens when the link is opened.
The old address keeps its suppression.

## Webhooks — `src/app/api/webhooks/zeptomail/[agent]/route.ts`

Configure each Mail Agent's webhook to its own URL with events Delivered,
Soft bounce, Hard bounce, Feedback loop (plus Open and Click on the Updates
agent). Authentication: ZeptoMail's signed webhook (HMAC-SHA256 of the raw
body with the agent's key; header name configurable via
`ZEPTOMAIL_WEBHOOK_SIGNATURE_HEADER`, default `zoho-webhook-signature` —
confirm in the console) or a shared secret (`x-hha-webhook-secret` header or
`?key=`). Every event is stored under a dedupe key (`email_webhook_events`)
and processed once; retries are harmless. Processing
(`src/lib/email/events.ts`): match the message by `client_reference`, then
request id, then newest to that address → update `email_messages`,
`email_delivery_state`, suppressions, campaign counters, auto-pause a
campaign whose hard-bounce rate crosses the configured line, and keep the
pilot pipeline's own history in step. The pilot-only webhook
(`/api/pilot/email-webhook`) still works for existing configuration.

## Reporting

`/admin/email-health`: both streams (sent, delivered, delivery rate, hard and
soft bounces, complaints, opens/clicks/unsubscribes for updates), by
category, campaigns with pause reasons, active suppressions, recent webhook
events. Our `email_messages` log is the long-term record (ZeptoMail keeps 60
days).

## Not in this pass

Campaign scheduling/sending for the generic `email_campaigns` table (the
pilot pipeline is the only runner today), reconciliation against ZeptoMail's
log API when a webhook was missed, and a suppression override button (use
`manually_overridden_by` in the database).
