{"id":"69ee9eb0a9e1ef7e28f472eb","title":"2026 04 27","path":"how-to/committee/release-notes/2026-04-27","contentMarkdown":"# 27-Apr-2026 — Brevo unsubscribes & blocked-contacts management [#18](https://github.com/nbarrett/ngx-ramblers/issues/18)\n## [build 624](https://github.com/nbarrett/ngx-ramblers/actions/runs/24969398442) — [commit 78478d7](https://github.com/nbarrett/ngx-ramblers/commit/78478d767f84746f2a075221d7a37dec78b7ab29)\n\n_____\n\n## What's new\n\n* A new **Unsubscribes** tab on **Mail Settings** lists every contact Brevo is currently suppressing — anyone who unsubscribed via an email link, the Brevo unsubscribe page, or the API, plus contacts blocked by an admin, hard-bounced or flagged as spam. \n* Blocks now propagate onto the matching NGX member record so re-imports, auto-subscribes and member admin all respect the most recent opt-out — automatically and visibly. \n* And the unsubscribe link in every outgoing email now lands on our own branded page instead of Brevo's hosted form, with optional reason capture.\n\n\n\n## Highlights\n\n### Live view of Brevo's blocklist\n\n- Searchable, sortable table of every blocked contact, with reason badges (unsubscribed via email / API / marketing automation, admin-blocked, hard bounce, spam-flagged) and per-sender stats.\n- Each row matches the contact to an NGX member by email and shows the matched name plus membership number where known.\n- A **Lists** column shows which Brevo lists the contact is associated with at the time of the last sync, plus a \"Blocked from receiving email\" hint when Brevo's account-level block is set.\n- **Date range slider** (last two years) and **per-sender** filter narrow the query server-side; default range is treated as \"no filter\" and omits date params from the Brevo call.\n- **Click any contact email** to open that contact's profile in Brevo in a new tab.\n\n### Member-level visibility\n\n- Each NGX member record gains a denormalised `emailBlock` summary populated by the sync, so other parts of the app can answer \"is this person actually emailable?\" with one read.\n- A concise warning banner appears at the top of the member admin modal's **Brevo** tab when the contact is blocked, citing reason and date with a deep link straight to the Unsubscribes view to re-enable.\n- Every block becomes a permanent **mailListAudit** row keyed by `(memberId, listId, blockedAt)` — visible on the existing **Brevo** tab inside the member admin modal as part of that member's history.\n- The member's NGX `mail.subscriptions` are also flipped to **unsubscribed** for the lists Brevo holds them on, so the local subscription view matches reality.\n- A new **\"Blocked from receiving email\"** filter is available on the Member Admin page so admins can quickly list every blocked member.\n\n### One-click cleanup\n\n- A **Remove from blocklist** icon button on each row calls Brevo's blocklist removal endpoint, clears the matched member's local `emailBlock`, and writes an admin-override audit row. A two-step confirm prevents accidental clicks. Subscription state is **not** reset automatically — re-subscribe deliberately on the member's Brevo tab if appropriate.\n\n### Bulk-import safety\n\n- Re-importing the Ramblers HQ CSV continues to create / update members as before, but **auto-subscribe is suppressed for any member with an active `emailBlock`** — even if HQ's `Email Marketing Consent` column says yes. The local block is the source of truth for outbound deliverability.\n\n### Single-pass list assignment on bulk import\n\n- The initial `processCreateContactRequests` payload now carries `listIds` directly, so a fresh bulk import creates the Brevo contact **and** assigns it to the configured lists in one round-trip. Previously contacts came in unassigned and only landed in their lists on a follow-up \"Update Brevo Mailing Lists\" pass — visible on a fresh import as two consecutive audit rows (\"Contact created in Brevo\" → \"Contact Updated in Brevo: … List Ids → 0: N\"). This was largely masked in production because most syncs are updates rather than creates; surfaced via the salesforce-mock synthetic-data path.\n- The Brevo create-audit row now mirrors the update-audit and records the full payload (`Contact created in Brevo: Attributes -> ..., List Ids -> 0: N`) instead of the previous bare `Contact created in Brevo`. Sets `first(listIds)` on the audit so the per-list filter on the Member Admin → Brevo tab works for create rows too.\n\n### Branded unsubscribe page\n\n- Email footers now point at our own `/unsubscribe` page instead of Brevo's hosted landing — recipients land on a page that wears the group's own branding (header, navbar, footer) rather than `sendibt3.com`.\n- The page **auto-completes the unsubscribe on load** with no form, no email entry, no identification step. The link itself carries a per-recipient HMAC-signed token (secret auto-generated per deployment and stored alongside the existing Brevo config); the token IS the identity, so clicking and going is enough.\n- Once the unsubscribe is recorded, the same page offers an **optional** reason form (too-many-emails, not-relevant, no-longer-interested, moved-away, never-signed-up, other) with a free-text \"tell us more\" field. Skipping is fine — the unsubscribe is already complete.\n- Outbound transactional emails now also set RFC 8058 one-click headers (`List-Unsubscribe: <https://…>, <mailto:…>` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`), so Gmail and Apple Mail's native unsubscribe buttons hit the same API endpoint and complete in one click without rendering the page.\n- The confirm endpoint calls Brevo's `updateContact(emailBlacklisted=true)` and applies the local `emailBlock` + `mailListAudit` row directly (mirroring the events webhook handler), with `source: \"branded-unsubscribe\"`. Optional reason submissions write a separate `branded-unsubscribe-feedback` audit row keyed off the same member.\n- Local blocks created via this path use a distinct `source` value, so the existing sync's self-heal logic (which only undoes `brevo-unsubscribes-sync` rows) doesn't accidentally clear them.\n\n### List-scoped unsubscribes, activity feed, and clear-all\n\n- The unsubscribe flow is now **list-scoped** rather than account-wide. The HMAC token in each outgoing email's unsubscribe link carries the originating list ID alongside the recipient identifier; clicking the link removes the contact from just that Brevo list (`removeContactFromList`) and flips only the matching local subscription. Account-level blocking is reserved for hard signals — Brevo's `blocked` / `hard_bounce` / `spam` / `complaint` events still propagate to the contact's `emailBlock` as before.\n- The branded unsubscribe page now names the list it removed the recipient from: \"We've removed X from {listName}. You won't receive any more emails from this list.\" Falls back to the previous wording if the token doesn't carry a list ID, so older signed links still in inboxes keep working.\n- `mailListAudit` rows now record `MailListAuditListType` (list-scoped vs account-wide) and `MailListAuditSource`, so the Member Admin → Brevo tab history shows which list each unsubscribe was for and where it came from.\n- New **unsubscribe activity feed** (`GET /unsubscribes/activity`) and **history view** (`GET /unsubscribes/history`) on the server, surfaced through `MailService.queryUnsubscribeActivity` / `queryUnsubscribeHistory` for paginated, sortable display in the Mail Settings UI.\n- New **Clear all blocklist** admin action (`DELETE /unsubscribes`) for resetting the entire local blocklist in one step — for staging / test environments and recovery from sync drift. Two-step confirm at the UI.\n- Member admin search filter recognises blocked-from-individual-list as a distinct state from globally blocked, and the Brevo tab now attaches any captured unsubscribe feedback rows alongside the matching block entry.\n- Outgoing unsubscribe links now auto-resolve `listId` server-side when the caller omits it: if the recipient is subscribed to exactly one list, that list ID flows into the HMAC token. Welcome / expiry-warning emails (which don't pass `emailRequest.listId`) now go through the list-scoped path rather than the legacy \"all local subscriptions disabled\" fallback.\n- `APP_URL` is now derived from `urlService.baseUrl()` rather than a hardcoded `group.href`, so unsubscribe links built while the app is running on `localhost:4200` point at localhost rather than the production domain. Same for `PW_RESET_LINK`.\n- Audit message dropped the Brevo list-ID suffix: \"Unsubscribed from All Ashford Mocks via branded unsubscribe page\" rather than the previous \"Unsubscribed from All Ashford Mocks (#10) ...\". The list ID is a Brevo implementation detail and not useful in the audit row.\n- Sender filter param tolerates the three shapes the UI / Express might send (JSON-encoded array, repeated `?sender=` params, or comma-separated). Caught exceptions on the unsubscribes / activity routes now `console.error` the underlying status code, response body, and stack unconditionally — no more silent debugLog when Brevo 5xx's.\n\n### Unsubscribes view polish\n\n- Default sub-tab is now Unsubscribes (was Blocks) and the toggle order matches.\n- `?sub-tab=blocks` / `?sub-tab=unsubscribes` are now honoured on initial load, alongside new `start-date`, `end-date`, and `sender` query params, so the entire filter state of the view is shareable as a URL.\n- Activity table now has full sort UI on every column (Name / List / Reason / Unsubscribed At), mirroring the Blocks table; `UnsubscribeSortField` gained `NAME` and `LIST_NAME` values, with a client-side `sortedActivity()` getter applying the sort.\n- Date-range slider now also constrains the activity feed (server applies a Mongo `timestamp` `$gte/$lte` filter on the audit collection); previously the slider only affected the Blocks query.\n- Slider got more horizontal space (`col` + `col-auto` instead of `col-sm-9` / `col-sm-3`) so the Clear-filters / Options buttons stop overlapping the \"To <date>\" label when both are visible.\n- Activity row collapses reason and free-text comment into a single pill (`Other — \"Cara says nooooo!\"`) instead of a badge plus a separate caption underneath.\n- \"All lists (legacy link)\" relabelled to \"Unknown list\" for the listId-zero case, and named lists fall back to the locally cached `listNameById` map when the server activity row doesn't carry an inline name.\n\n### Automation, not ad-hoc\n\n- A scheduled background sync runs every two hours so the Unsubscribes view stays current without an admin opening the page.\n- A **Brevo events webhook** captures `unsubscribed`, `blocked`, `hard_bounce`, `spam` and `complaint` events in real time, with `unsubscribe_revoked` / `unblocked` automatically clearing the local block. The webhook URL and secret can be retrieved from `GET /api/mail/webhooks/brevo-events/config` and pasted into Brevo's dashboard.\n- Members no longer in Brevo's blocklist have their local `emailBlock` **self-healed** on the next full sync, so the local state can never drift past Brevo's.\n\n### Help content seeded\n\n- The in-page help editor on the Unsubscribes tab is pre-populated with documentation explaining the view, side effects, filters, the Remove action, the automation, and the bulk-import gate. Admins can edit further from the page.\n\n### Salesforce writeback ready, not yet wired\n\n- Each row carries a Salesforce writeback status (`pending` / `not-applicable` / future `synced` / `failed`) and the matching membership number is captured at audit-write time, so the future Ramblers HQ Salesforce consent integration can iterate the audit collection directly when the consent endpoint becomes available.\n\n### Member bulk-load Upload History polish\n\n- Quick Search input on the Upload History view now baseline-aligns with the Uploaded-at / Member-action selects regardless of where their inline labels wrap.\n- Filter row tightened to a single line: each labelled select uses a Bootstrap `input-group` with the label as `input-group-text`, the row uses `flex-nowrap`, and Member Action right-aligns via `ms-auto` instead of CSS `float-end`.\n\n### Other\n\n- Member Raw Data tab now hides the JSON dump behind an eye-toggle (matching the password-field reveal pattern), since it includes unfiltered Mongo fields that aren't safe to leave on screen by default.\n- New brand SVG favicon.\n\n### **server**: serve index.html no-cache and 404 missing static assets\n\nAfter a deploy, tabs that had a stale index.html (referencing previous\ncontent-hashed bundles like main-GIDJEFWQ.js) would silently fail to\nboot. The SPA catch-all was happily serving index.html for the missing\n.js request, which tripped strict MIME checking (\"Expected JavaScript,\ngot text/html\"), leaving a blank screen until the user thought to\nhard-refresh.\n\n- index.html (both via express.static and the SPA fallback) now sends\nCache-Control: no-cache, no-store, must-revalidate, so browsers\nalways revalidate and pick up the new bundle hashes.\n- The SPA fallback returns 404 for requests whose path ends in a\nknown static-asset extension, so a genuinely missing asset fails\nloudly instead of being masked as HTML.\n\nHashed bundle/css/font/image assets remain safely long-cacheable via\ntheir content hashes - only the HTML entry point changes caching.\n\n## Brevo Unsubscribes & Blocked Contacts\n\nA new admin page at **Mail Settings → Unsubscribes** that gives committee members complete visibility of Brevo's suppression state — every contact who unsubscribed via an email link, the Brevo unsubscribe page, the Brevo API, plus contacts blocked by an admin, hard-bounced or flagged as spam.\n\n### Two views\n\nThe toggle at the top-right switches between:\n\n* **Unsubscribes** — member-level unsubscribe events, including the originating list and any feedback the contact provided (free-text comment or one of the standard reason codes such as *\"The content isn't relevant to me\"*, *\"I receive too many emails\"*, *\"I've moved away from the area\"*).\n* **Blocks** — Brevo's account-level blocklist: hard bounces, spam flags, admin blocks, and contacts who used the global Brevo unsubscribe page.\n\n![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/ff557f7d-abd0-4aae-853a-7b5dd71df085.png)\n\n*Unsubscribes tab — member-level unsubscribe events from Brevo, with reason badges and the originating list*\n\n### What each row shows\n\n* **Contact Email** — click to open the contact in Brevo in a new tab.\n* **Sender Email** — the sender against which the block was registered (relevant for transactional events).\n* **Reason** — Brevo's classification, colour-coded (hard bounces and spam in red).\n* **Matched Member** — local NGX member matched by email, with membership number for cross-reference.\n* **Lists** — Brevo list memberships at the time of the last sync.\n* **Blocked At / Unsubscribed At** — when Brevo recorded the event.\n\n### Filters\n\n* **Search** is client-side and filters across contact email, sender email, and reason text.\n* **Reason**, **Sender** and the **Date Range** slider are server-side filters — applying them re-fetches a narrowed set from Brevo.\n* **Clear filters** resets all server-side filters.\n\n![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/626857e4-32f9-4efd-bd18-a3f557aff765.png)\n\n*Blocks tab — Brevo's account-level blocklist (hard bounces, spam flags, admin blocks)*\n\n### Side effects of opening the page\n\nEach row that matches an NGX member writes a `mailListAudit` row keyed by `(memberId, listId, blockedAt)` so the block becomes part of that member's permanent audit trail (visible on the **Brevo** tab of the member admin modal). Each matched member's `emailBlock` summary is updated to reflect the latest Brevo state, and the contact's NGX `mail.subscriptions` are flipped to **unsubscribed** for the lists Brevo holds them on.\n\n### Actions\n\n**Remove from blocklist** (trash icon) calls Brevo's `DELETE /smtp/blockedContacts/{email}`, then clears the matched member's local `emailBlock` and writes an audit row recording the manual override. Subscription state on the member is **not** changed automatically — re-subscribe explicitly via the member's Brevo tab if appropriate.\n\nThe **Options** dropdown also exposes a **Clear all blocks** action that removes every contact from Brevo's blocklist and clears every local `emailBlock` record. NGX member records are not deleted.\n\n### Automation\n\n* A scheduled cron job runs the sync every two hours so the data here stays current without an admin opening the page.\n* A Brevo webhook endpoint (`POST /api/mail/webhooks/brevo-events?token=...`) writes the same audit and `emailBlock` updates in real time when Brevo emits `unsubscribed`, `blocked`, `hard_bounce`, `spam`, or `complaint` events. The webhook URL is configured inside Brevo's account settings.\n\n### Bulk-import safety\n\nWhen a Ramblers HQ CSV is re-imported, members already flagged with `emailBlock` are still updated, but they will not be auto-subscribed to mail lists regardless of what HQ's `Email Marketing Consent` column says. The local block is the source of truth for outbound deliverability.\n\n### **admin**: rsm-style restyle of admin pages with mobile-friendly tables ([#18](https://github.com/nbarrett/ngx-ramblers/issues/18))\n\n* Replaces the dark-panel, chunky-button look across admin pages with the lighter, slim-row, sunrise-underline pattern from the ramblers-salesforce-mock admin UI, and adds a collapse-to-cards layout below 576px so admin tables work on phones.\n\n* Introduces a new partial at assets/styles/rsm-admin.scss, imported from styles.sass after the legacy frames and tables. The partial provides opt-in utility classes (.rsm-admin-page, .rsm-toolbar, .rsm-admin-table, .rsm-table-responsive, .rsm-tabs/.rsm-tab, .rsm-btn-cta/.rsm-btn-ghost/.rsm-btn-danger/.rsm-btn-small, .rsm-form/.rsm-form-inline/.rsm-form-actions, .rsm-card-grid/.rsm-card, .rsm-chip/.rsm-muted/.rsm-error) that honour existing tokens (--btn-min-height, --btn-pad-y/x, --radius-1) and reuse the existing $ramblers-colour-* variables for palette consistency.\n\n* The same partial scopes overrides under app-page so the existing admin wrappers — .thumbnail-admin-edit (used by 41 admin templates), .admin-frame, table.tbl-green-g, and tabset .nav-tabs — automatically inherit the rsm look without per-page edits. The dark granite background on .admin-frame becomes a soft white panel; tbl-green-g headers drop the granite/cloudy reverse-video for muted uppercase labels with a sunrise hover; admin tab strips gain the rsm sunrise underline and wrap on narrow widths.\n\n* Member Admin (the page singled out as looking dated) is restyled directly: the admin-frame/admin-header-background wrapper is replaced with .rsm-admin-page + .rsm-toolbar, the chunky btn-primary buttons become .rsm-btn-cta for the primary action with .rsm-btn-ghost for secondaries and .rsm-btn-danger for delete actions, the per-row Edit input becomes .rsm-btn-ghost.rsm-btn-small, and every <td> carries a data-label so the .rsm-table-responsive wrapper can collapse the table into per-row cards on mobile. The component sass keeps sticky-column behaviour for desktop only and drops the granite header bar.\n","contentHtml":"<h1>27-Apr-2026 — Brevo unsubscribes &amp; blocked-contacts management <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/18\">#18</a></h1>\n<h2><a href=\"https://github.com/nbarrett/ngx-ramblers/actions/runs/24969398442\">build 624</a> — <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/78478d767f84746f2a075221d7a37dec78b7ab29\">commit 78478d7</a></h2>\n<hr>\n<h2>What&#39;s new</h2>\n<ul>\n<li>A new <strong>Unsubscribes</strong> tab on <strong>Mail Settings</strong> lists every contact Brevo is currently suppressing — anyone who unsubscribed via an email link, the Brevo unsubscribe page, or the API, plus contacts blocked by an admin, hard-bounced or flagged as spam. </li>\n<li>Blocks now propagate onto the matching NGX member record so re-imports, auto-subscribes and member admin all respect the most recent opt-out — automatically and visibly. </li>\n<li>And the unsubscribe link in every outgoing email now lands on our own branded page instead of Brevo&#39;s hosted form, with optional reason capture.</li>\n</ul>\n<h2>Highlights</h2>\n<h3>Live view of Brevo&#39;s blocklist</h3>\n<ul>\n<li>Searchable, sortable table of every blocked contact, with reason badges (unsubscribed via email / API / marketing automation, admin-blocked, hard bounce, spam-flagged) and per-sender stats.</li>\n<li>Each row matches the contact to an NGX member by email and shows the matched name plus membership number where known.</li>\n<li>A <strong>Lists</strong> column shows which Brevo lists the contact is associated with at the time of the last sync, plus a &quot;Blocked from receiving email&quot; hint when Brevo&#39;s account-level block is set.</li>\n<li><strong>Date range slider</strong> (last two years) and <strong>per-sender</strong> filter narrow the query server-side; default range is treated as &quot;no filter&quot; and omits date params from the Brevo call.</li>\n<li><strong>Click any contact email</strong> to open that contact&#39;s profile in Brevo in a new tab.</li>\n</ul>\n<h3>Member-level visibility</h3>\n<ul>\n<li>Each NGX member record gains a denormalised <code>emailBlock</code> summary populated by the sync, so other parts of the app can answer &quot;is this person actually emailable?&quot; with one read.</li>\n<li>A concise warning banner appears at the top of the member admin modal&#39;s <strong>Brevo</strong> tab when the contact is blocked, citing reason and date with a deep link straight to the Unsubscribes view to re-enable.</li>\n<li>Every block becomes a permanent <strong>mailListAudit</strong> row keyed by <code>(memberId, listId, blockedAt)</code> — visible on the existing <strong>Brevo</strong> tab inside the member admin modal as part of that member&#39;s history.</li>\n<li>The member&#39;s NGX <code>mail.subscriptions</code> are also flipped to <strong>unsubscribed</strong> for the lists Brevo holds them on, so the local subscription view matches reality.</li>\n<li>A new <strong>&quot;Blocked from receiving email&quot;</strong> filter is available on the Member Admin page so admins can quickly list every blocked member.</li>\n</ul>\n<h3>One-click cleanup</h3>\n<ul>\n<li>A <strong>Remove from blocklist</strong> icon button on each row calls Brevo&#39;s blocklist removal endpoint, clears the matched member&#39;s local <code>emailBlock</code>, and writes an admin-override audit row. A two-step confirm prevents accidental clicks. Subscription state is <strong>not</strong> reset automatically — re-subscribe deliberately on the member&#39;s Brevo tab if appropriate.</li>\n</ul>\n<h3>Bulk-import safety</h3>\n<ul>\n<li>Re-importing the Ramblers HQ CSV continues to create / update members as before, but <strong>auto-subscribe is suppressed for any member with an active <code>emailBlock</code></strong> — even if HQ&#39;s <code>Email Marketing Consent</code> column says yes. The local block is the source of truth for outbound deliverability.</li>\n</ul>\n<h3>Single-pass list assignment on bulk import</h3>\n<ul>\n<li>The initial <code>processCreateContactRequests</code> payload now carries <code>listIds</code> directly, so a fresh bulk import creates the Brevo contact <strong>and</strong> assigns it to the configured lists in one round-trip. Previously contacts came in unassigned and only landed in their lists on a follow-up &quot;Update Brevo Mailing Lists&quot; pass — visible on a fresh import as two consecutive audit rows (&quot;Contact created in Brevo&quot; → &quot;Contact Updated in Brevo: … List Ids → 0: N&quot;). This was largely masked in production because most syncs are updates rather than creates; surfaced via the salesforce-mock synthetic-data path.</li>\n<li>The Brevo create-audit row now mirrors the update-audit and records the full payload (<code>Contact created in Brevo: Attributes -&gt; ..., List Ids -&gt; 0: N</code>) instead of the previous bare <code>Contact created in Brevo</code>. Sets <code>first(listIds)</code> on the audit so the per-list filter on the Member Admin → Brevo tab works for create rows too.</li>\n</ul>\n<h3>Branded unsubscribe page</h3>\n<ul>\n<li>Email footers now point at our own <code>/unsubscribe</code> page instead of Brevo&#39;s hosted landing — recipients land on a page that wears the group&#39;s own branding (header, navbar, footer) rather than <code>sendibt3.com</code>.</li>\n<li>The page <strong>auto-completes the unsubscribe on load</strong> with no form, no email entry, no identification step. The link itself carries a per-recipient HMAC-signed token (secret auto-generated per deployment and stored alongside the existing Brevo config); the token IS the identity, so clicking and going is enough.</li>\n<li>Once the unsubscribe is recorded, the same page offers an <strong>optional</strong> reason form (too-many-emails, not-relevant, no-longer-interested, moved-away, never-signed-up, other) with a free-text &quot;tell us more&quot; field. Skipping is fine — the unsubscribe is already complete.</li>\n<li>Outbound transactional emails now also set RFC 8058 one-click headers (<code>List-Unsubscribe: &lt;https://…&gt;, &lt;mailto:…&gt;</code> and <code>List-Unsubscribe-Post: List-Unsubscribe=One-Click</code>), so Gmail and Apple Mail&#39;s native unsubscribe buttons hit the same API endpoint and complete in one click without rendering the page.</li>\n<li>The confirm endpoint calls Brevo&#39;s <code>updateContact(emailBlacklisted=true)</code> and applies the local <code>emailBlock</code> + <code>mailListAudit</code> row directly (mirroring the events webhook handler), with <code>source: &quot;branded-unsubscribe&quot;</code>. Optional reason submissions write a separate <code>branded-unsubscribe-feedback</code> audit row keyed off the same member.</li>\n<li>Local blocks created via this path use a distinct <code>source</code> value, so the existing sync&#39;s self-heal logic (which only undoes <code>brevo-unsubscribes-sync</code> rows) doesn&#39;t accidentally clear them.</li>\n</ul>\n<h3>List-scoped unsubscribes, activity feed, and clear-all</h3>\n<ul>\n<li>The unsubscribe flow is now <strong>list-scoped</strong> rather than account-wide. The HMAC token in each outgoing email&#39;s unsubscribe link carries the originating list ID alongside the recipient identifier; clicking the link removes the contact from just that Brevo list (<code>removeContactFromList</code>) and flips only the matching local subscription. Account-level blocking is reserved for hard signals — Brevo&#39;s <code>blocked</code> / <code>hard_bounce</code> / <code>spam</code> / <code>complaint</code> events still propagate to the contact&#39;s <code>emailBlock</code> as before.</li>\n<li>The branded unsubscribe page now names the list it removed the recipient from: &quot;We&#39;ve removed X from {listName}. You won&#39;t receive any more emails from this list.&quot; Falls back to the previous wording if the token doesn&#39;t carry a list ID, so older signed links still in inboxes keep working.</li>\n<li><code>mailListAudit</code> rows now record <code>MailListAuditListType</code> (list-scoped vs account-wide) and <code>MailListAuditSource</code>, so the Member Admin → Brevo tab history shows which list each unsubscribe was for and where it came from.</li>\n<li>New <strong>unsubscribe activity feed</strong> (<code>GET /unsubscribes/activity</code>) and <strong>history view</strong> (<code>GET /unsubscribes/history</code>) on the server, surfaced through <code>MailService.queryUnsubscribeActivity</code> / <code>queryUnsubscribeHistory</code> for paginated, sortable display in the Mail Settings UI.</li>\n<li>New <strong>Clear all blocklist</strong> admin action (<code>DELETE /unsubscribes</code>) for resetting the entire local blocklist in one step — for staging / test environments and recovery from sync drift. Two-step confirm at the UI.</li>\n<li>Member admin search filter recognises blocked-from-individual-list as a distinct state from globally blocked, and the Brevo tab now attaches any captured unsubscribe feedback rows alongside the matching block entry.</li>\n<li>Outgoing unsubscribe links now auto-resolve <code>listId</code> server-side when the caller omits it: if the recipient is subscribed to exactly one list, that list ID flows into the HMAC token. Welcome / expiry-warning emails (which don&#39;t pass <code>emailRequest.listId</code>) now go through the list-scoped path rather than the legacy &quot;all local subscriptions disabled&quot; fallback.</li>\n<li><code>APP_URL</code> is now derived from <code>urlService.baseUrl()</code> rather than a hardcoded <code>group.href</code>, so unsubscribe links built while the app is running on <code>localhost:4200</code> point at localhost rather than the production domain. Same for <code>PW_RESET_LINK</code>.</li>\n<li>Audit message dropped the Brevo list-ID suffix: &quot;Unsubscribed from All Ashford Mocks via branded unsubscribe page&quot; rather than the previous &quot;Unsubscribed from All Ashford Mocks (#10) ...&quot;. The list ID is a Brevo implementation detail and not useful in the audit row.</li>\n<li>Sender filter param tolerates the three shapes the UI / Express might send (JSON-encoded array, repeated <code>?sender=</code> params, or comma-separated). Caught exceptions on the unsubscribes / activity routes now <code>console.error</code> the underlying status code, response body, and stack unconditionally — no more silent debugLog when Brevo 5xx&#39;s.</li>\n</ul>\n<h3>Unsubscribes view polish</h3>\n<ul>\n<li>Default sub-tab is now Unsubscribes (was Blocks) and the toggle order matches.</li>\n<li><code>?sub-tab=blocks</code> / <code>?sub-tab=unsubscribes</code> are now honoured on initial load, alongside new <code>start-date</code>, <code>end-date</code>, and <code>sender</code> query params, so the entire filter state of the view is shareable as a URL.</li>\n<li>Activity table now has full sort UI on every column (Name / List / Reason / Unsubscribed At), mirroring the Blocks table; <code>UnsubscribeSortField</code> gained <code>NAME</code> and <code>LIST_NAME</code> values, with a client-side <code>sortedActivity()</code> getter applying the sort.</li>\n<li>Date-range slider now also constrains the activity feed (server applies a Mongo <code>timestamp</code> <code>$gte/$lte</code> filter on the audit collection); previously the slider only affected the Blocks query.</li>\n<li>Slider got more horizontal space (<code>col</code> + <code>col-auto</code> instead of <code>col-sm-9</code> / <code>col-sm-3</code>) so the Clear-filters / Options buttons stop overlapping the &quot;To <date>&quot; label when both are visible.</li>\n<li>Activity row collapses reason and free-text comment into a single pill (<code>Other — &quot;Cara says nooooo!&quot;</code>) instead of a badge plus a separate caption underneath.</li>\n<li>&quot;All lists (legacy link)&quot; relabelled to &quot;Unknown list&quot; for the listId-zero case, and named lists fall back to the locally cached <code>listNameById</code> map when the server activity row doesn&#39;t carry an inline name.</li>\n</ul>\n<h3>Automation, not ad-hoc</h3>\n<ul>\n<li>A scheduled background sync runs every two hours so the Unsubscribes view stays current without an admin opening the page.</li>\n<li>A <strong>Brevo events webhook</strong> captures <code>unsubscribed</code>, <code>blocked</code>, <code>hard_bounce</code>, <code>spam</code> and <code>complaint</code> events in real time, with <code>unsubscribe_revoked</code> / <code>unblocked</code> automatically clearing the local block. The webhook URL and secret can be retrieved from <code>GET /api/mail/webhooks/brevo-events/config</code> and pasted into Brevo&#39;s dashboard.</li>\n<li>Members no longer in Brevo&#39;s blocklist have their local <code>emailBlock</code> <strong>self-healed</strong> on the next full sync, so the local state can never drift past Brevo&#39;s.</li>\n</ul>\n<h3>Help content seeded</h3>\n<ul>\n<li>The in-page help editor on the Unsubscribes tab is pre-populated with documentation explaining the view, side effects, filters, the Remove action, the automation, and the bulk-import gate. Admins can edit further from the page.</li>\n</ul>\n<h3>Salesforce writeback ready, not yet wired</h3>\n<ul>\n<li>Each row carries a Salesforce writeback status (<code>pending</code> / <code>not-applicable</code> / future <code>synced</code> / <code>failed</code>) and the matching membership number is captured at audit-write time, so the future Ramblers HQ Salesforce consent integration can iterate the audit collection directly when the consent endpoint becomes available.</li>\n</ul>\n<h3>Member bulk-load Upload History polish</h3>\n<ul>\n<li>Quick Search input on the Upload History view now baseline-aligns with the Uploaded-at / Member-action selects regardless of where their inline labels wrap.</li>\n<li>Filter row tightened to a single line: each labelled select uses a Bootstrap <code>input-group</code> with the label as <code>input-group-text</code>, the row uses <code>flex-nowrap</code>, and Member Action right-aligns via <code>ms-auto</code> instead of CSS <code>float-end</code>.</li>\n</ul>\n<h3>Other</h3>\n<ul>\n<li>Member Raw Data tab now hides the JSON dump behind an eye-toggle (matching the password-field reveal pattern), since it includes unfiltered Mongo fields that aren&#39;t safe to leave on screen by default.</li>\n<li>New brand SVG favicon.</li>\n</ul>\n<h3><strong>server</strong>: serve index.html no-cache and 404 missing static assets</h3>\n<p>After a deploy, tabs that had a stale index.html (referencing previous\ncontent-hashed bundles like main-GIDJEFWQ.js) would silently fail to\nboot. The SPA catch-all was happily serving index.html for the missing\n.js request, which tripped strict MIME checking (&quot;Expected JavaScript,\ngot text/html&quot;), leaving a blank screen until the user thought to\nhard-refresh.</p>\n<ul>\n<li>index.html (both via express.static and the SPA fallback) now sends\nCache-Control: no-cache, no-store, must-revalidate, so browsers\nalways revalidate and pick up the new bundle hashes.</li>\n<li>The SPA fallback returns 404 for requests whose path ends in a\nknown static-asset extension, so a genuinely missing asset fails\nloudly instead of being masked as HTML.</li>\n</ul>\n<p>Hashed bundle/css/font/image assets remain safely long-cacheable via\ntheir content hashes - only the HTML entry point changes caching.</p>\n<h2>Brevo Unsubscribes &amp; Blocked Contacts</h2>\n<p>A new admin page at <strong>Mail Settings → Unsubscribes</strong> that gives committee members complete visibility of Brevo&#39;s suppression state — every contact who unsubscribed via an email link, the Brevo unsubscribe page, the Brevo API, plus contacts blocked by an admin, hard-bounced or flagged as spam.</p>\n<h3>Two views</h3>\n<p>The toggle at the top-right switches between:</p>\n<ul>\n<li><strong>Unsubscribes</strong> — member-level unsubscribe events, including the originating list and any feedback the contact provided (free-text comment or one of the standard reason codes such as <em>&quot;The content isn&#39;t relevant to me&quot;</em>, <em>&quot;I receive too many emails&quot;</em>, <em>&quot;I&#39;ve moved away from the area&quot;</em>).</li>\n<li><strong>Blocks</strong> — Brevo&#39;s account-level blocklist: hard bounces, spam flags, admin blocks, and contacts who used the global Brevo unsubscribe page.</li>\n</ul>\n<p><img src=\"https://ngx-ramblers.org.uk/api/aws/s3/site-content/ff557f7d-abd0-4aae-853a-7b5dd71df085.png\" alt=\"\"></p>\n<p><em>Unsubscribes tab — member-level unsubscribe events from Brevo, with reason badges and the originating list</em></p>\n<h3>What each row shows</h3>\n<ul>\n<li><strong>Contact Email</strong> — click to open the contact in Brevo in a new tab.</li>\n<li><strong>Sender Email</strong> — the sender against which the block was registered (relevant for transactional events).</li>\n<li><strong>Reason</strong> — Brevo&#39;s classification, colour-coded (hard bounces and spam in red).</li>\n<li><strong>Matched Member</strong> — local NGX member matched by email, with membership number for cross-reference.</li>\n<li><strong>Lists</strong> — Brevo list memberships at the time of the last sync.</li>\n<li><strong>Blocked At / Unsubscribed At</strong> — when Brevo recorded the event.</li>\n</ul>\n<h3>Filters</h3>\n<ul>\n<li><strong>Search</strong> is client-side and filters across contact email, sender email, and reason text.</li>\n<li><strong>Reason</strong>, <strong>Sender</strong> and the <strong>Date Range</strong> slider are server-side filters — applying them re-fetches a narrowed set from Brevo.</li>\n<li><strong>Clear filters</strong> resets all server-side filters.</li>\n</ul>\n<p><img src=\"https://ngx-ramblers.org.uk/api/aws/s3/site-content/626857e4-32f9-4efd-bd18-a3f557aff765.png\" alt=\"\"></p>\n<p><em>Blocks tab — Brevo&#39;s account-level blocklist (hard bounces, spam flags, admin blocks)</em></p>\n<h3>Side effects of opening the page</h3>\n<p>Each row that matches an NGX member writes a <code>mailListAudit</code> row keyed by <code>(memberId, listId, blockedAt)</code> so the block becomes part of that member&#39;s permanent audit trail (visible on the <strong>Brevo</strong> tab of the member admin modal). Each matched member&#39;s <code>emailBlock</code> summary is updated to reflect the latest Brevo state, and the contact&#39;s NGX <code>mail.subscriptions</code> are flipped to <strong>unsubscribed</strong> for the lists Brevo holds them on.</p>\n<h3>Actions</h3>\n<p><strong>Remove from blocklist</strong> (trash icon) calls Brevo&#39;s <code>DELETE /smtp/blockedContacts/{email}</code>, then clears the matched member&#39;s local <code>emailBlock</code> and writes an audit row recording the manual override. Subscription state on the member is <strong>not</strong> changed automatically — re-subscribe explicitly via the member&#39;s Brevo tab if appropriate.</p>\n<p>The <strong>Options</strong> dropdown also exposes a <strong>Clear all blocks</strong> action that removes every contact from Brevo&#39;s blocklist and clears every local <code>emailBlock</code> record. NGX member records are not deleted.</p>\n<h3>Automation</h3>\n<ul>\n<li>A scheduled cron job runs the sync every two hours so the data here stays current without an admin opening the page.</li>\n<li>A Brevo webhook endpoint (<code>POST /api/mail/webhooks/brevo-events?token=...</code>) writes the same audit and <code>emailBlock</code> updates in real time when Brevo emits <code>unsubscribed</code>, <code>blocked</code>, <code>hard_bounce</code>, <code>spam</code>, or <code>complaint</code> events. The webhook URL is configured inside Brevo&#39;s account settings.</li>\n</ul>\n<h3>Bulk-import safety</h3>\n<p>When a Ramblers HQ CSV is re-imported, members already flagged with <code>emailBlock</code> are still updated, but they will not be auto-subscribed to mail lists regardless of what HQ&#39;s <code>Email Marketing Consent</code> column says. The local block is the source of truth for outbound deliverability.</p>\n<h3><strong>admin</strong>: rsm-style restyle of admin pages with mobile-friendly tables (<a href=\"https://github.com/nbarrett/ngx-ramblers/issues/18\">#18</a>)</h3>\n<ul>\n<li><p>Replaces the dark-panel, chunky-button look across admin pages with the lighter, slim-row, sunrise-underline pattern from the ramblers-salesforce-mock admin UI, and adds a collapse-to-cards layout below 576px so admin tables work on phones.</p>\n</li>\n<li><p>Introduces a new partial at assets/styles/rsm-admin.scss, imported from styles.sass after the legacy frames and tables. The partial provides opt-in utility classes (.rsm-admin-page, .rsm-toolbar, .rsm-admin-table, .rsm-table-responsive, .rsm-tabs/.rsm-tab, .rsm-btn-cta/.rsm-btn-ghost/.rsm-btn-danger/.rsm-btn-small, .rsm-form/.rsm-form-inline/.rsm-form-actions, .rsm-card-grid/.rsm-card, .rsm-chip/.rsm-muted/.rsm-error) that honour existing tokens (--btn-min-height, --btn-pad-y/x, --radius-1) and reuse the existing $ramblers-colour-* variables for palette consistency.</p>\n</li>\n<li><p>The same partial scopes overrides under app-page so the existing admin wrappers — .thumbnail-admin-edit (used by 41 admin templates), .admin-frame, table.tbl-green-g, and tabset .nav-tabs — automatically inherit the rsm look without per-page edits. The dark granite background on .admin-frame becomes a soft white panel; tbl-green-g headers drop the granite/cloudy reverse-video for muted uppercase labels with a sunrise hover; admin tab strips gain the rsm sunrise underline and wrap on narrow widths.</p>\n</li>\n<li><p>Member Admin (the page singled out as looking dated) is restyled directly: the admin-frame/admin-header-background wrapper is replaced with .rsm-admin-page + .rsm-toolbar, the chunky btn-primary buttons become .rsm-btn-cta for the primary action with .rsm-btn-ghost for secondaries and .rsm-btn-danger for delete actions, the per-row Edit input becomes .rsm-btn-ghost.rsm-btn-small, and every <td> carries a data-label so the .rsm-table-responsive wrapper can collapse the table into per-row cards on mobile. The component sass keeps sticky-column behaviour for desktop only and drops the granite header bar.</p>\n</li>\n</ul>\n"}