Skip to content

🐛 Fixed email preview always showing the paywall on tier-restricted posts#29059

Merged
9larsons merged 1 commit into
mainfrom
slars/fix-tiers-email-preview
Jul 3, 2026
Merged

🐛 Fixed email preview always showing the paywall on tier-restricted posts#29059
9larsons merged 1 commit into
mainfrom
slars/fix-tiers-email-preview

Conversation

@9larsons

@9larsons 9larsons commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

fixes https://linear.app/ghost/issue/ONC-1875/preview-showing-incorrect-cta-in-editor
ref #28315

Problem

Since #28315, newsletter sends correctly gate tier-restricted posts per tier — but the editor's email preview and test emails were never taught about it. For any post with visibility restricted to specific tiers, every email preview selection rendered the paywalled version, including "Paid member" — there was no selection that showed the full post. Actual sends were unaffected; only the preview lied. The Browser preview tab was also unaffected (its "Paid member" simulates a member holding all active paid tiers), so the two preview tabs contradicted each other.

Cause

Two independent defects in the preview/test-email path, plus one adjacent behavior change:

  1. The preview controller loaded the post without the tiers relation, so getPostAccessFilter saw an empty tier list and returned "nobody has access" — every segment of a tiers post got the paywall regardless of its value.
  2. The editor only sends the fixed audiences status:free / status:-free, and the new segmentHasPostAccess check requires the segment to contain the post's tier filter — which those strings never do.
  3. segmentHasPostAccess also returned false for a null segment, silently flipping the behavior of GET /email_previews/posts/:id without memberSegment for paywalled posts (used to return the full body).

Change

  • Load tiers in the email preview controller's withRelated, mirroring batch sending.
  • New EmailRenderer.getPreviewSegment(post, segment) maps the editor's paid audience choice onto the tier access segment the send pipeline actually renders (status:-free+(product:'…')), so the email preview matches both delivery and the browser preview's existing "paid member with access" semantics. previewEmail and sendTestEmail render with the mapped segment while still personalizing the example member from the original choice. "Free member" is untouched — it already shows the no-access variant (public preview + paywall).
  • Restored the pre-🐛 Fixed paid members on other tiers receiving gated newsletter content #28315 rule that an unsegmented (null-segment) render returns full content. Safe for sends: the pipeline only produces a null segment for posts with no gated content.

Tests

  • Unit: getPreviewSegment mapping across visibilities; renderBody renders full content for the composite access segment and for null-segment renders of gated posts.
  • E2E: new regression test creates a tier-restricted post with a paywall card through the real API and asserts the paid preview contains the gated content with no paywall CTA, and the free preview shows the paywall. Verified it fails against the pre-fix code with the exact reported symptom.

Known follow-up (not in this PR)

The preview dropdown has no option for "paid member on a tier this post doesn't include" — a real audience that receives the paywalled version when sending to all members. That option's naming should align with the planned rename of the "Public preview" card divider ("Only visible to members" is inaccurate for tiers posts), so it's tracked as a separate product+engineering issue in Linear.

…osts

fixes https://linear.app/ghost/issue/ONC-1875/preview-showing-incorrect-cta-in-editor

- 28315 made newsletter sends tier-aware, but the email preview and test
  email path still requested the old free/paid audiences and never loaded
  the post's tiers relation, so every preview of a tiers-restricted post
  rendered the public preview + paywall CTA — including "Paid member"
- previews now map the paid audience onto the post's tier access segment,
  matching the browser preview's existing semantics (its paid member shim
  holds all active paid tiers, i.e. a paid member with access)
- also restored the pre-28315 behavior that an unsegmented render (no
  memberSegment) returns the full content — the send pipeline only
  produces a null segment for posts without gated content, so this only
  affects API preview clients
@nx-cloud

nx-cloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 39effdb

Command Status Duration Result
nx run ghost:test:ci:integration ✅ Succeeded 2m 52s View ↗
nx run ghost:test:integration ✅ Succeeded 2m 47s View ↗
nx run ghost:test:legacy ✅ Succeeded 2m 58s View ↗
nx run ghost:test:e2e ✅ Succeeded 2m 28s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded <1s View ↗
nx run-many -t test:unit -p ghost ✅ Succeeded 29s View ↗
nx run-many -t lint -p ghost ✅ Succeeded 35s View ↗
nx run @tryghost/admin:build ✅ Succeeded 9s View ↗
Additional runs (2) ✅ Succeeded ... View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-02 17:51:20 UTC

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b1ca6c4d-397e-4934-9545-984c562a255b

📥 Commits

Reviewing files that changed from the base of the PR and between 386558a and 39effdb.

📒 Files selected for processing (6)
  • ghost/core/core/server/services/email-service/email-controller.js
  • ghost/core/core/server/services/email-service/email-renderer.js
  • ghost/core/core/server/services/email-service/email-service.js
  • ghost/core/test/e2e-api/admin/email-previews.test.js
  • ghost/core/test/unit/server/services/email-service/email-renderer.test.js
  • ghost/core/test/unit/server/services/email-service/email-service.test.js

Walkthrough

Changes

This change introduces a getPreviewSegment method on EmailRenderer that maps the editor's audience preview selection into a tier-access-specific segment filter for tier-restricted posts. segmentHasPostAccess now treats a falsy segment as granting access. EmailService.previewEmail and sendTestEmail use the mapped segment when rendering/sending. The email controller now fetches the tiers relation on posts. Unit and e2e tests were added covering these behaviors.

Sequence Diagram(s)

See hidden diagrams above illustrating segment mapping and preview/test-send flow.

Possibly related issues

Suggested reviewers: acburdine

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing email previews on tier-restricted posts from always showing the paywall.
Description check ✅ Passed The description is clearly about the same preview/test-email tier-gating bug and the implemented fix.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slars/fix-tiers-email-preview

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.44.0)
ghost/core/core/server/services/email-service/email-controller.js

[{"text":"logging.warn(Email resume: ${email.id} created_at=${email.get('created_at') && new Date(email.get('created_at')).toISOString()} exceeds max age (${maxAgeMs}ms) — flipped to failed for operator review)","range":{"byteOffset":{"start":9295,"end":9499},"start":{"line":224,"column":20},"end":{"line":224,"column":222}},"file":"ghost/core/core/server/services/email-service/email-service.js","lines":" logging.warn(Email resume: ${email.id} created_at=${email.get('created_at') && new Date(email.get('created_at')).toISOString()} exceeds max age (${maxAgeMs}ms) — flipped to failed for operator review);","charCount":{"leading":20,"trailing":1},"language":"JavaScript","metaVariables":{"single":{},"multi":{"secondary":[{"text":"logging.warn","range":{"byteOffset":{"start":9295,"end":9307},"start":{"line":224,"column":20},"end":{"line":224,"column":32}}},{"text":"email","range":{"byteOffset":{"start":9325,"end":9330},"start":{"line":224,"column":50},"end":{"line":224

... [truncated 45742 characters] ...

(body.email_previews[0].html, new RegExp(selectedNewsletter.name));","charCount":{"leading":62,"trailing":2},"language":"JavaScript","metaVariables":{"single":{"ARG":{"text":"selectedNewsletter.name","range":{"byteOffset":{"start":11470,"end":11493},"start":{"line":240,"column":73},"end":{"line":240,"column":96}}}},"multi":{},"transformed":{}},"ruleId":"regexp-non-literal","severity":"warning","note":"[CWE-1333] Inefficient Regular Expression Complexity. Security best practice. [REFERENCES]\n - https://cwe.mitre.org/data/definitions/1333.html","message":"Do not use variable for regular expressions","labels":[{"text":"new RegExp(selectedNewsletter.name)","range":{"byteOffset":{"start":11459,"end":11494},"start":{"line":240,"column":62},"end":{"line":240,"column":97}},"style":"primary"}]}

ghost/core/core/server/services/email-service/email-renderer.js

[{"text":"logging.warn(Email resume: ${email.id} created_at=${email.get('created_at') && new Date(email.get('created_at')).toISOString()} exceeds max age (${maxAgeMs}ms) — flipped to failed for operator review)","range":{"byteOffset":{"start":9295,"end":9499},"start":{"line":224,"column":20},"end":{"line":224,"column":222}},"file":"ghost/core/core/server/services/email-service/email-service.js","lines":" logging.warn(Email resume: ${email.id} created_at=${email.get('created_at') && new Date(email.get('created_at')).toISOString()} exceeds max age (${maxAgeMs}ms) — flipped to failed for operator review);","charCount":{"leading":20,"trailing":1},"language":"JavaScript","metaVariables":{"single":{},"multi":{"secondary":[{"text":"logging.warn","range":{"byteOffset":{"start":9295,"end":9307},"start":{"line":224,"column":20},"end":{"line":224,"column":32}}},{"text":"email","range":{"byteOffset":{"start":9325,"end":9330},"start":{"line":224,"column":50},"end":{"line":224

... [truncated 45742 characters] ...

(body.email_previews[0].html, new RegExp(selectedNewsletter.name));","charCount":{"leading":62,"trailing":2},"language":"JavaScript","metaVariables":{"single":{"ARG":{"text":"selectedNewsletter.name","range":{"byteOffset":{"start":11470,"end":11493},"start":{"line":240,"column":73},"end":{"line":240,"column":96}}}},"multi":{},"transformed":{}},"ruleId":"regexp-non-literal","severity":"warning","note":"[CWE-1333] Inefficient Regular Expression Complexity. Security best practice. [REFERENCES]\n - https://cwe.mitre.org/data/definitions/1333.html","message":"Do not use variable for regular expressions","labels":[{"text":"new RegExp(selectedNewsletter.name)","range":{"byteOffset":{"start":11459,"end":11494},"start":{"line":240,"column":62},"end":{"line":240,"column":97}},"style":"primary"}]}

ghost/core/core/server/services/email-service/email-service.js

[{"text":"logging.warn(Email resume: ${email.id} created_at=${email.get('created_at') && new Date(email.get('created_at')).toISOString()} exceeds max age (${maxAgeMs}ms) — flipped to failed for operator review)","range":{"byteOffset":{"start":9295,"end":9499},"start":{"line":224,"column":20},"end":{"line":224,"column":222}},"file":"ghost/core/core/server/services/email-service/email-service.js","lines":" logging.warn(Email resume: ${email.id} created_at=${email.get('created_at') && new Date(email.get('created_at')).toISOString()} exceeds max age (${maxAgeMs}ms) — flipped to failed for operator review);","charCount":{"leading":20,"trailing":1},"language":"JavaScript","metaVariables":{"single":{},"multi":{"secondary":[{"text":"logging.warn","range":{"byteOffset":{"start":9295,"end":9307},"start":{"line":224,"column":20},"end":{"line":224,"column":32}}},{"text":"email","range":{"byteOffset":{"start":9325,"end":9330},"start":{"line":224,"column":50},"end":{"line":224

... [truncated 45742 characters] ...

(body.email_previews[0].html, new RegExp(selectedNewsletter.name));","charCount":{"leading":62,"trailing":2},"language":"JavaScript","metaVariables":{"single":{"ARG":{"text":"selectedNewsletter.name","range":{"byteOffset":{"start":11470,"end":11493},"start":{"line":240,"column":73},"end":{"line":240,"column":96}}}},"multi":{},"transformed":{}},"ruleId":"regexp-non-literal","severity":"warning","note":"[CWE-1333] Inefficient Regular Expression Complexity. Security best practice. [REFERENCES]\n - https://cwe.mitre.org/data/definitions/1333.html","message":"Do not use variable for regular expressions","labels":[{"text":"new RegExp(selectedNewsletter.name)","range":{"byteOffset":{"start":11459,"end":11494},"start":{"line":240,"column":62},"end":{"line":240,"column":97}},"style":"primary"}]}

  • 3 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@9larsons 9larsons merged commit 1db4d60 into main Jul 3, 2026
44 checks passed
@9larsons 9larsons deleted the slars/fix-tiers-email-preview branch July 3, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant