diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 7c3cd05338a0..b165a168d215 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -77,9 +77,30 @@ describe('formatMailbox', () => { 'Dry\r\nBcc: x@example.com', '"DryBcc: x@example.com" ', ], + [ + 'a non-ASCII name as an RFC 2047 encoded word', + 'Café Müller', + '=?UTF-8?B?Q2Fmw6kgTcO8bGxlcg==?= ', + ], ])('formats %s', (_name, name, expected) => { expect(formatMailbox(name, 'a@example.com')).toEqual(expected) }) + + it.each([ + ['CJK name with a character outside the emoji range', '株式会社山田商事部𠮷田'.repeat(4)], + ['emoji name', 'Launch party 🎉🚀 '.repeat(5)], + ])('keeps a long %s intact across encoded words', (_desc, name) => { + const [displayName, address] = formatMailbox(name, 'a@example.com').split(' <') + const encodedWords = displayName.split(' ') + const decoded = Buffer.concat( + encodedWords.map((word) => Buffer.from(word.match(/^=\?UTF-8\?B\?(.+)\?=$/)![1], 'base64')) + ).toString('utf8') + + expect(decoded).toEqual(name.trim()) + expect(address).toEqual('a@example.com>') + expect(encodedWords.length).toBeGreaterThan(1) + expect(Math.max(...encodedWords.map((word) => word.length))).toBeLessThanOrEqual(75) + }) }) describe('parseAddressList', () => { @@ -352,9 +373,15 @@ describe('EmailService', () => { 'test@posthog.com', ], [ - 'name with header-breaking characters is sanitized', - { name: '"Evil" ,\r\n Bcc:' }, - '"Evil fake@evil.com, Bcc:" ', + 'name with quotes, angle brackets and a line break kept inside the quoted name', + { name: '"Evil" ,\r\n Bcc:\\' }, + '"\\"Evil\\" , Bcc:\\\\" ', + 'test@posthog.com', + ], + [ + 'non-ASCII name with quotes as an RFC 2047 encoded word', + { name: 'Café "Müller"' }, + '=?UTF-8?B?Q2Fmw6kgIk3DvGxsZXIi?= ', 'test@posthog.com', ], ])('applies the %s', async (_desc, fromOverride, expectedFrom, expectedFeedback) => { @@ -976,7 +1003,13 @@ describe('EmailService', () => { invocation.queueParameters = createEmailParams({ from: { integrationId: 1 } }) await mailDevAPI.clearEmails() }) - it('should send an email', async () => { + it.each([ + ['a plain recipient name', 'Test User'], + ['a non-ASCII recipient name', 'Café "Müller"'], + ])('should send an email to %s', async (_desc, recipientName) => { + invocation.queueParameters = createEmailParams({ + to: { email: 'test@example.com', name: recipientName }, + }) const result = await service.executeSendEmail(invocation) expect(result.error).toBeUndefined() await waitForExpect(async () => expect(mailDevAPI.getEmails()).resolves.toHaveLength(1)) @@ -987,7 +1020,7 @@ describe('EmailService', () => { html: 'Test HTML', subject: 'Test Subject', text: 'Test Text', - to: [{ address: 'test@example.com', name: 'Test User' }], + to: [{ address: 'test@example.com', name: recipientName }], }) }) it('should include tracking code in the email with distinct_id', async () => { diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index ff83d8746de4..d8176a6b7e80 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -319,18 +319,55 @@ export function extractEmailsFromAddressList(value: string | undefined): string[ // RFC-822 `"Name" ` framing (whitespace, quotes, angle brackets, list separators). const FROM_OVERRIDE_EMAIL_REGEX = /^[^\s@"<>,;]+@[^\s@"<>,;]+\.[^\s@"<>,;]+$/ -// The display name is embedded as `"${name}" `, so strip the characters that would -// terminate the quoted phrase or start the address part, plus control characters. -function sanitizeFromName(name: string): string { - return name.replace(/[\x00-\x1F\x7F"<>\\]/g, '').trim() +const PRINTABLE_ASCII = /^[\x20-\x7E]*$/ +const ENCODED_WORD_PREFIX = '=?UTF-8?B?' +const ENCODED_WORD_SUFFIX = '?=' +const RFC_2047_MAX_ENCODED_WORD_LENGTH = 75 +const BASE64_CHARACTERS_PER_3_BYTES = 4 +const MAX_BYTES_PER_ENCODED_WORD = + Math.floor( + (RFC_2047_MAX_ENCODED_WORD_LENGTH - ENCODED_WORD_PREFIX.length - ENCODED_WORD_SUFFIX.length) / + BASE64_CHARACTERS_PER_3_BYTES + ) * 3 + +function cleanDisplayName(name: string | undefined): string { + return (name ?? '').replace(/[\x00-\x1F\x7F]/g, '').trim() +} + +function encodeDisplayName(name: string): string { + return PRINTABLE_ASCII.test(name) ? quoteDisplayName(name) : toEncodedWords(name) +} + +function quoteDisplayName(name: string): string { + return `"${name.replace(/[\\"]/g, '\\$&')}"` +} + +function toEncodedWords(name: string): string { + return splitOnCodePoints(name, MAX_BYTES_PER_ENCODED_WORD) + .map((chunk) => `${ENCODED_WORD_PREFIX}${Buffer.from(chunk).toString('base64')}${ENCODED_WORD_SUFFIX}`) + .join(' ') +} + +function splitOnCodePoints(text: string, maxBytes: number): string[] { + const chunks: string[] = [] + let chunk = '' + for (const codePoint of text) { + if (chunk && Buffer.byteLength(chunk + codePoint) > maxBytes) { + chunks.push(chunk) + chunk = '' + } + chunk += codePoint + } + return [...chunks, chunk] } -// A display name can come from Liquid output, so it may hold quotes, backslashes or line breaks. -// Escape it as an RFC 5322 quoted string and drop control characters, which also stops a templated -// name from injecting a header. export function formatMailbox(name: string | undefined, email: string): string { - const cleaned = (name ?? '').replace(/[\x00-\x1F\x7F]/g, '').trim() - return cleaned ? `"${cleaned.replace(/[\\"]/g, '\\$&')}" <${email}>` : email + const displayName = cleanDisplayName(name) + return displayName ? `${encodeDisplayName(displayName)} <${email}>` : email +} + +function toNodemailerAddress(name: string | undefined, email: string): { name: string; address: string } { + return { name: cleanDisplayName(name), address: email } } export function parseAddressList(value?: string): string[] | undefined { @@ -989,7 +1026,7 @@ export class EmailService { // Overrides arrive already rendered by the templating engine, so a template that // resolved to nothing means "no override" and the integration's stored sender applies. - const overrideName = from.name ? sanitizeFromName(from.name) : '' + const overrideName = cleanDisplayName(from.name) return { email: this.resolveFromEmailAddress(integration, from.email?.trim(), addLog), @@ -1048,8 +1085,8 @@ export class EmailService { ): Promise { // This can timeout but there is no native timeout so we do our own one const mailOptions: SendMailOptions = { - from: formatMailbox(from.name, from.email), - to: formatMailbox(params.to.name, params.to.email), + from: toNodemailerAddress(from.name, from.email), + to: toNodemailerAddress(params.to.name, params.to.email), subject: sanitizeEmailSubject(params.subject), ...(params.text ? { text: params.text } : {}), headers: { [AUTO_SUBMITTED_HEADER.Name!]: AUTO_SUBMITTED_HEADER.Value! },