Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ghost/core/core/server/services/email-rendering/finalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
14 changes: 11 additions & 3 deletions ghost/core/core/server/services/email-service/email-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,16 +567,24 @@ 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, '→');
html = html.replace(/–/g, '–');
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Loading