diff --git a/ghost/core/core/server/services/email-rendering/finalize.js b/ghost/core/core/server/services/email-rendering/finalize.js index 56f897248ca..04e830c8bda 100644 --- a/ghost/core/core/server/services/email-rendering/finalize.js +++ b/ghost/core/core/server/services/email-rendering/finalize.js @@ -32,6 +32,11 @@ const finalizeHtml = (html) => { html = html.replace(/“/g, '“'); html = html.replace(/”/g, '”'); + // Fix unnecessary hex-entity encoding of forward slashes that may + // be introduced by cheerio/juice serialization. + // Refs https://github.com/TryGhost/Ghost/issues/26905 + html = html.replace(/&#(?:[xX]2[fF]|47);/g, '/'); + return html; }; diff --git a/ghost/core/core/server/services/email-service/email-renderer.js b/ghost/core/core/server/services/email-service/email-renderer.js index 8f586593fc0..29628b113e6 100644 --- a/ghost/core/core/server/services/email-service/email-renderer.js +++ b/ghost/core/core/server/services/email-service/email-renderer.js @@ -567,9 +567,6 @@ class EmailRenderer { // TODO: normalizeReplacementStrings (replace unsupported replacement strings) - // Convert HTML to plaintext - const plaintext = htmlToPlaintext.email(html); - // Fix any unsupported chars in Outlook html = html.replace(/'/g, '''); html = html.replace(/→/g, '→'); @@ -577,6 +574,17 @@ class EmailRenderer { html = html.replace(/“/g, '“'); html = html.replace(/”/g, '”'); + // Fix unnecessary hex-entity encoding of forward slashes that may + // be introduced by cheerio/juice serialization. These entities are + // invalid in text/plain email parts and can appear as literal text + // in inbox previews or plain-text email clients. + // Refs https://github.com/TryGhost/Ghost/issues/26905 + html = html.replace(/&#(?:[xX]2[fF]|47);/g, '/'); + + // Convert HTML to plaintext (must run after entity fixes above so the + // plaintext version also contains clean, decoded text) + const plaintext = htmlToPlaintext.email(html); + return { html, plaintext, diff --git a/ghost/core/test/unit/server/services/email-service/email-renderer.test.js b/ghost/core/test/unit/server/services/email-service/email-renderer.test.js index c1b2666a001..cca7741a171 100644 --- a/ghost/core/test/unit/server/services/email-service/email-renderer.test.js +++ b/ghost/core/test/unit/server/services/email-service/email-renderer.test.js @@ -2381,6 +2381,54 @@ describe('Email renderer', function () { } }); }); + + it('does not encode forward slashes as hex entities in HTML or plaintext', async function () { + // Refs https://github.com/TryGhost/Ghost/issues/26905 + // i18next with escapeValue:true encodes / as / in interpolated + // values, which appear as literal text in plaintext email parts. + const post = createModel(Object.assign({}, basePost, { + published_at: new Date(2026, 2, 19), + authors: [ + createModel({name: 'Author/Name'}) + ] + })); + const newsletter = createModel({ + header_image: null, + name: 'Test Newsletter', + show_badge: true, + feedback_enabled: true, + show_share_button: true, + show_post_title_section: true + }); + const segment = null; + const options = {}; + + const response = await emailRenderer.renderBody( + post, + newsletter, + segment, + options + ); + + // Author name and published date must appear with raw characters + // and free of slash-related HTML entities. + // Compute the expected date the same way the email renderer does. + const {DateTime} = require('luxon'); + const expectedDate = DateTime.fromJSDate(new Date(2026, 2, 19)).setZone('Etc/UTC').setLocale('en-gb').toLocaleString({ + year: 'numeric', month: 'short', day: 'numeric' + }); + + const slashEntities = ['/', '/', '/', '/']; + for (const entity of slashEntities) { + assert.equal(response.html.includes(entity), false, `HTML should not contain ${entity}`); + assert.equal(response.plaintext.includes(entity), false, `Plaintext should not contain ${entity}`); + } + + assert.equal(response.html.includes('Author/Name'), true, 'HTML should contain author name with raw slash'); + assert.equal(response.html.includes(expectedDate), true, 'HTML should contain formatted date'); + assert.equal(response.plaintext.includes('Author/Name'), true, 'Plaintext should contain author name with raw slash'); + assert.equal(response.plaintext.includes(expectedDate), true, 'Plaintext should contain formatted date'); + }); }); describe('getTemplateData', function () {