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.
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.
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
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.
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.
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.
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.
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.
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:
- Per-group Brevo configuration — each group needs its own sender identity, templates, and contact lists
- Email isolation — Group A's member list must never receive Group B's emails
- Shared Brevo account vs per-group accounts — a single Brevo organisation with sub-accounts, or a centralised account with list-level segregation
- Deliverability at scale — SPF, DKIM, and DMARC records per group domain to maintain inbox placement across 500 sending domains
- 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
- 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
- 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
- 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