diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 7c3cd05338a0..de139b3aec47 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -77,11 +77,43 @@ 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?Q?Caf=C3=A9_M=C3=BCller?= ', + ], ])('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(' ') + + expect(encodedWords.map(decodeEncodedWord).join('')).toEqual(name.trim()) + expect(address).toEqual('a@example.com>') + expect(encodedWords.length).toBeGreaterThan(1) + expect(Math.max(...encodedWords.map((word) => word.length))).toBeLessThanOrEqual(75) + }) }) +function decodeEncodedWord(word: string): string { + const [, encoding, text] = word.match(/^=\?UTF-8\?([BQ])\?(.*)\?=$/i)! + const bytes = + encoding.toUpperCase() === 'B' + ? Buffer.from(text, 'base64') + : Buffer.from( + text + .replace(/_/g, ' ') + .replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))), + 'latin1' + ) + return bytes.toString('utf8') +} + describe('parseAddressList', () => { it.each([ ['clean input', 'a@b.com, c@d.com', ['a@b.com', 'c@d.com']], @@ -352,9 +384,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?Q?Caf=C3=A9_=22M=C3=BCller=22?= ', 'test@posthog.com', ], ])('applies the %s', async (_desc, fromOverride, expectedFrom, expectedFeedback) => { @@ -976,7 +1014,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 +1031,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..62a74a048573 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -1,6 +1,7 @@ import { MessageHeader, SESv2Client, SendEmailCommand, SendEmailCommandInput } from '@aws-sdk/client-sesv2' import { DateTime } from 'luxon' import { SendMailOptions } from 'nodemailer' +import * as mimeFuncs from 'nodemailer/lib/mime-funcs' import { Counter, Histogram } from 'prom-client' import { CyclotronInvocationQueueParametersEmailType } from '~/cdp/schema/cyclotron' @@ -319,18 +320,30 @@ 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 RFC_2047_MAX_ENCODED_WORD_LENGTH = 75 + +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) + : mimeFuncs.encodeWord(name, 'Q', RFC_2047_MAX_ENCODED_WORD_LENGTH) +} + +function quoteDisplayName(name: string): string { + return `"${name.replace(/[\\"]/g, '\\$&')}"` } -// 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 +1002,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 +1061,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! },