The member's Email & subscriptions tab now shows campaign emails alongside transactional ones in a single "Recent history" timeline, matching how Brevo presents a contact's activity.
GET /api/mail/campaigns/:id/content endpoint (Brevo getEmailCampaign) and rendering it in the sandboxed iframe. Brevo does not expose per-recipient rendered campaign bodies, so the stored template is shown and its {{contact.FIRSTNAME}} greeting tokens appear literally rather than resolved to a name.GET /api/mail/campaigns endpoint. Brevo caps the campaigns page size at 100, so the request is limited to 100; campaigns older than the most recent 100 fall back to "Campaign #id".A branded send to an entire list goes out as a Brevo campaign, which carries a single global params object. The server substitutes {{params.memberMergeFields.FNAME}} into the campaign HTML once at create time, using the params the composer built from the logged-in member - so every recipient saw the sender's first name in the greeting ("Hi Nick,") instead of their own. Selected-member sends use the transactional path, which builds params per recipient, so they were unaffected. That is why it showed on EKWG (a real list send) but not on staging (small selected-member tests). Entire-list is the default recipient mode, so most branded sends take the campaign path.
sendCampaign() now rewrites the per-recipient member merge tokens (FNAME, LNAME, FULL_NAME, EMAIL) to their Brevo contact-attribute equivalents ({{contact.FIRSTNAME}} etc.) before creating the campaign. Contact attributes are the only per-recipient values Brevo resolves within a campaign, and they survive the server-side params substitution, so each recipient is now greeted by their own name. MEMBER_NUM stays a params token as it has no contact-attribute equivalent; group-wide system tokens are correct to resolve to a single value.
processBatch() paired each recipient entry with a member document by array position, but the members are loaded with _id: { $in: request.memberIds }, which does not preserve the input order. When the database returned rows in a different order, the suppression and consent checks ran against one member while the email was built for, and delivered to, another - an unsubscribed member could be sent to, and the send history was attributed to the wrong person. The member is now looked up by id from the existing membersById map rather than by position.
The same campaign limitation hit the email footer: the shared branded layout renders "Sent to {{params.memberMergeFields.EMAIL}}", which is substituted once against the global params, so every recipient saw the sender's address rather than their own. renderBrandedTemplate() now takes a campaign flag (set only by create-campaign.ts) that rewrites the member merge tokens in the assembled layout and template to their Brevo contact-attribute equivalents before the params substitution, so the footer - and any member token in the template chrome - resolves per recipient. Transactional sends, the template preview and the forgot-password email are unaffected.
Ramblers Walks Manager photo URLs are stored with literal spaces in the filename (e.g. .../2026-02-24 (2).jpg). Browsers and Brevo's hosted "view in browser" tolerate the space, but Gmail rewrites every image through Google's proxy and encodes the space as +, which the Ramblers CDN treats as a literal character and 404s - so the photo broke in the inbox while looking fine in the browser view, and Brevo could not cache it either. imageSource() now emits remote image URLs with spaces encoded as %20, so Brevo caches them and Gmail loads them like any other photo. On the web it is a no-op, since browsers already treat a bare space as %20.
//site-content URLOpening the cropper on an existing self-hosted image (a banner/header override, or an article-block image) loads the current image via /api/aws/url-to-file. remoteUrlToBlob() first ran the URL through removeS3PrefixFrom(), which did url.replace("api/aws/s3", "") on a full absolute URL - collapsing the surrounding slashes into //site-content/... and stripping the proxy prefix the image is actually served behind, so the server's https.get could not retrieve it. The URL is now passed through unchanged (the server fetches it via its own proxy) and the now-unused removeS3PrefixFrom() has been removed.
When narrowing "Specific members" to a mailing list, the picker pool included anyone currently subscribed OR anyone who had unsubscribed from that list, so unsubscribed members appeared as greyed rows and the picker count ("4 of 8") disagreed with the list count ("4 members"). The narrow-list pool now contains only currently-subscribed members, matching the list count. "Any member" mode is unchanged.
Two related problems with auto-included events:
populateGroupEvents() and rebuilt the event list, preserving only which events were ticked and resetting each one's selectedMediaIndex to 0 (image back to the first photo). The rebuild now preserves the prior selectedMediaIndex per eventfromDate.value, but the events query filters on fromDate.date (the Date object, which cannot survive JSON), so after restore .date was undefined and the query matched nothing. The restore now rebuilds the full DateValue from the saved millis, so events repopulate. The draft also persists the chosen selectedMediaIndex per event and restores it, so the picked photo holds when a saved composition is reopened.