# 25-May-2026 — campaign activity in the member timeline + unchanged-subject send reminder [#279](https://github.com/nbarrett/ngx-ramblers/issues/279)

## [build 655](https://github.com/nbarrett/ngx-ramblers/actions/runs/26419860264) — [commit fc4d889](https://github.com/nbarrett/ngx-ramblers/commit/fc4d8894574b17935dceef0a03232839a40ca8f5)

_____

## Brevo contact activity: campaigns in the timeline

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.

- **Campaigns appear in Recent history**
- sends, deliveries, opens, clicks, bounces and unsubscribes from email campaigns are interleaved with the transactional walk emails in one continuous, date-ordered list.
- **Reads like transactional rows**
- each campaign row shows the campaign's subject as the title and the sender as "from {role}" (e.g. "Newsletter
- 25 May 2026, 11:19 am
- from Chairman"), with no separate "Campaign" labelling.
- **Preview campaign emails**
- the most recent row of each campaign gets the same "Preview email" toggle as transactional rows, fetching the campaign's stored HTML via a new `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.
- **Campaign names resolved** via a new `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".
- **Channels tidy-up**
- removed the redundant "Lists" row (it duplicated the Subscriptions checkboxes) and centred the Refresh / Open in Brevo buttons.

## Composer: unchanged-subject send reminder

- At the Send step, if the subject still equals the email type's default text and has not been personalised, a reminder appears with a link back to the Compose step. It is a nudge, not a block
- you can still send as-is.
- Exempt for email types that use a Subject Prefix or Suffix, since those compose their subject dynamically.

## Fix: campaign emails greeted every recipient by the sender's name

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.

## Fix: transactional batch send could build for and deliver to the wrong member

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

## Fix: campaign footer addressed every recipient by the sender's email

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.

## Fix: campaign walk photos broke in Gmail when the filename had a space

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

## Fix: image editor fetched a malformed `//site-content` URL

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

## Fix: recipient picker showed members who had unsubscribed from the list

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.

## Fix: composer lost the chosen event image, and reopened drafts/sent emails lost their events

Two related problems with auto-included events:

- **The chosen photo reset to the first on send.** Moving between steps updates a query param, which re-ran `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 event
- matched by id and clamped to the media count
- and re-derives the image from it, so a second-photo pick survives navigation and the send.
- **Reopened drafts and edited sent emails came back with no events.** The saved date filter persisted only `fromDate.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.