{"id":"6a4bb8a91bea39c9f5ed9dcd","title":"2026 07 06 Issue 307","path":"how-to/committee/release-notes/2026-07-06-issue-307","contentMarkdown":"# 06-Jul-2026 — Committee mail delivered straight to your inbox, no Gmail needed [#307](https://github.com/nbarrett/ngx-ramblers/issues/307)\n\n## [build 749](https://github.com/nbarrett/ngx-ramblers/actions/runs/28797550566) — [commit b431f59](https://github.com/nbarrett/ngx-ramblers/commit/b431f5943b1ee35f0ec22ff697fa0b75bf0dff4d)\n\n_____\n\nDelivers an alternative to the Gmail inbox: mail is delivered straight into\nthe NGX inbox via Cloudflare Email Routing, with no per-group Gmail account,\nOAuth consent or Pub/Sub. Designed so several sites that share one Cloudflare\nzone - the ngx-ramblers.org.uk subdomains such as bolton and pang-valley -\neach get a fully independent inbox. \n\n> **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).\n\n### What you get\n- Per site, chosen from Mail Settings -> Inbox (systemConfig.inbox.provider):\n\"Gmail account\" (the existing OAuth wizard) or \"Direct to inbox\" (this\nfeature). The Gmail wizard is gated behind the Gmail choice.\n- Each direct-to-inbox site reads and replies to its own mail from\nAdmin -> Inbox, backed by its own database. Sites on the same zone never\nsee each other's mail.\n\n### How mail reaches the right inbox (the router)\n- One account-wide Cloudflare Worker, \"email-inbox-router\", is the only shared\nmoving part. Every committee-address routing rule points at it.\n- On each inbound message the worker reads the recipient, takes the domain part\n(e.g. bolton.ngx-ramblers.org.uk) and POSTs the raw MIME to\n`https://<that-domain>/api/cloudflare/email-routing/inbound-inbox`. The\nrecipient's domain IS the destination server, so nothing is per-site in the\nworker and adding a site needs no worker change.\n- The receiving site verifies the request, parses the MIME (mailparser),\nuploads attachments to S3, resolves the committee role(s) from the recipient\nand stores the message via storeInboundMessage\n(externalSource = `CLOUDFLARE_INGRESS`) in its own database.\n\n### Setting up multiple sites on one shared zone\nbolton.ngx-ramblers.org.uk, pang-valley.ngx-ramblers.org.uk etc. are\nsubdomains of the single ngx-ramblers.org.uk Cloudflare zone. They share\nthat zone's MX and its one catch-all. Cloudflare's subdomain Email Routing\n(MX already present per subdomain) makes @bolton..., @pang-valley...\naddresses deliverable. For each site:\n1. Cloudflare: the subdomain has Email Routing enabled and its committee\naddresses (secretary@sub..., walks@sub..., ...) exist as routing rules.\n2. In the site: Mail Settings -> Inbox -> provider = Direct to inbox.\n3. Redeploy the site once so it receives the shared router secret\n(`NGX_INBOUND_SECRET` - see below).\n4. Click \"Route this site's committee mail into the inbox\": this ensures the\nrouter worker exists and repoints the site's @<its-domain> address rules\nat it.\n5. Mail to those addresses now lands in that site's Admin -> Inbox.\n\n### The catch-all (independent per subdomain)\nCloudflare provides exactly one catch-all per zone; there is no native\nper-subdomain catch-all (subdomain routing is done with per-address rules).\nTo give every subdomain a real catch-all without letting any of them clobber\nthe shared rule, the apex / platform-admin site points that single zone\ncatch-all at the router worker (email-inbox-router). The router then routes\n`anything@<subdomain>` to that subdomain's own inbox by domain, so each\nsubdomain effectively gets its own catch-all into its own inbox, with zero\nper-subdomain setup and no site able to change the shared rule. The per-site\n\"Route to inbox\" action above wires each site's known committee addresses;\npointing the zone catch-all at the router additionally sweeps up everything\nelse. Explicit per-address rules always take precedence, so the apex keeps\nits own role addresses (e.g. on Gmail) while the catch-all handles the rest.\n\n### Shared secret (how the platform gives every site the same key)\nThe router signs each POST with an HMAC secret and every receiving site\nvalidates it. It is a single value distributed through the existing\nplatform-admin shared-secrets mechanism:\nsecrets.NGX_INBOUND_SECRET in the central environments config is\ninjected into every managed site as an env var at deploy (the same path\nAWS_* and AUTH_SECRET travel) and read via envConfig.value. A site only picks\nit up after one redeploy - hence step 3 above.\n\n### Subdomain catch-all guard\nA guard refuses catch-all changes from a site whose baseDomain is a strict\nsubdomain of its Cloudflare zone. This stops a subdomain site such as\npang-valley.ngx-ramblers.org.uk changing the shared ngx-ramblers.org.uk\ncatch-all and taking out staging and every other site on the zone.\nApex / own-zone sites (Canterbury, staging) are unaffected and still own\ntheir catch-all.\n\n### What this commit adds\n- ngx-inbox-router worker template + generateRouterWorkerScript, and\nPOST /route-to-inbox to deploy it and repoint a site's address rules.\n- POST /api/cloudflare/email-routing/inbound-inbox handler (HMAC verify, MIME\nparse, S3 attachments, role resolution, storeInboundMessage) and\nensureCloudflareIngressConnection (a no-OAuth mailbox connection).\n- systemConfig.inbox.provider plus the Mail Settings -> Inbox provider\nselector and Direct panel.\n- The subdomain catch-all guard, the NGX_INBOUND_SECRET env key, and\nmailparser as a server dependency. Shared HMAC and S3-attachment helpers are\nextracted (the Gmail reader is refactored onto the latter) so the direct path\nreuses the existing inbound-mime plumbing.\n\n### refactor+docs(aws): keep the file-upload route declarative\n\nThe /s3/file-upload route had an inline arrow function wrapping multer\nwith branching and response building. Route files must stay declarative\nand delegate to named controller/handler functions.\n\n- aws-routes: the route now references named middleware/handlers only\n(authenticate, receiveFileUpload, uploadFile) with no multer or\nenvConfig wiring and no inline logic.\n- file-upload: new exported receiveFileUpload middleware owns the multer\ninstance and returns a JSON 400 on a parse or limit error instead of\nExpress's default 500 HTML page.\n- AGENTS.md: document the \"routes are declarative\" rule under a new\nBackend / Express Patterns section, and record the ~50 legacy inline\nhandlers that need refactoring before an error-level lint rule can be\nswitched on.","contentHtml":"<h1>06-Jul-2026 — Committee mail delivered straight to your inbox, no Gmail needed <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/307\">#307</a></h1>\n<h2><a href=\"https://github.com/nbarrett/ngx-ramblers/actions/runs/28797550566\">build 749</a> — <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/b431f5943b1ee35f0ec22ff697fa0b75bf0dff4d\">commit b431f59</a></h2>\n<hr>\n<p>Delivers an alternative to the Gmail inbox: mail is delivered straight into\nthe NGX inbox via Cloudflare Email Routing, with no per-group Gmail account,\nOAuth consent or Pub/Sub. Designed so several sites that share one Cloudflare\nzone - the ngx-ramblers.org.uk subdomains such as bolton and pang-valley -\neach get a fully independent inbox. </p>\n<blockquote>\n<p><strong>How to set it up:</strong> step-by-step in <a href=\"https://ngx-ramblers.org.uk/how-to/technical-articles/2026-07-09-direct-inbox-setup\">Setting up direct-to-inbox for committee replies</a>, the companion to the <a href=\"https://ngx-ramblers.org.uk/how-to/technical-articles/2026-05-29-gmail-inbox-setup\">Gmail inbox guide</a>.</p>\n</blockquote>\n<h3>What you get</h3>\n<ul>\n<li>Per site, chosen from Mail Settings -&gt; Inbox (systemConfig.inbox.provider):\n&quot;Gmail account&quot; (the existing OAuth wizard) or &quot;Direct to inbox&quot; (this\nfeature). The Gmail wizard is gated behind the Gmail choice.</li>\n<li>Each direct-to-inbox site reads and replies to its own mail from\nAdmin -&gt; Inbox, backed by its own database. Sites on the same zone never\nsee each other&#39;s mail.</li>\n</ul>\n<h3>How mail reaches the right inbox (the router)</h3>\n<ul>\n<li>One account-wide Cloudflare Worker, &quot;email-inbox-router&quot;, is the only shared\nmoving part. Every committee-address routing rule points at it.</li>\n<li>On each inbound message the worker reads the recipient, takes the domain part\n(e.g. bolton.ngx-ramblers.org.uk) and POSTs the raw MIME to\n<code>https://&lt;that-domain&gt;/api/cloudflare/email-routing/inbound-inbox</code>. The\nrecipient&#39;s domain IS the destination server, so nothing is per-site in the\nworker and adding a site needs no worker change.</li>\n<li>The receiving site verifies the request, parses the MIME (mailparser),\nuploads attachments to S3, resolves the committee role(s) from the recipient\nand stores the message via storeInboundMessage\n(externalSource = <code>CLOUDFLARE_INGRESS</code>) in its own database.</li>\n</ul>\n<h3>Setting up multiple sites on one shared zone</h3>\n<p>bolton.ngx-ramblers.org.uk, pang-valley.ngx-ramblers.org.uk etc. are\nsubdomains of the single ngx-ramblers.org.uk Cloudflare zone. They share\nthat zone&#39;s MX and its one catch-all. Cloudflare&#39;s subdomain Email Routing\n(MX already present per subdomain) makes @bolton..., @pang-valley...\naddresses deliverable. For each site:</p>\n<ol>\n<li>Cloudflare: the subdomain has Email Routing enabled and its committee\naddresses (secretary@sub..., walks@sub..., ...) exist as routing rules.</li>\n<li>In the site: Mail Settings -&gt; Inbox -&gt; provider = Direct to inbox.</li>\n<li>Redeploy the site once so it receives the shared router secret\n(<code>NGX_INBOUND_SECRET</code> - see below).</li>\n<li>Click &quot;Route this site&#39;s committee mail into the inbox&quot;: this ensures the\nrouter worker exists and repoints the site&#39;s @<its-domain> address rules\nat it.</li>\n<li>Mail to those addresses now lands in that site&#39;s Admin -&gt; Inbox.</li>\n</ol>\n<h3>The catch-all (independent per subdomain)</h3>\n<p>Cloudflare provides exactly one catch-all per zone; there is no native\nper-subdomain catch-all (subdomain routing is done with per-address rules).\nTo give every subdomain a real catch-all without letting any of them clobber\nthe shared rule, the apex / platform-admin site points that single zone\ncatch-all at the router worker (email-inbox-router). The router then routes\n<code>anything@&lt;subdomain&gt;</code> to that subdomain&#39;s own inbox by domain, so each\nsubdomain effectively gets its own catch-all into its own inbox, with zero\nper-subdomain setup and no site able to change the shared rule. The per-site\n&quot;Route to inbox&quot; action above wires each site&#39;s known committee addresses;\npointing the zone catch-all at the router additionally sweeps up everything\nelse. Explicit per-address rules always take precedence, so the apex keeps\nits own role addresses (e.g. on Gmail) while the catch-all handles the rest.</p>\n<h3>Shared secret (how the platform gives every site the same key)</h3>\n<p>The router signs each POST with an HMAC secret and every receiving site\nvalidates it. It is a single value distributed through the existing\nplatform-admin shared-secrets mechanism:\nsecrets.NGX_INBOUND_SECRET in the central environments config is\ninjected into every managed site as an env var at deploy (the same path\nAWS_* and AUTH_SECRET travel) and read via envConfig.value. A site only picks\nit up after one redeploy - hence step 3 above.</p>\n<h3>Subdomain catch-all guard</h3>\n<p>A guard refuses catch-all changes from a site whose baseDomain is a strict\nsubdomain of its Cloudflare zone. This stops a subdomain site such as\npang-valley.ngx-ramblers.org.uk changing the shared ngx-ramblers.org.uk\ncatch-all and taking out staging and every other site on the zone.\nApex / own-zone sites (Canterbury, staging) are unaffected and still own\ntheir catch-all.</p>\n<h3>What this commit adds</h3>\n<ul>\n<li>ngx-inbox-router worker template + generateRouterWorkerScript, and\nPOST /route-to-inbox to deploy it and repoint a site&#39;s address rules.</li>\n<li>POST /api/cloudflare/email-routing/inbound-inbox handler (HMAC verify, MIME\nparse, S3 attachments, role resolution, storeInboundMessage) and\nensureCloudflareIngressConnection (a no-OAuth mailbox connection).</li>\n<li>systemConfig.inbox.provider plus the Mail Settings -&gt; Inbox provider\nselector and Direct panel.</li>\n<li>The subdomain catch-all guard, the NGX_INBOUND_SECRET env key, and\nmailparser as a server dependency. Shared HMAC and S3-attachment helpers are\nextracted (the Gmail reader is refactored onto the latter) so the direct path\nreuses the existing inbound-mime plumbing.</li>\n</ul>\n<h3>refactor+docs(aws): keep the file-upload route declarative</h3>\n<p>The /s3/file-upload route had an inline arrow function wrapping multer\nwith branching and response building. Route files must stay declarative\nand delegate to named controller/handler functions.</p>\n<ul>\n<li>aws-routes: the route now references named middleware/handlers only\n(authenticate, receiveFileUpload, uploadFile) with no multer or\nenvConfig wiring and no inline logic.</li>\n<li>file-upload: new exported receiveFileUpload middleware owns the multer\ninstance and returns a JSON 400 on a parse or limit error instead of\nExpress&#39;s default 500 HTML page.</li>\n<li>AGENTS.md: document the &quot;routes are declarative&quot; rule under a new\nBackend / Express Patterns section, and record the ~50 legacy inline\nhandlers that need refactoring before an error-level lint rule can be\nswitched on.</li>\n</ul>\n"}