# Platform email delivery: one webhook, one suppression list — task document (2026-09-22)

Source: Nash, 2026-09-22. "Should I have a webhook for all different type of campaigns or should I have one webhook for the entire website? … Do we create a separate webhook for this pilot invitation and then another one for the broker invitation, another one for the carrier invitation? Or we just create one master webhook for the entire system? … Password resets or anything else, any other automated emails that will go via ZeptoMail. Are we going to create a separate webhook setting for each campaign?" Plus: "we will be sending emails from the platform from two different inboxes."

Scope: make delivery feedback (bounces, complaints, unsubscribes) work for **every** email the platform sends, through **one** endpoint, so a dead or unwilling address is respected everywhere. Not a redesign of any existing email flow.

---

## 1. The decision

**One webhook endpoint for the entire platform. Never one per campaign or per email type.**

| Question | Answer |
|---|---|
| One webhook per campaign? | No |
| One per email type (pilot / broker / carrier / password reset)? | No |
| One master endpoint for everything? | **Yes** |
| One registration in ZeptoMail? | No — one **URL**, registered on **each mail agent** |
| Separate sending agents per stream? | **Yes**, keep them — that is a different axis |

Why one endpoint:

1. **A bounce is a fact about an address, not about a campaign.** If `info@carrier.com` is dead, it is dead for pilot invitations, broker verifications, and password resets alike. Separate endpoints put that knowledge in silos that never talk to each other.
2. **The payload is identical for every event.** ZeptoMail posts the same shape regardless of which email triggered it. Routing belongs in our code, where it is versioned and testable, not in a dashboard.
3. **Every extra endpoint is another URL and another secret.** Adding an email type would mean touching ZeptoMail config. That is the fragility to avoid — a new email type should need zero webhook work.

Why sending stays separated: reputation is tracked per agent and per domain. Cold pilot outreach that draws complaints must not degrade password-reset delivery. The existing three agents (`app_mail_agent`, `permit_mail_agent`, `HeavyHaulAgent`) are the right instinct and stay. **Segregate sending, unify receiving.**

Registration is therefore: same URL + same secret, entered on every agent that sends mail. Three agents today, and any agent added later.

---

## 2. Current state (verified in the codebase, 2026-09-22)

### 2.1 Six send paths, one of them protected

| # | Where | Sends | Suppression checked | Send log |
|---|---|---|---|---|
| 1 | `src/lib/data/pilot-network.ts:381` | Pilot invitations + follow-ups (bulk) | **Yes** | `pilot_invitation_events` |
| 2 | `src/lib/data/carrier-company.ts:268` | Carrier relationship + dispatcher invites | **No** | `email_events` |
| 3 | `src/lib/data/broker-company.ts:417` | Broker verification + bulk invites | **No** | `verification_emails` |
| 4 | `src/app/api/trips/[id]/pilot-paperwork-request/route.ts:104` | Paperwork request | **No** | `trip_events` |
| 5 | `src/app/api/admin/email-templates/test/route.ts:38` | Admin test send | n/a | — |
| 6 | `src/app/api/admin/pilot-invitations/templates/route.ts:96` | Admin test send | n/a | — |

24 templates exist in `src/lib/email-templates.ts`. Bulk invites (`company_dispatcher_bulk_invitation`, `broker_company_bulk_invitation`) send to pasted lists — the highest bounce risk on the platform and currently the least protected.

### 2.2 Four parallel send logs

`pilot_invitation_events` (0018) · `email_events` (0021) · `verification_emails` (0022) · `trip_events`. Each has its own status vocabulary. Three of the four store `message_id`. There is no single place to answer "what did we send this address, and did it land?"

### 2.3 One partial suppression list

`pilot_suppressions` (0018) is keyed on email with reasons `unsubscribed | invalid_email | bounced | duplicate | do_not_contact | complaint | manual_block`. Only path 1 reads it. It has no scope column, so it cannot express "stop marketing but keep transactional."

### 2.4 One webhook, pilot-only

`/api/pilot/email-webhook` (secret via `x-pilot-webhook-secret` header or `?key=`) parses ZeptoMail events and calls `applyDeliveryEvents`, which matches **only** `pilot_profiles`. A hard bounce on a broker verification arrives, matches nothing, is counted as `unmatched`, and is dropped. Live and answering on heavyhaulgbt.com.

### 2.5 Two blockers found in code

- **`tags` never reach ZeptoMail.** All six call sites pass a `tags` object; `sendViaZeptoMail` builds a body of `from / to / reply_to / subject / textbody / htmlbody / track_clicks / track_opens` and silently discards it. There is currently **no** way to attribute a webhook event to a specific send except by email address.
- **The API rate limit will drop webhook bursts.** `src/proxy.ts` caps all `/api/*` at 300 requests per IP per minute (`API_LIMITS`) and returns 429. Webhook traffic arrives from ZeptoMail's servers and so concentrates on few source IPs — the shape that trips a per-IP limiter — while ZeptoMail requires a 200.

---

## Module A — One endpoint

**Task A1 — Generalize the route.** New `POST /api/email/webhook`, authenticated by `x-email-webhook-secret` header or `?key=`, backed by `EMAIL_WEBHOOK_SECRET`. Keep `/api/pilot/email-webhook` and `PILOT_WEBHOOK_SECRET` working as an alias so the existing ZeptoMail registration keeps delivering during the switch; the old path delegates to the new handler. Both answer 200 once authorised, as ZeptoMail requires, including on an empty verify payload.

**Task A2 — Exempt it from the per-IP API limit.** In `src/proxy.ts`, let the webhook path past `API_LIMITS` (or give it a much higher dedicated allowance) so bounce bursts are never answered with 429. The shared secret is the gate. Log a failed-secret attempt with `logSecurityEvent` so a leaked secret surfaces alongside sign-in failures. Use the existing `rateLimit` helper for any webhook-specific ceiling — do not add a second limiter.

**Task A3 — Widen event coverage.** The parser already classifies hard bounce, soft bounce, and complaint. Add the ZeptoMail events for **unsubscribe** and (per Q5) **delivered**. Unknown event names are recorded, never dropped silently.

---

## Module B — Attribution: know what bounced

**Task B1 — Send a reference ZeptoMail echoes back.** Add `client_reference` to the ZeptoMail send body, carrying a string we own, e.g. `pilot_invite:<profile_id>:<campaign_id>` or `broker_verify:<claim_id>`. ZeptoMail returns it on the webhook (visible as `client_reference` in the webhook data preview in the agent's Webhooks tab). Extend `OutboundEmail` so callers set it explicitly, and stop discarding `tags`.

**Task B2 — Three-step match in the webhook.** Resolve each event by `client_reference` first, then `message_id` against the send logs, then the recipient address. Address alone is the last resort because one address can appear on several trips.

**Task B3 — Verify the message-id relationship.** The send response returns `data[0].message_id`; the webhook payload carries `email_reference`. Whether these are the same value is **unconfirmed** — check against one real send before relying on step two of B2.

---

## Module C — One suppression list (migration 0028)

**Task C1 — `email_suppressions`.** Platform-wide, keyed on lower-cased email, replacing the pilot-only table. Columns: `email`, `scope` (`all` | `bulk`), `reason` (`hard_bounce | invalid_email | complaint | unsubscribed | manual_block | do_not_contact | duplicate`), `source` (which stream reported it), `detail`, `created_by`, `created_at`, `expires_at` (nullable, for soft-bounce cooling-off per Q4). Migrate existing `pilot_suppressions` rows in, keep the old table as a view or drop it once path 1 reads the new one.

**Task C2 — Scope rules.** The heart of this work. Proposed, subject to Q1:

| Event | Scope | Effect |
|---|---|---|
| Hard bounce / invalid address | `all` | Nothing is ever sent again — the mailbox does not exist |
| Spam complaint | `bulk` | No marketing; transactional still allowed |
| Unsubscribe | `bulk` | No marketing; transactional still allowed |
| Soft bounce | none (or temporary, Q4) | Recorded; repeated soft bounces may escalate |
| Manual block / do-not-contact | `all` | Admin decision |

**Why scope is not optional.** A pilot unsubscribes from the invitation campaign. Two months later a carrier hires that pilot on a real trip and shares permits. That notification **must** still send. A single unscoped list would silently break the product for exactly the people who converted.

**Task C3 — Classify every template.** Each of the 24 templates is marked `bulk` or `transactional` — a column on `email_templates`, defaulting to `transactional` so a new template is never silently treated as marketing. First pass, subject to Q2: bulk = pilot invitation + follow-up, both bulk-invitation templates, the reminder templates. Everything else transactional.

**Task C4 — Enforce at the seam.** `sendEmail()` itself refuses a send whose recipient is suppressed for that stream, returning a `suppressed` result. Enforcing in the adapter rather than in six call sites means a seventh call site (password resets) is protected on the day it is written, with no extra work. Admin test sends bypass with an explicit flag.

---

## Module D — One send log

**Task D1 — Pick the single log.** `email_events` (0021) is the natural home: general shape, already carries `message_id`, `status`, `template_key`, `recipient`. Decision needed (Q3): fold the other three in, or keep each stream's rich table and dual-write a thin row to `email_events` for delivery state only. Recommendation: **dual-write**. The stream tables carry business context (which claim, which campaign) that does not belong in a generic log, and rewriting three working features to consolidate logs is risk without payoff.

**Task D2 — Record delivery outcomes.** Webhook events append to `email_events` (bounce, complaint, unsubscribe, optionally delivered) and update the originating stream row where B2 resolved one.

**Task D3 — Admin visibility.** A Delivery tab in the admin area listing recent events, current suppressions with reason and scope, and a manual "remove from suppression list" action for the case of a fixed mailbox. Audited.

---

## Module E — Verification

- Unit tests for the parser against real ZeptoMail payload shapes for each event type, the three-step match, and the scope rules including the unsubscribed-pilot-still-gets-trip-mail case.
- A test asserting `sendEmail()` refuses a suppressed recipient, per scope.
- Live check after deploy: fire ZeptoMail's Verify from each of the three agents; confirm 200 and a recorded event.
- One real bounce test to an invalid address on the verified domain, end to end, confirming suppression is written and the address is then refused.

---

## Out of scope (not requested, do not build)

Password-reset emails themselves (none exist yet — this work only ensures they inherit suppression on day one), open and click tracking, an email marketing dashboard, per-recipient sending preferences beyond `bulk` / `transactional`, and any change to the six existing send flows beyond the suppression check.

---

## Open questions

1. **Complaint scope.** A spam complaint suppresses `bulk` in the table above, leaving transactional mail flowing. Stricter alternative: a complaint suppresses `all`. Which?
2. **Template classification.** Confirm the bulk list: pilot invitation, pilot follow-up, `company_dispatcher_bulk_invitation`, `broker_company_bulk_invitation`, and the two reminder templates. Are the reminders bulk or transactional? A person who asked for verification and got a reminder is arguably transactional.
3. **Log consolidation.** Dual-write thin delivery rows to `email_events` and keep the three stream tables (recommended), or migrate everything into one table?
4. **Soft bounces.** Record only, or escalate — for example, three soft bounces in 30 days becomes a temporary `bulk` suppression that expires?
5. **Delivered events.** ZeptoMail can post successful deliveries too. Track them, giving a true delivery rate at the cost of a far higher event volume, or stay with bounces and complaints only?
6. **The two inboxes.** Which addresses, and which stream does each serve? `EMAIL_FROM` is `info@heavyhaulagent.com` today. Knowing the split lets each stream send from the right agent and keeps reputation separated as intended.
7. **Secret rollover.** Keep `PILOT_WEBHOOK_SECRET` as the platform secret under a new name, or issue a fresh `EMAIL_WEBHOOK_SECRET` and update all three agents at once?
