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! },