# Email at Scale: Outbound via Brevo, Inbound via Cloudflare

_____

From Ramblers Head Office's perspective, the most immediately deployable capability across groups is **email**. This article covers both the outbound and inbound email architecture, their design philosophy, and what multi-tenant email at 500 groups would require.

## What Groups Receive Today

Groups already on the NGX Ramblers platform receive a complete email solution covering both directions:

**Outbound (via Brevo):**
- Newsletter and bulk email sending
- Event notification emails (new events, changes, cancellations)
- Booking confirmation, cancellation, and waitlist emails
- Member welcome and renewal reminder emails
- Configurable email templates and sender identity per group

**Inbound (via Cloudflare Email Routing):**
- Role-based email addresses (e.g. `chairman@group-a-ramblers.org.uk`, `events@group-a-ramblers.org.uk`)
- Single-recipient forwarding to a committee member's personal email
- Multi-recipient forwarding via Cloudflare Workers (e.g. `secretary@` forwards to 3 committee members simultaneously)
- Catch-all routing for unmatched addresses
- Destination address verification to prevent misuse
- SPF, DKIM, and DMARC validation on all inbound mail
- Full email routing logs with 30-day audit trail
- Admin UI for managing all routing rules, destinations, and workers

## Outbound Email Architecture

Brevo handles all outbound email. This was an intentional design decision: the platform was built to operate within Brevo's free account tier, which covers outbound sending. However, the Brevo integration is extensible — any additional Brevo capabilities (inbound email parsing, marketing automation, transactional webhooks, etc.) could be integrated into the platform if a paid Brevo plan were adopted. Since this was written the outbound side has grown campaign sending with a managed queue (create, schedule, release and cancel), a **Mail Reports** page for campaign and transactional analytics, and a **Mail Domains** page for domain authentication and sender identities — all under **Admin → Mail Settings**.

```mermaid
flowchart LR
    subgraph ngx["NGX Ramblers"]
        ADMIN@{ icon: "ngx:user", label: "Group admin", pos: "b", h: 48 }
        EMAIL["Outbound Email Service<br/>group-scoped"]
        ADMIN --> EMAIL
    end

    subgraph brevo["Brevo"]
        SEND@{ icon: "ngx:brevo", label: "Brevo API", pos: "b", h: 48 }
        TPL["Templates per group"]
        LIST["Contact lists per group"]
    end

    EMAIL --> SEND
    SEND --> TPL
    SEND --> LIST
    LIST -->|"newsletters"| M1@{ icon: "ngx:user", label: "Members", pos: "b", h: 48 }
    LIST -->|"event notifications"| M1
    LIST -->|"booking confirmations"| M1

    style ngx fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style brevo fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

### How Member Lists Stay in Sync

The contact lists Brevo sends to are populated from the NGX Ramblers membership. Each member carries a small signature of the fields that go to Brevo (email, names, member attributes, the lists they are eligible for, and whether marketing consent is withheld), and that signature drives the synchronisation:

- **On change** - an admin edit to a member, or a member updating their own contact preferences, pushes that single member to Brevo straight away rather than waiting for the next send.
- **Before a send** - if no member in the audience differs from its stored signature, the platform does nothing, instead of pulling the entire contact list and reconciling on every send as it once did.
- **Seed and backstop** - the first full load and a periodic reconcile use Brevo's Contact Import API. This replaced the Bulk Contact Update endpoint, which Brevo retires on 30 October 2026.

Marketing consent is part of eligibility, so the two systems agree on who should be on a list. A member whose Head Office consent is withheld (in Insight Hub parity mode) or whose group-level consent is withheld (when granular consent is enabled) is eligible for no Brevo lists, and the same machinery that adds and updates contacts also removes them. A consent withdrawal originating in Salesforce therefore propagates through to Brevo.

For a full before-and-after of how member and consent information moves between NGX Ramblers, Brevo and Salesforce, see the companion article [Brevo Member Sync: How the Information Flow Changes](https://ngx-ramblers.org.uk/how-to/technical-articles/2026-06-14-brevo-member-sync-information-flow).

## Inbound Email Architecture

Inbound email reaches a group in one of three ways, all configured through the NGX Ramblers admin UI with no Cloudflare dashboard access required. By default a group has **no NGX inbox**: each role address is forwarded straight to a committee member's personal inbox via Cloudflare Email Routing. A group can instead collect mail for a role into a shared inbox that committee members read and reply to from the role address inside **Admin → Inbox**. That shared inbox is backed one of two ways — the **Gmail** model, where Cloudflare forwards the role address into a dedicated Gmail account that NGX reads through the Gmail API; or the newer **direct-to-inbox** model, where a Cloudflare Email Worker delivers each message straight to NGX over a signed webhook, with no Gmail account, no Google project, and no OAuth to maintain.

### Forwarding to a Personal Inbox

Each committee role (chairman, secretary, events coordinator, etc.) gets a dedicated email address on the group's domain. The admin configures where each address forwards to:

- **Single recipient** — a direct Cloudflare routing rule forwards to one personal email
- **Multiple recipients** — a Cloudflare Worker script is deployed that forwards to all recipients simultaneously
- **Brevo re-send** — a Worker can instead hand the full message back to NGX, which rewrites the headers DMARC-safely (the role address becomes the sender, the original sender moves to Reply-To) and re-sends each copy through Brevo's SMTP relay — used where a recipient's provider rejects directly forwarded mail
- **Catch-all** — a fallback rule captures any unmatched addresses

```mermaid
flowchart TD
    SENDER@{ icon: "ngx:user", label: "External sender", pos: "b", h: 48 }
    SENDER -->|"chairman@group-a-ramblers.org.uk"| CF

    subgraph CF["Cloudflare Email Routing"]
        CFI@{ icon: "ngx:cloudflare", label: "Cloudflare", pos: "b", h: 48 }
        CFI --> MATCH["Rule matcher<br/>literal match on To address"]
        MATCH -->|"single recipient"| DIRECT["Direct forward rule"]
        MATCH -->|"multiple recipients"| WORKER["Cloudflare Worker<br/>email-fwd-group-a-chairman"]
        MATCH -->|"no match"| CATCH["Catch-all rule"]
    end

    DIRECT -->|"forward"| R1@{ icon: "ngx:user", label: "Committee member", pos: "b", h: 48 }
    WORKER -->|"forward to all"| R2@{ icon: "ngx:user", label: "Recipient 1", pos: "b", h: 48 }
    WORKER -->|"forward to all"| R3@{ icon: "ngx:user", label: "Recipient 2", pos: "b", h: 48 }
    WORKER -->|"forward to all"| R4@{ icon: "ngx:user", label: "Recipient 3", pos: "b", h: 48 }
    CATCH -->|"fallback"| R5@{ icon: "ngx:user", label: "Secretary", pos: "b", h: 48 }

    style CF fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

### Admin UI Capabilities

The NGX Ramblers admin interface provides full control over email routing without needing Cloudflare dashboard access:

- **Per-role configuration** — select forwarding target (member email, custom address, or multiple recipients) for each committee role
- **Destination verification** — register and verify forwarding addresses via Cloudflare's email confirmation flow
- **Worker management** — deploy, update, and delete multi-recipient Worker scripts directly from the admin UI
- **Status monitoring** — real-time status badges showing ACTIVE, OUTDATED, MISSING, or PENDING for each role's forwarding
- **Orphan detection** — identifies deployed Worker scripts not linked to any routing rule
- **Email routing logs** — 30-day rolling audit trail showing all routed emails with SPF/DKIM/DMARC validation results
- **Worker invocation logs** — execution monitoring for multi-recipient Workers (requests, errors, subrequests)

### Authentication and Deliverability

All inbound email passes through Cloudflare's authentication pipeline:

- **SPF** (Sender Policy Framework) — validates the sending server is authorised
- **DKIM** (DomainKeys Identified Mail) — validates the message hasn't been tampered with
- **DMARC** (Domain-based Message Authentication) — policy enforcement combining SPF and DKIM

Authentication results are logged and visible in the admin UI, helping diagnose deliverability issues.

### Shared Inbox via Gmail

For roles where a shared mailbox is more useful than forwarding to one person, a role address can instead be backed by a dedicated Gmail account that NGX manages as a shared inbox. Mail still arrives through Cloudflare Email Routing, which forwards the role address into the Gmail account. NGX then connects to that account through the Gmail API and brings the conversations into the **Admin → Inbox** view, where any authorised committee member can read a thread and reply from the role address.

```mermaid
flowchart TB
    SENDER@{ icon: "ngx:user", label: "External sender", pos: "b", h: 48 }
    CF@{ icon: "ngx:cloudflare", label: "Cloudflare Email Routing", pos: "b", h: 48 }
    GMAIL["Dedicated Gmail account<br/>one per shared inbox"]
    SENDER -->|"role address"| CF
    CF -->|"forward"| GMAIL

    subgraph ngx["NGX Ramblers"]
        STORE@{ icon: "ngx:mongodb", label: "Threads + messages", pos: "b", h: 48 }
        UI["Admin → Inbox<br/>shared read + reply"]
        STORE --> UI
    end

    GMAIL -->|"Gmail API ingest<br/>poll every 30s, or push (Pub/Sub)"| STORE
    UI -->|"reply from role address"| GMAIL
    UI -->|"web-push + email digest"| COMMITTEE@{ icon: "ngx:user", label: "Committee members", pos: "b", h: 48 }

    style ngx fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

**Connecting a mailbox.** A member administrator enters an OAuth client (ID, secret, redirect URI) once under System Settings, then connects each Gmail account from Admin → Inbox. NGX requests a single scope (`gmail.modify` - list, read, mark as read, and send) and stores an encrypted refresh token. The OAuth secret and the token are never exposed to non-administrators.

**Delivery: pull or push.** Each mailbox chooses how new mail reaches NGX. **Pull** (the default) polls Gmail every 30 seconds and needs no extra setup. **Push** uses Gmail's watch API and a Google Cloud Pub/Sub topic to deliver notifications in real time. A daily job renews the watch (Gmail expires it after seven days) and checks each token's health, so push degrades safely and a revoked token surfaces on the mailbox status.

**Collaboration and access.** Messages and threads are stored in NGX (the `inboxMailboxConnections`, `inboxThreads` and `inboxMessages` collections), so the inbox is a shared, searchable record rather than living in one person's mail client. Each connected inbox sets an access policy: **all committee roles** lets any committee member work any role mapped to that inbox, while **assigned roles only** restricts each role to the members assigned to it. Committee roles are pointed at inboxes through role mailbox assignments.

**Notifications.** When a message arrives, NGX sends a browser web-push notification to the assigned members and, every five minutes, a Brevo transactional email digest of anything not yet notified to those who have opted in.

Step-by-step setup is covered in the companion guide [Setting up a Gmail inbox for committee replies](https://ngx-ramblers.org.uk/how-to/technical-articles/2026-05-29-gmail-inbox-setup).

### Direct to Inbox (Cloudflare, no Gmail)

The newer shared-inbox model removes Gmail from the path entirely. Instead of forwarding a role address into a mailbox, a Cloudflare Email Worker posts each message straight to NGX at `POST /api/cloudflare/email-routing/inbound-inbox`, HMAC-signed with a shared secret (`NGX_INBOUND_SECRET`). NGX parses the MIME, files the message under the role it was addressed to, and shows it in **Admin → Inbox** — the same shared read-and-reply experience as the Gmail model, but push-only (real-time on receipt), with no polling, no refresh token, and no seven-day watch to renew.

```mermaid
flowchart TB
    SENDER@{ icon: "ngx:user", label: "External sender", pos: "b", h: 48 }
    CF@{ icon: "ngx:cloudflare", label: "Cloudflare Email Worker", pos: "b", h: 48 }
    SENDER -->|"role address"| CF

    subgraph ngx["NGX Ramblers"]
        HOOK@{ icon: "ngx:express", label: "/inbound-inbox webhook", pos: "b", h: 48 }
        STORE@{ icon: "ngx:mongodb", label: "Threads + messages", pos: "b", h: 48 }
        UI["Admin → Inbox<br/>shared read + reply"]
        HOOK --> STORE --> UI
    end

    CF -->|"POST raw MIME<br/>HMAC-signed, real-time"| HOOK
    UI -->|"web-push + email digest"| COMMITTEE@{ icon: "ngx:user", label: "Committee members", pos: "b", h: 48 }

    style ngx fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

**Two delivery paths.** A site that owns its Cloudflare zone clicks *Route this site's committee mail into the inbox*, which deploys a shared `email-inbox-router` worker and repoints each `@domain` routing rule at it. A site that is a subdomain of a shared zone (for example `group-a.ngx-ramblers.org.uk`) has no rules of its own to add: the parent (apex) site sets its catch-all to the **shared inbox router**, and the router fans each message out by recipient domain to the right site's webhook. The router never drops mail — anything it cannot deliver is forwarded to a safety-net address rather than bounced.

#### How the shared inbox router fans out to per-site inboxes

When a shared zone hosts many group subdomains (e.g. `*.ngx-ramblers.org.uk`), a single Cloudflare catch-all rule at the zone apex directs all inbound mail to the `ngx-inbox-router` worker. The worker takes the recipient address from the email's `To` header, strips everything before and including the `@` sign, and lowercases what remains to get the destination domain. It then builds a webhook URL by prepending `https://` and appending `/api/cloudflare/email-routing/inbound-inbox` — so a message addressed to `chair@group-a.ngx-ramblers.org.uk` results in a POST to `https://group-a.ngx-ramblers.org.uk/api/cloudflare/email-routing/inbound-inbox`.

Each group subdomain runs its own NGX Ramblers instance on Fly.io, so the worker's POST lands on the correct site's inbox handler. The handler verifies the HMAC signature (shared via `NGX_INBOUND_SECRET`), parses the MIME, maps the recipient to a role alias, and stores the message. If the site's inbox provider is not set to direct-to-inbox, the handler returns a JSON instruction (`"forward"` or `"drop"`) and the worker acts on it.

```mermaid
flowchart TB
    SENDER@{ icon: "ngx:user", label: "External sender", pos: "b", h: 48 }
    SENDER -->|"chairman@group-a.ngx-ramblers.org.uk"| CF

    subgraph CF["Cloudflare zone: ngx-ramblers.org.uk"]
        CATCH["Catch-all routing rule"]
        ROUTER["ngx-inbox-router worker<br/>extracts domain from To: header"]
    end
    CATCH --> ROUTER

    subgraph pang["group-a.ngx-ramblers.org.uk"]
        FLY1@{ icon: "logos:fly-icon", pos: "t", h: 24 }
        HOOK1@{ icon: "ngx:express", label: "/inbound-inbox", pos: "b", h: 48 }
        INBOX1["Group A inbox"]
        HOOK1 --> INBOX1
    end

    subgraph staging["group-b.ngx-ramblers.org.uk"]
        FLY2@{ icon: "logos:fly-icon", pos: "t", h: 24 }
        HOOK2@{ icon: "ngx:express", label: "/inbound-inbox", pos: "b", h: 48 }
        INBOX2["Group B inbox"]
        HOOK2 --> INBOX2
    end

    subgraph seven["group-c.ngx-ramblers.org.uk"]
        FLY3@{ icon: "logos:fly-icon", pos: "t", h: 24 }
        HOOK3@{ icon: "ngx:express", label: "/inbound-inbox", pos: "b", h: 48 }
        INBOX3["Group C inbox"]
        HOOK3 --> INBOX3
    end

    ROUTER -->|"POST to group-a.ngx-ramblers.org.uk"| HOOK1
    ROUTER -->|"POST to group-b.ngx-ramblers.org.uk"| HOOK2
    ROUTER -->|"POST to group-c.ngx-ramblers.org.uk"| HOOK3

    style CF fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style pang fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style staging fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style seven fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

The same mechanism works for own-zone sites: each owns its own Cloudflare zone, the site deploys the shared router to its catch-all, and the router posts back to itself (the domain extracted from the recipient matches the site's own domain).

Step-by-step setup is covered in the companion guide [Setting up direct-to-inbox for committee replies](https://ngx-ramblers.org.uk/how-to/technical-articles/2026-07-09-direct-inbox-setup).

## Complete Email Flow — Both Directions

**Outbound** (platform to members): NGX Ramblers → Brevo API → member inboxes

**Inbound** (public to committee): external sender → Cloudflare Email Routing → one of: a committee member's personal inbox (forwarding); a dedicated Gmail account NGX reads via the Gmail API; or, with direct-to-inbox, a Cloudflare Email Worker that posts straight to NGX — the latter two both presented as a shared inbox in Admin → Inbox

**Admin**: all routing rules, destinations, and logs managed through the NGX Ramblers admin UI — no Cloudflare dashboard access required

## Multi-Tenant Email Requirements at 500 Groups

For a national rollout, the multi-tenant architecture must prioritise:

1. **Per-group Brevo configuration** — each group needs its own sender identity, templates, and contact lists
2. **Email isolation** — Group A's member list must never receive Group B's emails
3. **Shared Brevo account vs per-group accounts** — a single Brevo organisation with sub-accounts, or a centralised account with list-level segregation
4. **Deliverability at scale** — SPF, DKIM, and DMARC records per group domain to maintain inbox placement across 500 sending domains
5. **Cloudflare email routing per group** — each group needs its own set of routing rules and verified destinations. Currently each group has its own Cloudflare zone — in a multi-tenant model, this could be managed centrally via the super-admin concept
6. **Inbound routing at scale** — in practice, most groups use a single contact address forwarding to one person via a simple Cloudflare routing rule (no Worker required). Multi-recipient Workers are available for groups that need them but are the exception rather than the norm. This means scaling to 500 groups is primarily a matter of managing simple routing rules, not thousands of Worker scripts
7. **Email-first deployments** — **NGX-Lite**, an email-only application mode, runs the mail and membership capabilities without the full website, giving a deployment shape for groups that want the email solution before (or without) adopting the website
8. **Shared inboxes at scale** — groups that prefer a collaborative shared inbox over personal forwarding each add a connected Gmail account with its own OAuth token, delivery mode and watch renewal. A single Google Cloud project and OAuth client can serve many group mailboxes (each group's Gmail is added as a test user), so the per-group overhead is one connected mailbox to authorise rather than a separate Google project per group. The direct-to-inbox model goes further and removes Google from the picture altogether — an own-zone or subdomain site gets a shared inbox with no Gmail account and no Google project at all, the lightest option at 500-group scale
