Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions nodejs/src/cdp/services/messaging/email.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,30 @@ describe('formatMailbox', () => {
'Dry\r\nBcc: x@example.com',
'"DryBcc: x@example.com" <a@example.com>',
],
[
'a non-ASCII name as an RFC 2047 encoded word',
'Café Müller',
'=?UTF-8?B?Q2Fmw6kgTcO8bGxlcg==?= <a@example.com>',
],
])('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', () => {
Expand Down Expand Up @@ -352,9 +373,15 @@ describe('EmailService', () => {
'test@posthog.com',
],
[
'name with header-breaking characters is sanitized',
{ name: '"Evil" <fake@evil.com>,\r\n Bcc:' },
'"Evil fake@evil.com, Bcc:" <test@posthog.com>',
'name with quotes, angle brackets and a line break kept inside the quoted name',
{ name: '"Evil" <fake@evil.com>,\r\n Bcc:\\' },
'"\\"Evil\\" <fake@evil.com>, Bcc:\\\\" <test@posthog.com>',
'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>',
'test@posthog.com',
],
])('applies the %s', async (_desc, fromOverride, expectedFrom, expectedFeedback) => {
Expand Down Expand Up @@ -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))
Expand All @@ -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 () => {
Expand Down
61 changes: 49 additions & 12 deletions nodejs/src/cdp/services/messaging/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,55 @@ export function extractEmailsFromAddressList(value: string | undefined): string[
// RFC-822 `"Name" <email>` framing (whitespace, quotes, angle brackets, list separators).
const FROM_OVERRIDE_EMAIL_REGEX = /^[^\s@"<>,;]+@[^\s@"<>,;]+\.[^\s@"<>,;]+$/

// The display name is embedded as `"${name}" <email>`, 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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1048,8 +1085,8 @@ export class EmailService {
): Promise<void> {
// 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! },
Expand Down
Loading