# 06-Jul-2026 — Committee mail delivered straight to your inbox, no Gmail needed [#307](https://github.com/nbarrett/ngx-ramblers/issues/307)

## [build 749](https://github.com/nbarrett/ngx-ramblers/actions/runs/28797550566) — [commit b431f59](https://github.com/nbarrett/ngx-ramblers/commit/b431f5943b1ee35f0ec22ff697fa0b75bf0dff4d)

_____

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](https://ngx-ramblers.org.uk/how-to/technical-articles/2026-07-09-direct-inbox-setup), the companion to the [Gmail inbox guide](https://ngx-ramblers.org.uk/how-to/technical-articles/2026-05-29-gmail-inbox-setup).

### 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:
1. Cloudflare: the subdomain has Email Routing enabled and its committee
addresses (secretary@sub..., walks@sub..., ...) exist as routing rules.
2. In the site: Mail Settings -> Inbox -> provider = Direct to inbox.
3. Redeploy the site once so it receives the shared router secret
(`NGX_INBOUND_SECRET` - see below).
4. Click "Route this site's committee mail into the inbox": this ensures the
router worker exists and repoints the site's @<its-domain> address rules
at it.
5. 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.