When a post has visibility: 'tiers' and includes a public preview divider, the newsletter email delivers the full post content to all paid members, including those on tiers that do not have access. On the website, the same members are correctly paywalled. This contradicts the documented behavior at https://ghost.org/help/public-previews/, which states:
"The public preview always uses the post access settings to determine how visitors and members see the content on your site, or in their inbox as an email newsletter."
Root cause: The email pipeline never invokes content-gating.js and has no concept of per-tier segmentation. It collapses tier-restricted posts to a binary status:free / status:-free split, treating visibility: 'tiers' as if it were visibility: 'paid'.
Three places in the email pipeline conspire to produce the bug:
-
ghost/core/core/server/services/email-service/email-renderer.js:335 — getSegments() hardcodes the segment list to ['status:free', 'status:-free']. There is no per-tier segment, so the renderer cannot produce a "paid but wrong tier → show paywall" variant.
-
ghost/core/core/server/services/email-service/email-renderer.js:397 — renderBody() treats visibility === 'tiers' identically to visibility === 'paid':
const isPaidPost = post.get('visibility') === 'paid' || post.get('visibility') === 'tiers';
The paywall is then only inserted when segment === 'status:free' (lines 402–410), so every paid member, regardless of tier, receives the full content.
-
ghost/core/core/server/services/email-service/email-segmenter.js:28-64 — getMemberFilterForSegment() filters only on newsletter visibility (members / paid) and never references post.tiers. The recipient query for status:-free therefore includes paid members of every tier.
The website path is correct: ghost/core/core/server/services/members/content-gating.js checkPostAccess() maps post.tiers to an NQL query (product:'tier-slug') and evaluates it against member.products. The email pipeline never calls this.
Steps to Reproduce
- On a site with three active paid tiers (
tier-a, tier-b, tier-c), have at least one active paid member on each tier.
- Create a post and add a public preview divider in the body.
- In the post settings, set Access to "Specific tiers" and select only
tier-a and tier-b.
- Publish & email the post to all subscribers.
- As the
tier-c member, (a) open the received email and (b) visit the post URL while logged in.
Expected
- Email:
tier-c member sees only the public preview portion followed by the paywall.
- Website:
tier-c member sees only the public preview portion followed by the paywall.
Actual
- Email:
tier-c member receives the full post content, with no paywall. ❌
- Website:
tier-c member is correctly paywalled. ✅
- Free members are correctly paywalled in both places. ✅
tier-a / tier-b members correctly receive the full content in both places. ✅
This is verifiable at the DB level: email_batches for the affected send contains only two rows — member_segment = 'status:free' and member_segment = 'status:-free' — confirming no per-tier batch was created.
Suggested fix direction
A correct fix likely requires all three sites to change together:
getSegments() should, for visibility: 'tiers' posts that include a <!--members-only--> marker, return both an "authorized tiers" segment (e.g. product:'tier-a',product:'tier-b') and an "everyone else" segment so the renderer produces two versions.
renderBody() should insert the paywall whenever the rendered segment is not in the post's authorized-tier set, not just when segment === 'status:free'.
getMemberFilterForSegment() should AND the segment expression into the recipient query so each batch actually delivers the right rendered version to the right members.
Ghost Version
6.38.0 (relevant code paths unchanged on main at the time of filing)
Node.js Version
22.x
How did you install Ghost?
Magic Pages
Database type
MySQL 8
When a post has
visibility: 'tiers'and includes a public preview divider, the newsletter email delivers the full post content to all paid members, including those on tiers that do not have access. On the website, the same members are correctly paywalled. This contradicts the documented behavior at https://ghost.org/help/public-previews/, which states:Root cause: The email pipeline never invokes
content-gating.jsand has no concept of per-tier segmentation. It collapses tier-restricted posts to a binarystatus:free/status:-freesplit, treatingvisibility: 'tiers'as if it werevisibility: 'paid'.Three places in the email pipeline conspire to produce the bug:
ghost/core/core/server/services/email-service/email-renderer.js:335—getSegments()hardcodes the segment list to['status:free', 'status:-free']. There is no per-tier segment, so the renderer cannot produce a "paid but wrong tier → show paywall" variant.ghost/core/core/server/services/email-service/email-renderer.js:397—renderBody()treatsvisibility === 'tiers'identically tovisibility === 'paid':The paywall is then only inserted when
segment === 'status:free'(lines 402–410), so every paid member, regardless of tier, receives the full content.ghost/core/core/server/services/email-service/email-segmenter.js:28-64—getMemberFilterForSegment()filters only on newsletter visibility (members/paid) and never referencespost.tiers. The recipient query forstatus:-freetherefore includes paid members of every tier.The website path is correct:
ghost/core/core/server/services/members/content-gating.jscheckPostAccess()mapspost.tiersto an NQL query (product:'tier-slug') and evaluates it againstmember.products. The email pipeline never calls this.Steps to Reproduce
tier-a,tier-b,tier-c), have at least one active paid member on each tier.tier-aandtier-b.tier-cmember, (a) open the received email and (b) visit the post URL while logged in.Expected
tier-cmember sees only the public preview portion followed by the paywall.tier-cmember sees only the public preview portion followed by the paywall.Actual
tier-cmember receives the full post content, with no paywall. ❌tier-cmember is correctly paywalled. ✅tier-a/tier-bmembers correctly receive the full content in both places. ✅This is verifiable at the DB level:
email_batchesfor the affected send contains only two rows —member_segment = 'status:free'andmember_segment = 'status:-free'— confirming no per-tier batch was created.Suggested fix direction
A correct fix likely requires all three sites to change together:
getSegments()should, forvisibility: 'tiers'posts that include a<!--members-only-->marker, return both an "authorized tiers" segment (e.g.product:'tier-a',product:'tier-b') and an "everyone else" segment so the renderer produces two versions.renderBody()should insert the paywall whenever the rendered segment is not in the post's authorized-tier set, not just whensegment === 'status:free'.getMemberFilterForSegment()should AND the segment expression into the recipient query so each batch actually delivers the right rendered version to the right members.Ghost Version
6.38.0 (relevant code paths unchanged on
mainat the time of filing)Node.js Version
22.x
How did you install Ghost?
Magic Pages
Database type
MySQL 8