24-Jun-2026 — Upgrade Brevo SDK to v5, add a subscription audit trail with Salesforce consent writeback #168
build 720 — commit ce096af
The Brevo email integration now runs on the current @getbrevo/brevo v5 SDK, and the member-to-Brevo-to-Salesforce data flow is now consistent: a user action makes the full change to every system at the moment it happens, every subscription change is audited, and a member who fully unsubscribes is removed from Brevo and has their consent written back to Salesforce in the same step. The full reconcile is demoted to a repair and backstop rather than the thing that completes changes.
Brevo SDK v5 and the Contact Import migration
The v5 SDK replaces the December 2023 pre-release beta (v2.0.0-beta.4) that depended on the unmaintained request HTTP library. In the same change the member-to-Brevo list synchronisation moves off the Bulk Contact Update endpoint, which Brevo retires on 30 October 2026, onto the Contact Import API.
- Every Brevo feature on the site (transactional email, campaigns, contacts, lists, folders, segments, senders, domains, account) now uses the v5 client.
- The member list sync keeps working after the 30 October 2026 endpoint retirement.
- The deprecated
requestdependency is removed; v5 uses native fetch with no external dependencies.
Member list updates still run from the admin Brevo tools. Behind the scenes each batch is now submitted as a Contact Import job rather than a single synchronous call. Brevo accepts the job and processes it shortly afterwards, so the request returns once the job is accepted rather than once every contact is written. Contacts are matched by email and updated in place, and new contacts are created if they do not yet exist, which is the same outcome the sync produced before. The duplicate-contact fallback path is unchanged.
Incremental and real-time member sync
The member-to-Brevo list sync is incremental. Each member carries a signature of the values pushed to Brevo (email, names, member attributes, Brevo-eligible list ids, and whether Head Office consent is withheld) plus the list ids last synced. The pre-send reconcile first checks whether any member in the audience differs from its signature; if none do, it returns without contacting Brevo. When a member is saved through the admin member editor or the contact-preferences page, only that member is pushed, and a Brevo failure there is logged and left for the next reconcile rather than blocking the save.
User-invoked actions now perform the full extent of the change. The real-time per-member sync deletes the Brevo contact when a member becomes ineligible for every list (their last subscription removed, or consent withheld), instead of only removing them from a list and leaving the contact for the reconcile to clean up. Members who leave some lists but keep others still get the lighter remove-from-list and update treatment. The full reconcile remains as the initial seed and the periodic repair, and stamps the signature on every member it syncs so subsequent sends can skip.
Subscription changes are now audited at the point of save
Every change to a member's mailing-list subscriptions is recorded as an audit entry, regardless of which mechanism made it, so the trail can no longer be bypassed by a code path that forgets to write one.
- The server diffs a member's subscriptions before and after each save and writes a "Subscribed to / Unsubscribed from
- list" audit for each transition, with the acting user recorded against it. This is the one place every save flows through, so the admin member editor, the self-service contact-preferences page, bulk loads and the reconcile are all covered.
- A new member's initial auto-subscribes are audited, and a subscription removed because its list was deleted is audited.
- The scattered client-side toggle audits and their broadcast plumbing are removed, so a subscription change is recorded exactly once, by the server.
Salesforce consent writeback
When a member's active subscribed-list count drops to zero, their consent is written back to Salesforce in the same save, and this fires from every page that can clear subscriptions (member admin and the self-service email-subscriptions page). This matters because a fully unsubscribed member receives no further emails and so can never use a branded-unsubscribe link again; if the writeback did not happen at that moment it could never happen, and the local, Brevo and Salesforce states would fall out of step with no way back into sync. The writeback is locked to the Brevo contact deletion above, both keyed off the same "subscriptions reached zero" event.
The writeback authenticates with the site's active group code (systemConfig.group.groupCode), not the first configured API token. Leftover tokens for other groups can sit in the Salesforce config without redirecting a writeback to the wrong tenant; if the active group has no token it fails with a clear "no token for group <code>" message instead of silently using another tenant. The writeback triggers on subscriptions reaching zero rather than on Brevo-eligibility, so a member whose ineligibility comes from withheld consent (a value that usually originated in Salesforce) is not written straight back.
Marketing consent and Brevo list membership
Marketing-consent withdrawal removes a member from their Brevo lists, not just excludes them from NGX-composed sends as before. A member whose relevant marketing consent is withheld is treated as eligible for no Brevo lists, so the reconcile removes or deletes their contact and the real-time path removes them from their lists. In Insight Hub parity mode (the default, granular consent off) the rule is Head Office consent: emailMarketingConsent false when respectHeadOfficeConsent is on. Once granular consent is enabled, group-level consent takes over: groupMarketingConsent false withholds the member, because for a group's own site that is the consent that applies (per #209, Head Office consent represents Central Office marketing, not group marketing). The decision is driven by the member's own persisted consent fields rather than by reading the admin-only Salesforce config in the member-facing client, which avoids exposing the per-group API keys held in that config to ordinary logged-in members. The base memberSubscribed / memberSubscribedToAnyList / subscribedListIds helpers are left consent-agnostic so the member-admin "subscribed without consent" diagnostic filter still works.
The member modal's "Subscriptions do not guarantee delivery" alert now reflects the actual send settings: the consent warning shows only when respectHeadOfficeConsent is on, and the block warning only when respectEmailBlocks is on, matching what a real send would do.
Brevo daily send allowance now comes from the account
The manual "daily campaign limit" field is removed. The remaining daily campaign allowance is read directly from the Brevo account's sendLimit credits, the value Brevo actually reports and enforces. A paid plan with no daily hold reads as no limit. The separate "credits available out of 300 emails/day" panel is unchanged.
Reliability fixes
- The Brevo rate limiter is reworked from a target rate into a hard reservoir (token bucket) refilling at the documented contacts limit with headroom, plus full-jitter backoff, so a large bulk delete no longer produces a synchronised 429 retry storm.
- The bulk member upsert (
createOrUpdateAll) no longer crashes the server when one upsert fails; it returns a single response and logs the cause. A long-standing double-send was the trigger. - Bulk delete refreshes the member list before running the Brevo reconcile, so a just-deleted member is no longer re-created in Brevo and then failed on write-back.
- A benign "contact does not exist" 404 from the contact-info and campaign-stats lookups is no longer logged as an error with a stack trace; it is treated as the expected "this member is not a Brevo contact" case.
Mail-admin UX
- A shared subscriber-count component (the "N subscribers" hint with a names tooltip) is reused by the email composer and the mail-list-settings page, replacing duplicated logic. The settings page also shows a sync-health view: the local count, Brevo's reported count, and a warning when they differ.
- The member modal remembers the selected tab and Brevo sub-tab across opens, and its tab strip wraps instead of overflowing on mobile.
Why now
Brevo notified us on 26 May 2026 that the Bulk Contact Update API is retired on 30 October 2026. Every deployed site runs this same code against its own Brevo account, so all sites would lose member list syncing after that date without this change.
How to try it out
Against a real Brevo account on a deployed environment:
- Run a member list update from the admin Brevo tools and confirm contacts appear in the expected lists with their attributes.
- Edit a member in the member editor and confirm the change reaches Brevo without sending an email; edit contact preferences as a member and confirm the same.
- Unsubscribe a member from their last list (from the member admin modal or the self-service email-subscriptions page) and confirm, together: the local "Unsubscribed from
- list" audit, the Brevo contact deleted, and a "Salesforce consent writeback succeeded" audit.
- Open the email composer and start a send when nothing has changed; confirm the pre-send sync reports it is already in sync and does not pull the whole contact list.
- Send a test transactional email and a test campaign.
- Open the unsubscribe and blocked-contact views and confirm they load; confirm the sender and domain management screens list and update correctly.
Technical changes
- Bumped @getbrevo/brevo from 2.0.0-beta.4 to 5.0.4 in server/package.json and removed the now-unused
requestdependency. - Added a single brevoClient() factory in brevo-config.ts returning new BrevoClient({apiKey}), replacing the per-class new XxxApi() + setApiKey() pattern across roughly 45 files.
- Migrated every call to the v5 request-object form (client.contacts., client.transactionalEmails., client.emailCampaigns., client.senders., client.domains., client.account.). Lists, folders, segments and attributes now live under client.contacts.
- Reworked the shared response and error handling in common/messages.ts and common/error-log.ts from the v2 {response, body} tuple and HttpError to the v5 typed responses (withRawResponse()) and BrevoError. Added handleErrorAllowingNotFound for the benign contact-not-found case.
- Reworked common/rate-limiting.ts to a Bottleneck reservoir refilling at the documented contacts rate (with a safety factor) and minTime smoothing, with full-jitter exponential backoff on 429s.
- Rewrote contacts-batch-update.ts to call client.contacts.importContacts with a JSON body, grouping requests by their target list ids, in place of the retiring updateBatchContacts call.
- Replaced response casts and
anywith the real v5 SDK response types throughout. This corrected two shapes the oldanyhad hidden: the segments handler wrapped each segment instead of returning the declared flat shape, and the domain DNS mapping read camelCase fields when v5 returns snake_case dns_records. Widened Account.marketingAutomation.key to optional in mail.model.ts. - Added member.mail.lastSyncedSignature and member.mail.lastSyncedListIds to track per-member Brevo sync state.
- Added brevoContactSignature / memberRequiresBrevoSync / syncChangedMembersToBrevo to mail-list-updater.service.ts. The real-time path now also deletes contacts for members who became ineligible for all lists, and excludes them from the list-removal set.
- Added server/lib/mongo/controllers/member-subscription-audit.ts (subscriptionTransitions diff + auditSubscriptionChanges) and wired it into the member controller's create/update/createOrUpdateAll. Removed the client toggle-audit broadcast plumbing from mail-subscription-setting, member-admin-modal and email-subscriptions.
- Added server/lib/salesforce/member-consent-writeback.ts (becameFullyUnsubscribed + writeBackFullOptOuts) and wired it into the member controller's update/createOrUpdateAll, so a subscriptions-to-zero save writes consent back to Salesforce from every page.
- Changed pushSalesforceConsent to select its token via the site's active group code (new activeGroupCodeWithToken helper) instead of firstConfiguredToken, returning GroupCodeToken from salesforce.model.ts.
- Removed dailyCampaignSendLimit from the config model, seeds and admin input; campaignDailyAllowance / campaignOverflowNotice and the campaign-queue summary now derive the allowance from the account.
- Made marketing consent authoritative over Brevo list membership via brevoConsentWithheld / brevoEligibleListIds / brevoEligibleForAnyList, routed through the reconcile and real-time filters and the sync signature. Persisted the granular consent fields (groupMarketingConsent / areaMarketingConsent / otherMarketingConsent) on the Member and bulk-load schemas. Added apiKeysByGroupCode to the redacted sensitiveKeys set as defence in depth.
- Added a shared list-subscriber.service.ts and list-subscriber-count.ts component reused by the composer and mail-list-settings, with the settings page showing local vs Brevo counts and a discrepancy warning. Member modal tab/sub-tab persisted via the URL and wrapped on mobile.
Verification
Server tsc clean, frontend type-check (tsc) clean, all 812 server unit tests pass (including new units for the subscription diff, the active-group token selection, the account-derived allowance and the full-opt-out trigger). Not yet exercised against a live Brevo or Salesforce account, and the frontend was not run through a full Angular build.