Changed email rendering to interpret segments at a single boundary - #29359
Conversation
WalkthroughEmail segment access now uses shared content-gating logic to evaluate candidate audiences. Email rendering propagates an audience descriptor through paywall handling, segmented content filtering, template data, and preheader generation. Preview and test-email flows use the derived audience status for example-member construction and send the selected render segment. Unit tests cover tier access, paid visibility, malformed segments, and updated service interactions. Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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.1)ghost/core/test/unit/server/services/email-service/email-renderer.test.jsast-grep timed out on this file ghost/core/test/unit/server/services/email-service/email-service.test.jsast-grep retry budget exhausted before isolating this batch ghost/core/test/unit/server/services/members/content-gating.test.jsast-grep retry budget exhausted before isolating this batch Comment |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run ghost:test:ci:integration |
✅ Succeeded | 2m 41s | View ↗ |
nx run ghost:test:integration |
✅ Succeeded | 2m 46s | View ↗ |
nx run ghost:test:legacy |
✅ Succeeded | 3m 3s | View ↗ |
nx run ghost:test:e2e |
✅ Succeeded | 2m 28s | View ↗ |
nx run-many -t test:unit -p ghost |
✅ Succeeded | 33s | View ↗ |
nx run ghost-monorepo:lint:boundaries |
✅ Succeeded | 18s | View ↗ |
nx run-many -t lint -p ghost,ghost-monorepo |
✅ Succeeded | 21s | View ↗ |
nx run @tryghost/admin:build |
✅ Succeeded | 10s | View ↗ |
nx run-many --target=build --projects=tag:publi... |
✅ Succeeded | <1s | View ↗ |
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗
☁️ Nx Cloud last updated this comment at 2026-07-15 18:02:25 UTC
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 122bc422f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #29359 +/- ##
==========================================
- Coverage 74.06% 74.05% -0.01%
==========================================
Files 1578 1578
Lines 137430 137609 +179
Branches 16652 16665 +13
==========================================
+ Hits 101781 101905 +124
- Misses 34604 34657 +53
- Partials 1045 1047 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
122bc42 to
7802e56
Compare
ref https://linear.app/ghost/issue/BER-3779/ - segment strings are the only audience identity the email pipeline has (persisted on email_batches, free-form via the preview APIs), and their meaning was reverse-engineered in four places: the paywall check, the data-gh-segment card stripping, the preheader condition, and getExampleMember - the tier-aware segments from #28315 made that scattering fragile and the tier-specific previews in BER-3779 add more segment shapes, so audience facts are now derived once per render (describeSegment) and read as data everywhere below - access is answered by rebuilding the member a segment describes and reusing checkPostAccess (new checkSegmentPostAccess in content-gating) instead of substring-matching filter strings, which wrongly paywalled tier segments on paid-visibility posts and couldn't handle segment shapes it didn't anticipate - the preheader now regenerates from the gated html for any audience without post access, not just status:free, so gated content no longer leaks into the preheader of the no-access variants from #28315
7802e56 to
4f682ea
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/email-service/email-renderer.test.js (1)
2419-2463: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover the paid-but-denied preheader path.
The
bronzefixture omitsplaintext, so it cannot exercise preheader regeneration for an audience withhasPostAccess: false. Add plaintext containing the gated phrase; the existing Line 2457 assertion can then catch a preheader leak too.Suggested test fixture addition
if (key === 'title') { return 'Test Post'; } + if (key === 'plaintext') { + return 'some text for both finishing part only for members'; + }This covers the PR objective to prevent gated content appearing in preheaders.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ghost/core/test/unit/server/services/email-service/email-renderer.test.js` around lines 2419 - 2463, Update the post fixture in the tier-gated rendering test to include plaintext containing the gated phrase, so the bronze audience exercises preheader regeneration with denied access. Keep the existing bronze assertions unchanged, allowing them to verify the phrase is absent from both rendered HTML and preheader output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@ghost/core/test/unit/server/services/email-service/email-renderer.test.js`:
- Around line 2419-2463: Update the post fixture in the tier-gated rendering
test to include plaintext containing the gated phrase, so the bronze audience
exercises preheader regeneration with denied access. Keep the existing bronze
assertions unchanged, allowing them to verify the phrase is absent from both
rendered HTML and preheader output.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ff2ddbb4-450c-4e2a-8d74-5c239e3be2bc
📒 Files selected for processing (6)
ghost/core/core/server/services/email-service/email-renderer.jsghost/core/core/server/services/email-service/email-service.jsghost/core/core/server/services/members/content-gating.jsghost/core/test/unit/server/services/email-service/email-renderer.test.jsghost/core/test/unit/server/services/email-service/email-service.test.jsghost/core/test/unit/server/services/members/content-gating.test.js
🚧 Files skipped from review as they are similar to previous changes (4)
- ghost/core/test/unit/server/services/email-service/email-service.test.js
- ghost/core/test/unit/server/services/members/content-gating.test.js
- ghost/core/core/server/services/members/content-gating.js
- ghost/core/core/server/services/email-service/email-service.js

ref https://linear.app/ghost/issue/BER-3779/
The email pipeline identifies each render variant only by its member segment string — persisted on
email_batches.member_segmentand accepted free-form by the preview APIs. Four consumers were reverse-engineering meaning from that string with their own heuristics: the paywall check (segment.includes(accessFilter)), the data-gh-segment card stripping, the preheader condition, andgetExampleMember's enrichment check. The tier-aware segments from #28315 already strained this, and the tier-specific previews in BER-3779 add more segment shapes — every new shape had to be anticipated by all four heuristics or previews and sends silently rendered the wrong variant.Adding tier-specific previews was hard, so this refactor sets it up to be easy:
checkSegmentPostAccessin content-gating answers "can this audience read this post's gated content" by rebuilding the member the segment describes (status + the tiers it names) and reusingcheckPostAccess, replacing the substring matching. This also fixes tier segments on paid-visibility posts being wrongly paywalled — the substring check could never match a filter shape it didn't anticipate.describeSegment(post, segment)in the renderer derives the audience facts (status,hasPostAccess) once per render; the paywall, card stripping, and preheader read the descriptor instead of re-parsing the segment.getExampleMembertakes the audience status from its callers instead of strict-matching segment strings, so test emails keep personalizing from the real recipient as new segment shapes appear.status:free— previously the no-access variants introduced by 🐛 Fixed paid members on other tiers receiving gated newsletter content #28315 could leak gated content from model plaintext into paid recipients' preheaders.No behavioural change for any segment the pipeline currently produces; the tier-preview feature lands separately on top.