27-Apr-2026 — Brevo unsubscribes & blocked-contacts management #18
build 624 — commit 78478d7
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
emailBlocksummary 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.subscriptionsare 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'sEmail Marketing Consentcolumn says yes. The local block is the source of truth for outbound deliverability.
Single-pass list assignment on bulk import
- The initial
processCreateContactRequestspayload now carrieslistIdsdirectly, 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 bareContact created in Brevo. Setsfirst(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
/unsubscribepage instead of Brevo's hosted landing — recipients land on a page that wears the group's own branding (header, navbar, footer) rather thansendibt3.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:…>andList-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 localemailBlock+mailListAuditrow directly (mirroring the events webhook handler), withsource: "branded-unsubscribe". Optional reason submissions write a separatebranded-unsubscribe-feedbackaudit row keyed off the same member. - Local blocks created via this path use a distinct
sourcevalue, so the existing sync's self-heal logic (which only undoesbrevo-unsubscribes-syncrows) 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'sblocked/hard_bounce/spam/complaintevents still propagate to the contact'semailBlockas 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.
mailListAuditrows now recordMailListAuditListType(list-scoped vs account-wide) andMailListAuditSource, 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 throughMailService.queryUnsubscribeActivity/queryUnsubscribeHistoryfor 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
listIdserver-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 passemailRequest.listId) now go through the list-scoped path rather than the legacy "all local subscriptions disabled" fallback. APP_URLis now derived fromurlService.baseUrl()rather than a hardcodedgroup.href, so unsubscribe links built while the app is running onlocalhost:4200point at localhost rather than the production domain. Same forPW_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 nowconsole.errorthe 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=unsubscribesare now honoured on initial load, alongside newstart-date,end-date, andsenderquery 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;
UnsubscribeSortFieldgainedNAMEandLIST_NAMEvalues, with a client-sidesortedActivity()getter applying the sort. - Date-range slider now also constrains the activity feed (server applies a Mongo
timestamp$gte/$ltefilter on the audit collection); previously the slider only affected the Blocks query. - Slider got more horizontal space (
col+col-autoinstead ofcol-sm-9/col-sm-3) so the Clear-filters / Options buttons stop overlapping the "To" 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
listNameByIdmap 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,spamandcomplaintevents in real time, withunsubscribe_revoked/unblockedautomatically clearing the local block. The webhook URL and secret can be retrieved fromGET /api/mail/webhooks/brevo-events/configand pasted into Brevo's dashboard. - Members no longer in Brevo's blocklist have their local
emailBlockself-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/ futuresynced/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-groupwith the label asinput-group-text, the row usesflex-nowrap, and Member Action right-aligns viams-autoinstead of CSSfloat-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.

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.

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 andemailBlockupdates in real time when Brevo emitsunsubscribed,blocked,hard_bounce,spam, orcomplaintevents. 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)
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
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.