Skip to content

🐛 Fixed RTL languages rendering left-to-right in newsletter emails#27357

Open
betschki wants to merge 5 commits intoTryGhost:mainfrom
magicpages:fix/newsletter-rtl-support
Open

🐛 Fixed RTL languages rendering left-to-right in newsletter emails#27357
betschki wants to merge 5 commits intoTryGhost:mainfrom
magicpages:fix/newsletter-rtl-support

Conversation

@betschki
Copy link
Copy Markdown
Contributor

Newsletters for sites with an RTL publication language (Persian, Arabic, Hebrew, Urdu) rendered left-to-right because the email template never emitted lang/dir on the root <html> element.

Portal already calls i18next's built-in dir() for the same purpose; the email renderer just never wired it through. The renderer now passes the resolved locale and direction into the template context, the template sets html lang/dir, the inline body style picks up direction, and the feedback button row mirrors with the document.

Defaults to ltr when no dir helper is provided so existing renders are byte-identical for LTR locales.

Before:
Screenshot 2026-04-12 at 18-04-31 Ghost Admin - Test

After:
Screenshot 2026-04-12 at 18-03-15 Ghost Admin - Test

Got some code for us? Awesome 🎊!

Please take a minute to explain the change you're making:

  • Why are you making it?
  • What does it do?
  • Why is this something Ghost users or developers need?

Please check your PR against these items:

  • I've read and followed the Contributor Guide
  • I've explained my change
  • I've written an automated test to prove my change works

We appreciate your contribution! 🙏

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 12, 2026

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: e52b8f85-d750-4d80-81bb-341f277d893e

📥 Commits

Reviewing files that changed from the base of the PR and between b9ec001 and 5dd2763.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (5)
  • ghost/core/core/server/services/email-service/email-renderer.js
  • ghost/core/core/server/services/email-service/email-service-wrapper.js
  • ghost/core/core/server/services/email-service/email-templates/partials/styles.hbs
  • ghost/core/core/server/services/email-service/email-templates/template.hbs
  • ghost/core/test/unit/server/services/email-service/email-renderer.test.js

Walkthrough

The changes introduce bi-directional text direction support to email rendering. The EmailRenderer class now accepts an optional dir dependency that computes text direction based on locale. During template data generation, the renderer determines and passes both the current locale and computed direction (rtl or ltr) to email templates via the site object. The i18n.dir method is wired into the email service wrapper and used to resolve direction values. Email template markup and styles are updated to apply these dynamic values to the <html> element's lang and dir attributes, the body CSS rule, and feedback component directionality. Test coverage validates correct direction assignment for various locales and fallback behavior when the dir helper is unavailable.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing RTL language rendering in newsletter emails, which is the core objective of the PR.
Description check ✅ Passed The description comprehensively explains the problem, solution, implementation details, and includes before/after screenshots demonstrating the fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/newsletter-rtl-support

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.42.1)
ghost/core/test/unit/server/services/email-service/email-renderer.test.js

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.

Newsletters for sites with an RTL publication language (Persian, Arabic,
Hebrew, Urdu) rendered left-to-right because the email template never
emitted lang/dir on the root <html> element. Portal already calls
i18next's built-in dir() for the same purpose; the email renderer just
never wired it through. The renderer now passes the resolved locale and
direction into the template context, the template sets html lang/dir,
the inline body style picks up direction, and the feedback button row
mirrors with the document. Defaults to ltr when no dir helper is
provided so existing renders are byte-identical for LTR locales.
@betschki betschki force-pushed the fix/newsletter-rtl-support branch from 5dd2763 to 7afd15c Compare April 12, 2026 18:14
The email preview API endpoint snapshot still expected the old <html>
shape; the renderer now emits lang/dir and a body direction style.
Regenerating the snapshot here so the new wrapper output matches.
The integration snapshot test calls MemberWelcomeEmailRenderer directly
with a fixture siteSettings that doesn't include locale, which made the
new wrapper render <html lang dir="ltr"> (empty lang attribute).
Production goes through service.js#getSiteSettings which already falls
back to en, so this only affected the test path, but the renderer now
defends against any direct caller missing locale by defaulting to en.
Snapshot regenerated with the corrected output.
@marceloomens
Copy link
Copy Markdown

I'd love to see this PR merged soonest, as I currently have an issue with Ghost newsletter RTL support in production. Please let me know if there's anything I can do in support of moving this PR along.

The "Manage subscription" cell in the subscription details box was
hardcoded to align right via both an HTML attribute and CSS. Combined
with the table column flip that comes from <html dir="rtl">, that
pushed the link inwards against the subscription details block instead
of out to the email margin. The HTML align attribute is now conditional
on site.direction (Outlook still wants the literal value), the CSS
uses text-align: end so it flips automatically in modern clients, and
the mobile-stacking media rule that forced text-align: left switches
to start so stacked content reads in the document direction.
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@EvanHahn EvanHahn left a comment

Choose a reason for hiding this comment

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

Mostly looks good but I have a few comments. Thanks for doing this!

font-size: 15px;
font-weight: 400;
text-align: right;
text-align: end;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

text-align: end and text-align: start are unsupported in some email clients. We may need to work around this by interpolating a value.

* @param {object} dependencies.labs
* @param {{Post: object}} dependencies.models
* @param {Function} dependencies.t
* @param {Function} [dependencies.dir] Returns 'rtl' or 'ltr' for a given locale (i18next's `i18n.dir`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • nit: could you use a proper type like () => 'rtl' | 'ltr' instead of just Function?
  • nit: could you make this function required? I see no reason to omit it and handle that case. That'll also let you remove the "Falls back to ltr when dir helper is not provided" test below.

-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
color: #15212A;
direction: {{site.direction}};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't know much about this property, but MDN discourages it in favor of the dir HTML attribute (which you've set). Is this necessary?

Comment on lines +2440 to +2454
it('Sets site.direction to rtl for Persian (fa)', async function () {
settings.locale = 'fa';
const data = await templateDataWithSettings({});
assert.equal(data.site.locale, 'fa');
assert.equal(data.site.direction, 'rtl');
});

it('Sets site.direction to rtl for Arabic, Hebrew, and Urdu', async function () {
for (const locale of ['ar', 'he', 'ur']) {
settings.locale = locale;
const data = await templateDataWithSettings({});
assert.equal(data.site.locale, locale, `expected locale ${locale}`);
assert.equal(data.site.direction, 'rtl', `expected rtl for ${locale}`);
}
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: can you combine these two tests into one? I think you can remove the first test and add fa into the locale array.

Comment on lines +1386 to +1405
it('Renders RTL <html> attributes when site locale is Persian (fa)', async function () {
customSettings.locale = 'fa';
const post = createModel(basePost);
const newsletter = createModel(baseNewsletter);
const response = await emailRenderer.renderBody(post, newsletter, null, {});
assert.match(response.html, /<html lang="fa" dir="rtl">/);
assert.match(response.html, /direction:\s*rtl/);
// The feedback button row should also flip direction
assert.match(response.html, /class="feedback-buttons-container" dir="rtl"/);
});

it('Renders RTL <html> attributes for Arabic, Hebrew, and Urdu', async function () {
for (const locale of ['ar', 'he', 'ur']) {
customSettings.locale = locale;
const post = createModel(basePost);
const newsletter = createModel(baseNewsletter);
const response = await emailRenderer.renderBody(post, newsletter, null, {});
assert.match(response.html, new RegExp(`<html lang="${locale}" dir="rtl">`), `expected rtl <html> for ${locale}`);
}
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: can you combine these two tests into one? I think you can remove the first test and add fa into the locale array.

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.

3 participants