From 910c503f4a62bd9bad9ebb5903f8c8d4ac853546 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:36:07 +0200 Subject: [PATCH] fix: render right-to-left mail right-to-left The HTML and plain-text body documents had no direction, so an Arabic or Hebrew message was laid out left-to-right: left-aligned, with its sentence-final punctuation on the wrong side. Port the webmail's `dir="auto"` on the body of both documents so the first strong character picks the direction. An email that declares its own `dir` on its `` or `
` tag keeps it; the parser merges those attributes into ours only while ours has none. The shrink-to-fit reporter widens its wrapper to the content's natural width and scales it from the top left. In a right-to-left body that over-wide block overflowed to the left instead, so a 600px newsletter scaled into the space left of the screen. A negative right margin now keeps the widened wrapper flush left in either direction, so the existing origin, pan clamp and pinch anchoring still hold. --- src/components/EmailBodyView.tsx | 6 ++++++ src/lib/__tests__/email-html.test.ts | 18 ++++++++++++++++++ src/lib/email-html.ts | 14 ++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 32488f08..fb0943c6 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -248,10 +248,15 @@ const HEIGHT_REPORTER = ` if (Z.tx < minTx) Z.tx = minTx; if (Z.tx > 0) Z.tx = 0; w.style.width = Z.layoutW + 'px'; + // An over-wide block in a right-to-left body overflows to the left, + // where the top-left origin and the pan clamp don't expect it. The + // negative right margin keeps it flush left in either direction. + w.style.marginRight = (Z.avail - Z.layoutW) + 'px'; w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')'; } else { Z.tx = 0; w.style.width = '100%'; + w.style.marginRight = ''; w.style.transform = 'none'; } } @@ -266,6 +271,7 @@ const HEIGHT_REPORTER = ` // Reset to full width so wrapping content reports its true overflow extent // (the widest unbreakable element) rather than a previously-scaled box. w.style.width = '100%'; + w.style.marginRight = ''; var content = w.scrollWidth; Z.avail = avail; Z.layoutW = content > avail + 1 ? content : avail; diff --git a/src/lib/__tests__/email-html.test.ts b/src/lib/__tests__/email-html.test.ts index e952d87a..3bdd28a7 100644 --- a/src/lib/__tests__/email-html.test.ts +++ b/src/lib/__tests__/email-html.test.ts @@ -205,6 +205,20 @@ describe('prepareEmailHtml', () => { expect(html).toContain('max-height:134px; max-width:200px; width:100%;'); expect(html).toContain('style="max-width:300px; width:100%"'); }); + + it('lets the content pick its text direction', () => { + expect(prepareEmailHtml('مرحبا بالعالم.
').html).toContain(''); + }); + + it('keeps a direction the email declares on its own html or body tag', () => { + const body = prepareEmailHtml('Newsletter مرحبا
').html; + expect(body).not.toContain('dir="auto"'); + expect(body).toContain(''); + const root = prepareEmailHtml('x
').html; + expect(root).not.toContain('dir="auto"'); + // A dir on an inner element is not a document direction. + expect(prepareEmailHtml('x
').html).toContain(''); + }); }); describe('wrapPlainTextEmail', () => { @@ -213,6 +227,10 @@ describe('wrapPlainTextEmail', () => { expect(wrapPlainTextEmail('x', { font: 'mono' })).toContain('ui-monospace'); expect(wrapPlainTextEmail('x')).not.toContain('ui-monospace'); }); + + it('lets the content pick its text direction', () => { + expect(wrapPlainTextEmail('مرحبا بالعالم.')).toContain(''); + }); }); describe('hasNativeDarkMode', () => { diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index a35055a5..37020889 100644 --- a/src/lib/email-html.ts +++ b/src/lib/email-html.ts @@ -526,6 +526,13 @@ function isWordHtml(html: string): boolean { return /class=["']?(?:Mso|WordSection)|