Skip to content

Public preview emails ignore tier-specific visibility #27893

@betschki

Description

@betschki

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:

  1. ghost/core/core/server/services/email-service/email-renderer.js:335getSegments() 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.

  2. ghost/core/core/server/services/email-service/email-renderer.js:397renderBody() 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.

  3. ghost/core/core/server/services/email-service/email-segmenter.js:28-64getMemberFilterForSegment() 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

  1. On a site with three active paid tiers (tier-a, tier-b, tier-c), have at least one active paid member on each tier.
  2. Create a post and add a public preview divider in the body.
  3. In the post settings, set Access to "Specific tiers" and select only tier-a and tier-b.
  4. Publish & email the post to all subscribers.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs:triage[triage] this needs to be triaged by the Ghost team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions