06-Jul-2026 — Committee mail delivered straight to your inbox, no Gmail needed #307
Delivers an alternative to the Gmail inbox: mail is delivered straight into
the NGX inbox via Cloudflare Email Routing, with no per-group Gmail account,
OAuth consent or Pub/Sub. Designed so several sites that share one Cloudflare
zone - the ngx-ramblers.org.uk subdomains such as bolton and pang-valley -
each get a fully independent inbox.
How to set it up: step-by-step in Setting up direct-to-inbox for committee replies, the companion to the Gmail inbox guide.
What you get
- Per site, chosen from Mail Settings -> Inbox (systemConfig.inbox.provider):
"Gmail account" (the existing OAuth wizard) or "Direct to inbox" (this
feature). The Gmail wizard is gated behind the Gmail choice.
- Each direct-to-inbox site reads and replies to its own mail from
Admin -> Inbox, backed by its own database. Sites on the same zone never
see each other's mail.
How mail reaches the right inbox (the router)
- One account-wide Cloudflare Worker, "email-inbox-router", is the only shared
moving part. Every committee-address routing rule points at it.
- On each inbound message the worker reads the recipient, takes the domain part
(e.g. bolton.ngx-ramblers.org.uk) and POSTs the raw MIME to
https://<that-domain>/api/cloudflare/email-routing/inbound-inbox. The
recipient's domain IS the destination server, so nothing is per-site in the
worker and adding a site needs no worker change.
- The receiving site verifies the request, parses the MIME (mailparser),
uploads attachments to S3, resolves the committee role(s) from the recipient
and stores the message via storeInboundMessage
(externalSource =
CLOUDFLARE_INGRESS) in its own database.
Setting up multiple sites on one shared zone
bolton.ngx-ramblers.org.uk, pang-valley.ngx-ramblers.org.uk etc. are
subdomains of the single ngx-ramblers.org.uk Cloudflare zone. They share
that zone's MX and its one catch-all. Cloudflare's subdomain Email Routing
(MX already present per subdomain) makes @bolton..., @pang-valley...
addresses deliverable. For each site:
- Cloudflare: the subdomain has Email Routing enabled and its committee
addresses (secretary@sub..., walks@sub..., ...) exist as routing rules.
- In the site: Mail Settings -> Inbox -> provider = Direct to inbox.
- Redeploy the site once so it receives the shared router secret
(
NGX_INBOUND_SECRET - see below).
- Click "Route this site's committee mail into the inbox": this ensures the
router worker exists and repoints the site's @ address rules
at it.
- Mail to those addresses now lands in that site's Admin -> Inbox.
The catch-all (independent per subdomain)
Cloudflare provides exactly one catch-all per zone; there is no native
per-subdomain catch-all (subdomain routing is done with per-address rules).
To give every subdomain a real catch-all without letting any of them clobber
the shared rule, the apex / platform-admin site points that single zone
catch-all at the router worker (email-inbox-router). The router then routes
anything@<subdomain> to that subdomain's own inbox by domain, so each
subdomain effectively gets its own catch-all into its own inbox, with zero
per-subdomain setup and no site able to change the shared rule. The per-site
"Route to inbox" action above wires each site's known committee addresses;
pointing the zone catch-all at the router additionally sweeps up everything
else. Explicit per-address rules always take precedence, so the apex keeps
its own role addresses (e.g. on Gmail) while the catch-all handles the rest.
Shared secret (how the platform gives every site the same key)
The router signs each POST with an HMAC secret and every receiving site
validates it. It is a single value distributed through the existing
platform-admin shared-secrets mechanism:
secrets.NGX_INBOUND_SECRET in the central environments config is
injected into every managed site as an env var at deploy (the same path
AWS_* and AUTH_SECRET travel) and read via envConfig.value. A site only picks
it up after one redeploy - hence step 3 above.
Subdomain catch-all guard
A guard refuses catch-all changes from a site whose baseDomain is a strict
subdomain of its Cloudflare zone. This stops a subdomain site such as
pang-valley.ngx-ramblers.org.uk changing the shared ngx-ramblers.org.uk
catch-all and taking out staging and every other site on the zone.
Apex / own-zone sites (Canterbury, staging) are unaffected and still own
their catch-all.
What this commit adds
- ngx-inbox-router worker template + generateRouterWorkerScript, and
POST /route-to-inbox to deploy it and repoint a site's address rules.
- POST /api/cloudflare/email-routing/inbound-inbox handler (HMAC verify, MIME
parse, S3 attachments, role resolution, storeInboundMessage) and
ensureCloudflareIngressConnection (a no-OAuth mailbox connection).
- systemConfig.inbox.provider plus the Mail Settings -> Inbox provider
selector and Direct panel.
- The subdomain catch-all guard, the NGX_INBOUND_SECRET env key, and
mailparser as a server dependency. Shared HMAC and S3-attachment helpers are
extracted (the Gmail reader is refactored onto the latter) so the direct path
reuses the existing inbound-mime plumbing.
refactor+docs(aws): keep the file-upload route declarative
The /s3/file-upload route had an inline arrow function wrapping multer
with branching and response building. Route files must stay declarative
and delegate to named controller/handler functions.
- aws-routes: the route now references named middleware/handlers only
(authenticate, receiveFileUpload, uploadFile) with no multer or
envConfig wiring and no inline logic.
- file-upload: new exported receiveFileUpload middleware owns the multer
instance and returns a JSON 400 on a parse or limit error instead of
Express's default 500 HTML page.
- AGENTS.md: document the "routes are declarative" rule under a new
Backend / Express Patterns section, and record the ~50 legacy inline
handlers that need refactoring before an error-level lint rule can be
switched on.