# 27-Apr-2026 — Brevo unsubscribes & blocked-contacts management [#18](https://github.com/nbarrett/ngx-ramblers/issues/18)
## [build 624](https://github.com/nbarrett/ngx-ramblers/actions/runs/24969398442) — [commit 78478d7](https://github.com/nbarrett/ngx-ramblers/commit/78478d767f84746f2a075221d7a37dec78b7ab29)

_____

## What's new

* 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. 
* 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. 
* 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.



## Highlights

### Live view of Brevo's blocklist

- 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.
- Each row matches the contact to an NGX member by email and shows the matched name plus membership number where known.
- 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.
- **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.
- **Click any contact email** to open that contact's profile in Brevo in a new tab.

### Member-level visibility

- 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.
- 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.
- 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.
- 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.
- A new **"Blocked from receiving email"** filter is available on the Member Admin page so admins can quickly list every blocked member.

### One-click cleanup

- 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.

### Bulk-import safety

- 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.

### Single-pass list assignment on bulk import

- 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.
- 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.

### Branded unsubscribe page

- 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`.
- 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.
- 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.
- 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.
- 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.
- 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.

### List-scoped unsubscribes, activity feed, and clear-all

- 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.
- 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.
- `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.
- 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.
- 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.
- 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.
- 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.
- `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`.
- 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.
- 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.

### Unsubscribes view polish

- Default sub-tab is now Unsubscribes (was Blocks) and the toggle order matches.
- `?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.
- 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.
- 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.
- 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.
- 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.
- "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.

### Automation, not ad-hoc

- A scheduled background sync runs every two hours so the Unsubscribes view stays current without an admin opening the page.
- 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.
- 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.

### Help content seeded

- 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.

### Salesforce writeback ready, not yet wired

- 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.

### Member bulk-load Upload History polish

- 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.
- 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`.

### Other

- 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.
- New brand SVG favicon.

### **server**: serve index.html no-cache and 404 missing static assets

After a deploy, tabs that had a stale index.html (referencing previous
content-hashed bundles like main-GIDJEFWQ.js) would silently fail to
boot. The SPA catch-all was happily serving index.html for the missing
.js request, which tripped strict MIME checking ("Expected JavaScript,
got text/html"), leaving a blank screen until the user thought to
hard-refresh.

- index.html (both via express.static and the SPA fallback) now sends
Cache-Control: no-cache, no-store, must-revalidate, so browsers
always revalidate and pick up the new bundle hashes.
- The SPA fallback returns 404 for requests whose path ends in a
known static-asset extension, so a genuinely missing asset fails
loudly instead of being masked as HTML.

Hashed bundle/css/font/image assets remain safely long-cacheable via
their content hashes - only the HTML entry point changes caching.

## Brevo Unsubscribes & Blocked Contacts

A 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.

### Two views

The toggle at the top-right switches between:

* **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"*).
* **Blocks** — Brevo's account-level blocklist: hard bounces, spam flags, admin blocks, and contacts who used the global Brevo unsubscribe page.

![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/ff557f7d-abd0-4aae-853a-7b5dd71df085.png)

*Unsubscribes tab — member-level unsubscribe events from Brevo, with reason badges and the originating list*

### What each row shows

* **Contact Email** — click to open the contact in Brevo in a new tab.
* **Sender Email** — the sender against which the block was registered (relevant for transactional events).
* **Reason** — Brevo's classification, colour-coded (hard bounces and spam in red).
* **Matched Member** — local NGX member matched by email, with membership number for cross-reference.
* **Lists** — Brevo list memberships at the time of the last sync.
* **Blocked At / Unsubscribed At** — when Brevo recorded the event.

### Filters

* **Search** is client-side and filters across contact email, sender email, and reason text.
* **Reason**, **Sender** and the **Date Range** slider are server-side filters — applying them re-fetches a narrowed set from Brevo.
* **Clear filters** resets all server-side filters.

![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/626857e4-32f9-4efd-bd18-a3f557aff765.png)

*Blocks tab — Brevo's account-level blocklist (hard bounces, spam flags, admin blocks)*

### Side effects of opening the page

Each 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.

### Actions

**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.

The **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.

### Automation

* A scheduled cron job runs the sync every two hours so the data here stays current without an admin opening the page.
* 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.

### Bulk-import safety

When 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.

### **admin**: rsm-style restyle of admin pages with mobile-friendly tables ([#18](https://github.com/nbarrett/ngx-ramblers/issues/18))

* 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.

* 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.

* 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.

* 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.
