Skip to content

src/gmail-mime.test.ts

355 lines · 11.2 KB · TypeScript
Raw
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
	applyMimeHeaderUpdates,
	assembleComposedMime,
	assertComposedReplyThreading,
	assertNotSelfOnlyTo,
	decodeBase64Latin1_DO_NOT_USE_FOR_MIME,
	decodeBase64ToBytes,
	decodeUtf8Base64,
	decodeUtf8Base64Url,
	defaultReplyToHeader,
	encodeBytesToBase64,
	encodeBytesToBase64Url,
	encodeHeaderValue,
	encodeUtf8Base64,
	excludeMailboxAddresses,
	extractEmails,
	formatAddressHeader,
	isOnlyMailbox,
	requireRealSubject,
	splitMimeRaw,
	toBase64Url,
} from './gmail-mime.ts'

describe('formatAddressHeader', () => {
	it('returns null for empty values', () => {
		assert.equal(formatAddressHeader(undefined), null)
		assert.equal(formatAddressHeader([]), null)
		assert.equal(formatAddressHeader(['', '  ']), null)
	})

	it('keeps a single already-formatted address', () => {
		assert.equal(formatAddressHeader('sponsor-kcd@10xn.dev'), 'sponsor-kcd@10xn.dev')
		assert.equal(
			formatAddressHeader('"Kent C. Dodds" <me@kentcdodds.com>'),
			'"Kent C. Dodds" <me@kentcdodds.com>',
		)
	})

	it('joins an address list with commas', () => {
		assert.equal(
			formatAddressHeader(['sponsor-kcd@10xn.dev', 'Pierre Burgy <pierre@strapi.com>']),
			'sponsor-kcd@10xn.dev, Pierre Burgy <pierre@strapi.com>',
		)
	})

	it('RFC 2047 encodes a non-ASCII display name', () => {
		assert.equal(
			formatAddressHeader('Pierre Burgy 🥐 <pierre@strapi.com>'),
			'=?UTF-8?B?' + encodeUtf8Base64('Pierre Burgy 🥐') + '?= <pierre@strapi.com>',
		)
	})
})

describe('assembleComposedMime', () => {
	it('includes To, Cc, and Bcc headers', () => {
		const assembled = assembleComposedMime({
			from: '"Kent C. Dodds" <me@kentcdodds.com>',
			to: 'Verity <influencercontact@corp.stardesk.net>',
			cc: ['sponsor-kcd@10xn.dev'],
			bcc: 'me+log@kentcdodds.com',
			subject: 'Re: Partnership Opportunity X StarDesk',
			body: ['Hey Verity,', 'I have a team that will take it from here.', '- Kent'],
		})
		assert.match(assembled.mime, /^From: "Kent C\. Dodds" <me@kentcdodds\.com>/m)
		assert.match(assembled.mime, /^To: Verity <influencercontact@corp\.stardesk\.net>/m)
		assert.match(assembled.mime, /^Cc: sponsor-kcd@10xn\.dev/m)
		assert.match(assembled.mime, /^Bcc: me\+log@kentcdodds\.com/m)
		assert.match(assembled.mime, /^Subject: Re: Partnership Opportunity X StarDesk/m)
		assert.equal(assembled.cc, 'sponsor-kcd@10xn.dev')
		assert.equal(assembled.bcc, 'me+log@kentcdodds.com')
		assert.match(assembled.text, /I have a team that will take it from here\./)
	})

	it('omits missing Cc and Bcc headers', () => {
		const assembled = assembleComposedMime({
			from: 'me@kentcdodds.com',
			to: 'pierre@strapi.com',
			subject: 'Hello',
			body: 'Hi',
		})
		assert.doesNotMatch(assembled.mime, /^Cc:/m)
		assert.doesNotMatch(assembled.mime, /^Bcc:/m)
	})

	it('rejects an empty body', () => {
		assert.throws(
			() =>
				assembleComposedMime({
					from: 'me@kentcdodds.com',
					to: 'pierre@strapi.com',
					subject: 'Hello',
					body: ['', '  '],
				}),
			/non-empty body/,
		)
	})
})

describe('encodeHeaderValue', () => {
	it('leaves ASCII headers alone', () => {
		assert.equal(encodeHeaderValue('Re: Epic Web / Strapi'), 'Re: Epic Web / Strapi')
	})
})

describe('self-send helpers', () => {
	const mailbox = 'me@kentcdodds.com'

	it('extractEmails finds bare addresses in display-name headers', () => {
		assert.deepEqual(extractEmails('"Kent C. Dodds" <me@kentcdodds.com>'), ['me@kentcdodds.com'])
		assert.deepEqual(
			extractEmails('A <a@example.com>, B <b@example.com>'),
			['a@example.com', 'b@example.com'],
		)
	})

	it('isOnlyMailbox is true when every address is the mailbox', () => {
		assert.equal(isOnlyMailbox('"Kent" <me@kentcdodds.com>', mailbox), true)
		assert.equal(isOnlyMailbox('jaimie@example.com', mailbox), false)
	})

	it('excludeMailboxAddresses keeps other recipients', () => {
		assert.equal(
			excludeMailboxAddresses(
				'"Kent" <me@kentcdodds.com>, Jaimie <jaimie@example.com>',
				mailbox,
			),
			'Jaimie <jaimie@example.com>',
		)
	})

	it('defaultReplyToHeader uses From for inbound mail', () => {
		assert.equal(
			defaultReplyToHeader({
				mailboxEmail: mailbox,
				from: 'Jaimie <jaimie@example.com>',
				to: '"Kent" <me@kentcdodds.com>',
			}),
			'Jaimie <jaimie@example.com>',
		)
	})

	it('defaultReplyToHeader uses original To for own outbound mail', () => {
		assert.equal(
			defaultReplyToHeader({
				mailboxEmail: mailbox,
				from: '"Kent C. Dodds" <me@kentcdodds.com>',
				to: 'Jaimie <jaimie@example.com>',
			}),
			'Jaimie <jaimie@example.com>',
		)
	})

	it('assertNotSelfOnlyTo refuses self-only To', () => {
		assert.throws(
			() =>
				assertNotSelfOnlyTo({
					to: 'me@kentcdodds.com',
					mailboxEmail: mailbox,
				}),
			/self-send trap/,
		)
		assert.doesNotThrow(() =>
			assertNotSelfOnlyTo({
				to: 'me@kentcdodds.com',
				mailboxEmail: mailbox,
				allowSelfSend: true,
			}),
		)
	})
})

describe('requireRealSubject', () => {
	it('accepts a real subject', () => {
		assert.equal(requireRealSubject('Re: Hello'), 'Re: Hello')
		assert.equal(requireRealSubject('  Hi  '), 'Hi')
	})

	it('rejects missing, empty, and coerced subjects', () => {
		assert.throws(() => requireRealSubject(undefined), /real subject string/)
		assert.throws(() => requireRealSubject(null), /real subject string/)
		assert.throws(() => requireRealSubject(''), /non-empty subject/)
		assert.throws(() => requireRealSubject('   '), /non-empty subject/)
		assert.throws(() => requireRealSubject('undefined'), /literal string "undefined"/)
		assert.throws(() => requireRealSubject('null'), /literal string "undefined"/)
	})
})

describe('assembleComposedMime subject guard', () => {
	it('refuses undefined subject instead of serializing Subject: undefined', () => {
		assert.throws(
			() =>
				assembleComposedMime({
					from: 'me@kentcdodds.com',
					to: 'pierre@strapi.com',
					subject: undefined as unknown as string,
					body: 'Hi',
				}),
			/real subject string/,
		)
	})
})

describe('assertComposedReplyThreading', () => {
	it('allows new messages with no threading fields', () => {
		assert.deepEqual(assertComposedReplyThreading({}), {})
	})

	it('requires threadId and inReplyTo together', () => {
		assert.throws(
			() => assertComposedReplyThreading({ threadId: '1a06fde84f207bc6' }),
			/both threadId and inReplyTo/,
		)
		assert.throws(
			() => assertComposedReplyThreading({ inReplyTo: '<abc@mail.gmail.com>' }),
			/both threadId and inReplyTo/,
		)
	})

	it('defaults references from inReplyTo when omitted', () => {
		assert.deepEqual(
			assertComposedReplyThreading({
				threadId: '1a06fde84f207bc6',
				inReplyTo: '<abc@mail.gmail.com>',
			}),
			{
				threadId: '1a06fde84f207bc6',
				inReplyTo: '<abc@mail.gmail.com>',
				references: '<abc@mail.gmail.com>',
			},
		)
	})

	it('rejects literal undefined/null tokens', () => {
		assert.throws(
			() =>
				assertComposedReplyThreading({
					threadId: 'undefined',
					inReplyTo: '<abc@mail.gmail.com>',
				}),
			/threadId must not be the literal/,
		)
	})
})


describe('UTF-8 base64 round-trip (en-dash pit of success)', () => {
	it('round-trips U+2013 en-dash through encodeUtf8Base64 / decodeUtf8Base64', () => {
		const original = 'Pricing \u2013 early bird ends Friday' // en-dash
		const encoded = encodeUtf8Base64(original)
		const decoded = decodeUtf8Base64(encoded)
		assert.equal(decoded, original)
		// UTF-8 bytes for en-dash are E2 80 93 — must survive
		const bytes = decodeBase64ToBytes(encoded)
		const utf8 = new TextEncoder().encode(original)
		const idx = utf8.indexOf(0xe2)
		assert.equal(bytes[idx], 0xe2)
		assert.equal(bytes[idx + 1], 0x80)
		assert.equal(bytes[idx + 2], 0x93) // U+2013 en-dash
	})

	it('round-trips en-dash through Base64URL helpers used for Gmail raw', () => {
		const original = 'Bullets with \u2013 and \u2014 dashes'
		const encoded = toBase64Url(original)
		assert.equal(decodeUtf8Base64Url(encoded), original)
		assert.equal(new TextDecoder().decode(decodeBase64ToBytes(encoded)), original)
	})

	it('documents the bare-atob mojibake failure mode', () => {
		const original = 'en-dash: \u2013'
		const utf8Bytes = new TextEncoder().encode(original)
		const b64 = encodeBytesToBase64(utf8Bytes)
		// Bare atob → Latin-1 string; re-encoding that as UTF-8 is the incident bug
		const latin1 = atob(b64)
		const mojibake = new TextEncoder().encode(latin1)
		const mojibakeText = new TextDecoder().decode(mojibake)
		assert.notEqual(mojibakeText, original)
		assert.match(mojibakeText, /â/)
		// Safe path stays clean
		assert.equal(decodeUtf8Base64(b64), original)
	})

	it('decodeBase64Latin1_DO_NOT_USE_FOR_MIME always throws', () => {
		assert.throws(() => decodeBase64Latin1_DO_NOT_USE_FOR_MIME('YQ=='), /bare atob/)
	})
})

describe('header-only MIME update leaves body bytes unchanged', () => {
	it('adds Bcc without altering body bytes containing UTF-8 en-dashes', () => {
		const bodyText =
			'Content-Type: text/plain; charset="UTF-8"\r\n' +
			'Content-Transfer-Encoding: base64\r\n\r\n' +
			encodeUtf8Base64('Launch \u2014 now\n\u2022 early bird \u2013 ends soon\n')
		const bodyBytes = new TextEncoder().encode(bodyText)
		const headers =
			'From: "Kent C. Dodds" <me@kentcdodds.com>\r\n' +
			'To: list@example.com\r\n' +
			'Subject: Pricing announcement'
		const raw = new Uint8Array([
			...new TextEncoder().encode(headers),
			0x0d, 0x0a, 0x0d, 0x0a,
			...bodyBytes,
		])
		const before = splitMimeRaw(raw)
		assert.deepEqual(Array.from(before.bodyBytes), Array.from(bodyBytes))

		const patched = applyMimeHeaderUpdates(raw, { bcc: 'me+log@kentcdodds.com' })
		const after = splitMimeRaw(patched)
		assert.deepEqual(Array.from(after.bodyBytes), Array.from(bodyBytes))
		assert.match(after.headersText, /^Bcc: me\+log@kentcdodds\.com$/m)
		assert.match(after.headersText, /^To: list@example\.com$/m)
		// Body still decodes to en-dashes when read as UTF-8 base64 part
		assert.ok(after.bodyBytes.every((b, i) => b === bodyBytes[i]))
	})

	it('replaces existing Bcc and can remove Cc with null', () => {
		const bodyBytes = new Uint8Array([0xe2, 0x80, 0x93]) // raw en-dash bytes in body
		const headers =
			'From: me@kentcdodds.com\r\n' +
			'To: a@example.com\r\n' +
			'Cc: old-cc@example.com\r\n' +
			'Bcc: old-bcc@example.com\r\n' +
			'Subject: Hi'
		const raw = new Uint8Array([
			...new TextEncoder().encode(headers),
			0x0d, 0x0a, 0x0d, 0x0a,
			...bodyBytes,
		])
		const patched = applyMimeHeaderUpdates(raw, {
			bcc: 'new-bcc@example.com',
			cc: null,
		})
		const after = splitMimeRaw(patched)
		assert.deepEqual(Array.from(after.bodyBytes), Array.from(bodyBytes))
		assert.match(after.headersText, /^Bcc: new-bcc@example\.com$/m)
		assert.doesNotMatch(after.headersText, /^Cc:/m)
		assert.doesNotMatch(after.headersText, /old-bcc/)
	})

	it('preserves body across Base64URL encode/decode used by draft updates', () => {
		const bodyBytes = new Uint8Array([0xe2, 0x80, 0x93, 0xe2, 0x80, 0x94, 0x0a])
		const headers = 'From: me@kentcdodds.com\r\nTo: a@example.com\r\nSubject: X'
		const raw = new Uint8Array([
			...new TextEncoder().encode(headers),
			0x0d, 0x0a, 0x0d, 0x0a,
			...bodyBytes,
		])
		const patched = applyMimeHeaderUpdates(raw, { bcc: 'bcc@example.com' })
		const wire = encodeBytesToBase64Url(patched)
		const roundTrip = decodeBase64ToBytes(wire)
		const after = splitMimeRaw(roundTrip)
		assert.deepEqual(Array.from(after.bodyBytes), Array.from(bodyBytes))
	})
})