Skip to content

[pull] main from TryGhost:main - #1478

Merged
pull[bot] merged 17 commits into
code:mainfrom
TryGhost:main
Sep 9, 2026
Merged

pull[bot] merged 17 commits into
code:mainfrom
TryGhost:main

Conversation

@pull

@pull pull Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

luissazevedo and others added 17 commits September 9, 2026 12:33
ref https://linear.app/ghost/issue/BER-3946/update-newsletter-sending-status-copy

The pending-send card shown on the Overview and Newsletter tabs while an
email is going out read "This newsletter is still sending". The "still"
gives the message a negative tone for what is a normal in-progress state.
Replaced it with "Your newsletter is being sent", which matches the tone
of the sending banner and the post-publish success modal.
closes [NY-1537](https://linear.app/ghost/issue/NY-1537/)
closes [NY-1535](https://linear.app/ghost/issue/NY-1535/)

## Context

Automation emails were switched to the bulk Mailgun domain in
[#29728](<#29728>). As part of
that work, responsibility for applying `bulkEmail:mailgun:tag` (ex:
`blog-{site_id}`) moved from the shared
Mailgun client to individual callers. Newsletter sends continued
applying the configured site tag, but automation sends bypassed that
provider and initially sent only the `automation-email` classification.

Automation analytics had originally been introduced in
[#29333](<#29333>) with a filter
containing only `automation-email`.

[#30093](<#30093>) restored the
configured site tag on outgoing automation emails. Automation analytics
intentionally continued querying only `automation-email` during the
rollout window so events from previously sent emails would not be
excluded.

That rollout window has now passed. This change completes the transition
by querying automation events using both `automation-email` and the
configured site tag, preventing sites on shared Mailgun domains from
fetching one another's automation events.

### Relevant commits

*
[`58244a1`](<58244a1>)
switched automation emails to the bulk Mailgun domain and moved
configured tag handling out of the shared Mailgun client.
*
[`f84ed756`](<f84ed75>)
introduced automation email analytics using the `automation-email`
filter.
*
[`d64be44f`](<d64be44>)
restored the configured site tag on outgoing automation emails while
leaving analytics filtering unchanged for the transition period.

## Testing

Manually tested, and confirmed that automation email analytics jobs
include the `bulkEmail:mailgun:tag` in the query (via a temporary
console log), and confirmed that email opens still populate for
automations.
no ref

This change should have no user impact.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

Which of the extra fields a publisher defines about their members come back is about
to depend on which side of Ghost is asking, and several callers said nothing and
inherited staff as the answer. That is the wrong thing to inherit: a surface that
silently loses these fields sends an incomplete payload, which someone notices,
while the other default hands a reader fields that were never theirs, which nobody
does. Fetching them is now something a caller asks for, and the surfaces that show
them say whose view they are showing. Two callers had been quietly relying on the
old default. The unsubscribe link, which identifies someone by a signed link rather
than a session, was loading every value a member holds onto the request and then
discarding all of it behind a fixed list of newsletter preferences. The CSV import
and export declared their own narrower view of the fields service with the argument
missing altogether, which TypeScript accepts, so it had been arriving as nothing at
all.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

A publisher collecting a delivery address and an internal note about the same member
wants different answers to who may see each, so which of the extra fields they define
are open to the member whose record it is now belongs to the field rather than to the
feature. A field can be closed, readable, or readable and writable, and it is closed
when it is made, including every field that already exists. Nothing infers the setting
from a field's name, type or use, and no client that has never heard of it can open
one, because the permissive answer here publishes what a site has already collected to
the people it was collected about.

A field closed to members behaves, to that member, exactly as a field nobody has
defined: absent from what they are offered, absent from their own record, and a write
naming it refused in the same words a write naming nothing gets. That is not two code
paths written to agree. The narrowing happens in the query, so a closed field is never
fetched and "unknown field" is the only answer left to give. Fetching everything and
dropping what the audience may not have leaves a correct answer one forgotten filter
away, and the way that fails is silent. It is also why there is no longer a way to ask
for the site's fields without saying who is asking.

The filter is unconditional, staff included. They are narrowed to a list that happens
to hold every level rather than routed around the clause, so there is no branch to
reach by accident and an audience nobody has taught this about narrows to nothing at
all. Getting it wrong costs a publisher sight of their own fields, which somebody
reports within the hour. The other direction is the one nobody sees.

A member is refused differently when they may read a field but not change it, because
they are looking at it and telling them it does not exist would be a lie about
something on their screen. A site whose every field is closed carries no such key in a
member's payload at all, rather than an empty one: an empty bag says a publisher
collects something about them without saying what. An access change is recorded in the
history with the level it became and the level it was, unlike the other edits, because
who could read a member's answers, and from when, gets asked long after the current
setting has stopped being the answer.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

Deciding who a field is for now happens where a publisher makes the field, rather than
against the API by hand. The choice is one of three rather than two switches, because
the levels are ordered: a member cannot usefully change something they are not shown.
Opening a field says, on the spot, that anything already recorded in it becomes visible
to the member, which is the part nobody would expect. It reads as a change of display
and is a disclosure of everything staff have written there since the field existed. Who
each field is for also reads off the list itself, because which of them members can
reach is a question asked of the whole list rather than of one field at a time. Admin
ships separately from Core, and a Core that predates the setting sends no answer at
all; that reads as staff-only, in one place, since guessing the other way would draw a
field as open on a Ghost that has no such idea and the publisher would believe it.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

A publisher sets this in Settings and a member lives with the result on the other side
of Ghost, through a different API, signed in as themselves. Until now nothing watched
both ends at once: the Admin tests drive the control against a faked server, and the
API tests prove the rules with no publisher and no browser, so a control that saved
the setting somewhere the enforcement never read would leave both green. This defines
a field through Settings, fills it in on a member, signs a real member in through the
magic link and asks what they are told, then opens and closes the field and asks
again. It reads the member's own side over its API rather than through Portal, which
does not render these yet.

Two of the Admin tests were the weaker half of what this now proves and have gone;
what stays there is the disclosure warning, whose condition is the app's own, and the
older backend that sends no setting at all, which no real Ghost can be made to serve.

Also removes a self-referencing type on the definitions query. The type is read off
the function's own signature, so naming it as the return type made it circular, which
the main typecheck accepted as an implicit any and the E2E workspace's stricter one
refused.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

A test had half as long to finish on a laptop as it had on CI, so a correct test could
fail in front of the person writing it and pass once pushed. That is the worst way for
a suite to be wrong: it costs an investigation every time, and it teaches everyone to
disbelieve a local failure, which is the one signal that arrives early enough to be
cheap. Half a minute is also not the difference between a fast iteration loop and a
slow one, so the shorter budget was buying nothing to offset that.

It was not a decision anybody made. The two were deliberately brought together at
thirty seconds once; a later commit raised CI to sixty because CI needed longer, and
splitting them again was a side effect of that rather than its intent. The identical
branches left behind on the assertion timeout are the fossil of the same tidy-up,
and go here too.
no ref

Consolidated the "add Tag" picker in the Posts/Pages list with the Editor sidebar tag picker.
Custom member fields are intended for one Ghost(Pro) plan and above, but nothing could
express that. A labs flag says a feature does not exist yet, which is the wrong thing to
tell someone whose plan simply does not include it, and it is the same answer for every
site. This adds limitCustomFields alongside the other flag limits the host already sets,
so the decision about which plan carries the feature is configuration on the hosting side
rather than a change to Ghost. It stays open unless a host switches it off, so nothing
changes for anyone until that configuration exists. The routes that exist only to change
field definitions sit behind one guard, and Admin asks the same question before offering
any of it, so a publisher whose plan excludes the feature is told so rather than shown a
door that refuses them.
ref https://linear.app/ghost/issue/BER-3951/

Gift delivery analytics queried all events on shared Mailgun domains
because the filter only included `gift-delivery`. Gift delivery sends
now include `bulkEmail:mailgun:tag`, and analytics require both tags.
Missing or empty site tags remain supported.

Automation analytics were already fixed in
[#30430](#30430); this change
completes site scoping for gift delivery emails.
…0628)

no ref

The post editor's settings sidebar has two save gates, and they were not validating the same thing. Both gates now read `validatedFieldsOf(live)`, so the key list is the only thing deciding what either of them checks.
…st editor (#30629)

no ref

The Facebook and X card panes had each grown their own copy of the accepted image types, the unsupported type message, the upload error mapping, the fallback chain for a card's title, description and image, and the `FieldError` element the meta-data pane also carries.
@pull pull Bot locked and limited conversation to collaborators Sep 9, 2026
@pull pull Bot added the ⤵️ pull label Sep 9, 2026
@pull
pull Bot merged commit 707373f into code:main Sep 9, 2026
3 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants