🐛 Fixed newsletter not sending if locale is invalid (#21573)

ref https://github.com/moment/luxon/blob/master/docs/intl.md

- We noticed the following error trace: RangeError: Incorrect locale
information provided
at BatchSendingService.retryDb
(/home/ghost/node_modules/@tryghost/email-service/lib/BatchSendingService.js:639:32)
    at new DateTimeFormat (<anonymous>)
at getCachedDTF
(/home/ghost/node_modules/luxon/build/node/luxon.js:621:11)
at new PolyDateFormatter
(/home/ghost/node_modules/luxon/build/node/luxon.js:842:16)
at Locale.dtFormatter
(/home/ghost/node_modules/luxon/build/node/luxon.js:1066:12)
at Formatter.dtFormatter
(/home/ghost/node_modules/luxon/build/node/luxon.js:2274:21)
at Formatter.formatDateTime
(/home/ghost/node_modules/luxon/build/node/luxon.js:2280:17)
at DateTime.toLocaleString
(/home/ghost/node_modules/luxon/build/node/luxon.js:6893:78)
at formatDateLong
(/home/ghost/node_modules/@tryghost/email-service/lib/EmailRenderer.js:45:74)
at Object.getValue
(/home/ghost/node_modules/@tryghost/email-service/lib/EmailRenderer.js:683:47)
at
/home/ghost/node_modules/@tryghost/email-service/lib/SendingService.js:158:36
    at Array.map (<anonymous>)
at
/home/ghost/node_modules/@tryghost/email-service/lib/SendingService.js:154:54
    at Array.map (<anonymous>)
at SendingService.buildRecipients
(/home/ghost/node_modules/@tryghost/email-service/lib/SendingService.js:151:24)
at SendingService.send
(/home/ghost/node_modules/@tryghost/email-service/lib/SendingService.js:127:33)
at response.retryDb
(/home/ghost/node_modules/@tryghost/email-service/lib/BatchSendingService.js:451:51)
- This is due to the locale being user-input - it can be set to any
string.
- In our email sending code we pass the string to luxon to format dates,
which errors if the locale is not valid according it Intl.
- This fix ensures that the locale is valid before passing it to luxon,
falling back to en-gb if the locale is not valid
This commit is contained in:
Hannah Wolfe 2024-11-07 21:55:20 +00:00 committed by GitHub
parent 2eb1fdf7cd
commit d3cda0d39d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 95 additions and 10 deletions

View File

@ -12,6 +12,8 @@ const {EmailAddressParser} = require('@tryghost/email-addresses');
const {registerHelpers} = require('./helpers/register-helpers'); const {registerHelpers} = require('./helpers/register-helpers');
const crypto = require('crypto'); const crypto = require('crypto');
const DEFAULT_LOCALE = 'en-gb';
// Wrapper function so that i18next-parser can find these strings // Wrapper function so that i18next-parser can find these strings
const t = (x) => { const t = (x) => {
return x; return x;
@ -38,10 +40,17 @@ function escapeHtml(unsafe) {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
function formatDateLong(date, timezone, locale = 'en-gb') { function isValidLocale(locale) {
if (locale === 'en') { try {
locale = 'en-gb'; // 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({ return DateTime.fromJSDate(date).setZone(timezone).setLocale(locale).toLocaleString({
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@ -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) { getFromAddress(post, newsletter) {
// Clean from address to ensure DMARC alignment // Clean from address to ensure DMARC alignment
const addresses = this.#emailAddressService.getAddress({ const addresses = this.#emailAddressService.getAddress({
@ -562,7 +586,7 @@ class EmailRenderer {
*/ */
getMemberStatusText(member) { getMemberStatusText(member) {
const t = this.#t; const t = this.#t;
const locale = this.#settingsCache.get('locale'); const locale = this.#getValidLocale();
if (member.status === 'free') { if (member.status === 'free') {
// Not really used, but as a backup // Not really used, but as a backup
@ -625,7 +649,7 @@ class EmailRenderer {
*/ */
buildReplacementDefinitions({html, newsletterUuid}) { buildReplacementDefinitions({html, newsletterUuid}) {
const t = this.#t; // es-lint-disable-line no-shadow const t = this.#t; // es-lint-disable-line no-shadow
const locale = this.#settingsCache.get('locale'); const locale = this.#getValidLocale();
const baseDefinitions = [ const baseDefinitions = [
{ {

View File

@ -399,6 +399,7 @@ describe('Email renderer', function () {
assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].id, 'created_at');
assert.equal(replacements[0].getValue(member), '13 March 2023'); assert.equal(replacements[0].getValue(member), '13 March 2023');
}); });
it('handles dates when the locale is fr', function () { it('handles dates when the locale is fr', function () {
emailRenderer = new EmailRenderer({ emailRenderer = new EmailRenderer({
urlUtils: { urlUtils: {
@ -427,6 +428,7 @@ describe('Email renderer', function () {
assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].id, 'created_at');
assert.equal(replacements[0].getValue(member), '13 mars 2023'); assert.equal(replacements[0].getValue(member), '13 mars 2023');
}); });
it('handles dates when the locale is en (US)', function () { it('handles dates when the locale is en (US)', function () {
emailRenderer = new EmailRenderer({ emailRenderer = new EmailRenderer({
urlUtils: { urlUtils: {
@ -455,6 +457,65 @@ describe('Email renderer', function () {
assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].id, 'created_at');
assert.equal(replacements[0].getValue(member), '13 March 2023'); 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 () { describe('isMemberTrialing', function () {
let emailRenderer; let emailRenderer;