diff --git a/ghost/email-service/lib/EmailRenderer.js b/ghost/email-service/lib/EmailRenderer.js index cd2c3ef29209..8cf928b973c4 100644 --- a/ghost/email-service/lib/EmailRenderer.js +++ b/ghost/email-service/lib/EmailRenderer.js @@ -12,8 +12,10 @@ const {EmailAddressParser} = require('@tryghost/email-addresses'); const {registerHelpers} = require('./helpers/register-helpers'); const crypto = require('crypto'); +const DEFAULT_LOCALE = 'en-gb'; + // Wrapper function so that i18next-parser can find these strings -const t = (x) => { +const t = (x) => { return x; }; @@ -38,10 +40,17 @@ function escapeHtml(unsafe) { .replace(/'/g, '''); } -function formatDateLong(date, timezone, locale = 'en-gb') { - if (locale === 'en') { - locale = 'en-gb'; +function isValidLocale(locale) { + try { + // Attempt to create a DateTimeFormat with the locale + new Intl.DateTimeFormat(locale); + return true; // No error means it's a valid locale + } catch (e) { + return false; // RangeError means invalid locale } +} + +function formatDateLong(date, timezone, locale = DEFAULT_LOCALE) { return DateTime.fromJSDate(date).setZone(timezone).setLocale(locale).toLocaleString({ year: 'numeric', month: 'long', @@ -185,7 +194,7 @@ class EmailRenderer { this.#models = models; this.#t = t; } - + getSubject(post, isTestEmail = false) { const subject = post.related('posts_meta')?.get('email_subject') || post.get('title'); return isTestEmail ? `[TEST] ${subject}` : subject; @@ -216,6 +225,21 @@ class EmailRenderer { }; } + // Locale is user-input, so we need to ensure it's valid + #getValidLocale() { + let locale = this.#settingsCache.get('locale') || DEFAULT_LOCALE; + + // Remove any trailing whitespace + locale = locale.trim(); + + // If the locale is just "en", or is not valid, revert to default + if (locale === 'en' || !isValidLocale(locale)) { + locale = DEFAULT_LOCALE; + } + + return locale; + } + getFromAddress(post, newsletter) { // Clean from address to ensure DMARC alignment const addresses = this.#emailAddressService.getAddress({ @@ -561,8 +585,8 @@ class EmailRenderer { * @returns {string} */ getMemberStatusText(member) { - const t = this.#t; - const locale = this.#settingsCache.get('locale'); + const t = this.#t; + const locale = this.#getValidLocale(); if (member.status === 'free') { // Not really used, but as a backup @@ -625,7 +649,7 @@ class EmailRenderer { */ buildReplacementDefinitions({html, newsletterUuid}) { const t = this.#t; // es-lint-disable-line no-shadow - const locale = this.#settingsCache.get('locale'); + const locale = this.#getValidLocale(); const baseDefinitions = [ { diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js index 5dd0018ab7cf..1afb5457e482 100644 --- a/ghost/email-service/test/email-renderer.test.js +++ b/ghost/email-service/test/email-renderer.test.js @@ -130,7 +130,7 @@ describe('Email renderer', function () { email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'free' - }; + }; }); it('returns the unsubscribe header replacement by default', function () { @@ -388,7 +388,7 @@ describe('Email renderer', function () { email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'free' - }; + }; }); it('handles dates when the locale is en-gb (default)', function () { @@ -399,6 +399,7 @@ describe('Email renderer', function () { assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].getValue(member), '13 March 2023'); }); + it('handles dates when the locale is fr', function () { emailRenderer = new EmailRenderer({ urlUtils: { @@ -427,6 +428,7 @@ describe('Email renderer', function () { assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].getValue(member), '13 mars 2023'); }); + it('handles dates when the locale is en (US)', function () { emailRenderer = new EmailRenderer({ urlUtils: { @@ -455,6 +457,65 @@ describe('Email renderer', function () { assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].getValue(member), '13 March 2023'); }); + + it('handles dates when the locale has whitespace like "en "', function () { + emailRenderer = new EmailRenderer({ + urlUtils: { + urlFor: () => 'http://example.com/subdirectory/' + }, + labs: { + isSet: () => labsEnabled + }, + settingsCache: { + get: (key) => { + if (key === 'timezone') { + return 'UTC'; + } + if (key === 'locale') { + return 'en '; + } + } + }, + settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl}, + t: t + }); + const html = '%%{created_at}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); + assert.equal(replacements.length, 2); + assert.equal(replacements[0].token.toString(), '/%%\\{created_at\\}%%/g'); + assert.equal(replacements[0].id, 'created_at'); + assert.equal(replacements[0].getValue(member), '13 March 2023'); + }); + + it('handles dates when the locale is invalid like "(en)"', function () { + emailRenderer = new EmailRenderer({ + urlUtils: { + urlFor: () => 'http://example.com/subdirectory/' + }, + labs: { + isSet: () => labsEnabled + }, + settingsCache: { + get: (key) => { + if (key === 'timezone') { + return 'UTC'; + } + if (key === 'locale') { + return '(en)'; + } + } + }, + settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl}, + t: t + }); + + const html = '%%{created_at}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); + assert.equal(replacements.length, 2); + assert.equal(replacements[0].token.toString(), '/%%\\{created_at\\}%%/g'); + assert.equal(replacements[0].id, 'created_at'); + assert.equal(replacements[0].getValue(member), '13 March 2023'); + }); }); describe('isMemberTrialing', function () { let emailRenderer;