Skip to content

🐛 Fixed appearance of some welcome email image captions#27486

Merged
EvanHahn merged 4 commits intomainfrom
NY-1233
Apr 23, 2026
Merged

🐛 Fixed appearance of some welcome email image captions#27486
EvanHahn merged 4 commits intomainfrom
NY-1233

Conversation

@EvanHahn
Copy link
Copy Markdown
Contributor

@EvanHahn EvanHahn commented Apr 21, 2026

closes https://linear.app/ghost/issue/NY-1233
ref #27485

Before After
Before screenshot After screenshot

Welcome emails use <figure> and <figcaption> for images and their captions, which some email clients don't support. That causes the styling to be messed up in those clients.

Newsletter rendering had already fixed this. This patch fixes it the same way.

In the long term, we should unify these renderers. In the short term, let's fix this bug.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (2)
  • ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/integration/services/__snapshots__/member-welcome-emails-snapshot.test.js.snap is excluded by !**/*.snap

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 83bc730d-a382-4332-9698-64f2e2e71e5d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The email finalization flow now uses a new finalizeHtml helper that parses HTML with Cheerio, adds the kg-card-figcaption class to figcaption elements, runs juice with inlinePseudoElements: true and removeStyleTags: true, re-parses and converts figure and figcaption elements to div tags, and performs character entity substitutions. finalize(html) returns the transformed HTML and plaintext derived from that HTML. Unit tests were updated to expect .kg-image-card .kg-card-figcaption and the absence of original figure/figcaption tags.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title mentions 'Fixed appearance of some welcome email image captions' which directly describes the main change—fixing caption styling by replacing unsupported figure/figcaption elements with div-based structure for better email client compatibility.
Description check ✅ Passed The description directly relates to the changeset: it explains the bug (figure/figcaption elements cause styling issues in email clients), references the fix (applying the same approach used in newsletter rendering), and includes visual before/after comparisons demonstrating the caption styling improvement.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch NY-1233

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 and usage tips.

@EvanHahn

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
ghost/core/core/server/services/email-rendering/finalize.js (1)

9-28: Ordering is correct; consider a brief comment on why juice runs between the two cheerio passes.

The sequence (add kg-card-figcaption → juice → rename figure/figcaption to div) is deliberate: juice must run while the tag names still match the figcaption/figure CSS selectors before they become divs. A short inline comment near line 15 noting this ordering dependency would help future maintainers avoid accidentally reordering the steps.

Also minor: reassigning html (parameter) and reusing $ across two parses is fine but slightly reduces readability; separate names (withClass, $2) could make the two-phase nature more explicit. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/email-rendering/finalize.js` around lines 9 -
28, In finalizeHtml, add a short inline comment right before the juice(html,
...) call explaining that juice must run while elements are still
<figure>/<figcaption> so CSS selectors are applied before we convert those tags
to <div> (i.e., "run juice before renaming tags because selectors target
figure/figcaption"); optionally, for clarity, consider renaming the intermediate
variables (e.g., keep the original parsed DOM/HTML as withClass or $1 and the
post-juice parse as $2) to make the two-phase processing around cheerio.load and
juice more explicit.
ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js (1)

905-931: LGTM — good coverage of the figure→div rewrite and caption styling.

Assertions correctly validate both the structural change (no figure/figcaption remain) and that juice inlined the styles onto the renamed element via the kg-card-figcaption class before the rename. One small suggestion: the font-family: -apple-system check (Line 930) is brittle against unrelated changes to the sans-serif stack in the shared styles — asserting a substring like font-family: presence, or matching the entire expected stack, may be more robust. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js`
around lines 905 - 931, The assertion checking the caption font is brittle:
update the assertion on $caption.attr('style') in the test for
MemberWelcomeEmailRenderer to avoid hardcoding "-apple-system"; either assert
that the style string includes "font-family:" (i.e. presence of a font-family
declaration) or compare against the full expected font stack if you want exact
matching; modify the check that currently uses
$caption.attr('style').includes('font-family: -apple-system') to one of these
more robust alternatives so the test no longer fails on unrelated changes to the
sans-serif stack.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/email-rendering/finalize.js`:
- Around line 9-28: In finalizeHtml, add a short inline comment right before the
juice(html, ...) call explaining that juice must run while elements are still
<figure>/<figcaption> so CSS selectors are applied before we convert those tags
to <div> (i.e., "run juice before renaming tags because selectors target
figure/figcaption"); optionally, for clarity, consider renaming the intermediate
variables (e.g., keep the original parsed DOM/HTML as withClass or $1 and the
post-juice parse as $2) to make the two-phase processing around cheerio.load and
juice more explicit.

In
`@ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js`:
- Around line 905-931: The assertion checking the caption font is brittle:
update the assertion on $caption.attr('style') in the test for
MemberWelcomeEmailRenderer to avoid hardcoding "-apple-system"; either assert
that the style string includes "font-family:" (i.e. presence of a font-family
declaration) or compare against the full expected font stack if you want exact
matching; modify the check that currently uses
$caption.attr('style').includes('font-family: -apple-system') to one of these
more robust alternatives so the test no longer fails on unrelated changes to the
sans-serif stack.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 172f211e-3313-4f4c-a5ee-b8669e24556d

📥 Commits

Reviewing files that changed from the base of the PR and between 19d6ad3 and 85d4a05.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/integration/services/__snapshots__/member-welcome-emails-snapshot.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • ghost/core/core/server/services/email-rendering/finalize.js
  • ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js

@EvanHahn EvanHahn marked this pull request as ready for review April 21, 2026 16:31
@EvanHahn EvanHahn requested a review from troyciesco April 21, 2026 16:31
el.tagName = 'div';
});

return $.html();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Cheerio has the side-effect of changing the way HTML entities are encoded. For example, &copy; becomes &#xA9;. These are equivalent. That's part of why I recently changed the tests to be resilient to this.

Comment on lines +10 to +12
// Add a class to each figcaption so we can style them in the email.
let $ = cheerio.load(html, null, false);
$('figcaption').addClass('kg-card-figcaption');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lifted from the newsletter renderer:

// Add a class to each figcaption so we can style them in the email
$('figcaption').each((i, elem) => !!($(elem).addClass('kg-card-figcaption')));
html = $.html();

Comment on lines +23 to +25
$('figure, figcaption').each((_, el) => {
el.tagName = 'div';
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lifted from the newsletter renderer:

// convert figure and figcaption to div so that Outlook applies margins
$('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));

Base automatically changed from member-welcome-email-renderer-test-with-cheerio to main April 21, 2026 16:35
closes https://linear.app/ghost/issue/NY-1233

Welcome emails use `<figure>` and `<figcaption>` for images and their
captions, which some email clients don't support. That causes the
styling to be messed up in those clients.

[Newsletter rendering had already fixed this.][0] This patch fixes it
the same way.

In the long term, we should unify these renderers. In the short term,
let's fix this bug.

[0]: https://github.com/TryGhost/Ghost/blob/d26bfdb0b20f029a82709782804164d621848d3f/ghost/core/core/server/services/email-service/email-renderer.js#L514-L544
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js (1)

905-930: Assert the replacements are actually <div> elements.

This test verifies figure/figcaption are removed, but not that they were converted to divs. That leaves the stated behavior under-tested.

Strengthen the assertion
                 const $ = cheerio.load(result.html);
+                const $imageCard = $('.kg-image-card');
 
                 assert.equal($('figure').length, 0);
                 assert.equal($('figcaption').length, 0);
+                assert.equal($imageCard[0]?.tagName, 'div');
 
-                const $caption = $('.kg-image-card .kg-card-figcaption');
+                const $caption = $imageCard.find('.kg-card-figcaption');
+                assert.equal($caption[0]?.tagName, 'div');
                 assert.equal($caption.text(), 'A caption');
                 assert($caption.attr('style').includes('text-align: center'), 'caption should be centered');
                 assert($caption.attr('style').includes('font-size: 13px'), 'caption should have 13px font');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js`
around lines 905 - 930, Add an assertion that the image card nodes are actual
div elements: in the test "uses <div>s with inline styles for image cards"
(which uses lexicalRenderStub and MemberWelcomeEmailRenderer.render to produce
result.html), explicitly check that the element with class .kg-image-card is a
div (e.g. assert($('div.kg-image-card').length > 0 or
assert($('.kg-image-card').is('div'))), and similarly ensure the caption node
selector .kg-card-figcaption is a div, so the test verifies replacement to divs
rather than just removal of figure/figcaption.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js`:
- Around line 905-930: Add an assertion that the image card nodes are actual div
elements: in the test "uses <div>s with inline styles for image cards" (which
uses lexicalRenderStub and MemberWelcomeEmailRenderer.render to produce
result.html), explicitly check that the element with class .kg-image-card is a
div (e.g. assert($('div.kg-image-card').length > 0 or
assert($('.kg-image-card').is('div'))), and similarly ensure the caption node
selector .kg-card-figcaption is a div, so the test verifies replacement to divs
rather than just removal of figure/figcaption.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1a481bca-24a9-4628-832d-21f370ec9a41

📥 Commits

Reviewing files that changed from the base of the PR and between 85d4a05 and 6d92532.

⛔ Files ignored due to path filters (2)
  • ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/integration/services/__snapshots__/member-welcome-emails-snapshot.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • ghost/core/core/server/services/email-rendering/finalize.js
  • ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/core/server/services/email-rendering/finalize.js

@github-actions

This comment was marked as resolved.

1 similar comment
@github-actions

This comment was marked as resolved.

Comment thread ghost/core/core/server/services/email-rendering/finalize.js
@EvanHahn EvanHahn marked this pull request as draft April 22, 2026 21:17
@EvanHahn EvanHahn marked this pull request as ready for review April 22, 2026 21:54
@EvanHahn EvanHahn requested a review from troyciesco April 22, 2026 21:57
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@troyciesco troyciesco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

up to you if you care about Sonar's recommendation for replaceAll (I don't really, since replace matches the other email renderer)

@EvanHahn EvanHahn merged commit d9f9208 into main Apr 23, 2026
43 checks passed
@EvanHahn EvanHahn deleted the NY-1233 branch April 23, 2026 14:21
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.

2 participants