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" '), '"Kent C. Dodds" ', ) }) it('joins an address list with commas', () => { assert.equal( formatAddressHeader(['sponsor-kcd@10xn.dev', 'Pierre Burgy ']), 'sponsor-kcd@10xn.dev, Pierre Burgy ', ) }) it('RFC 2047 encodes a non-ASCII display name', () => { assert.equal( formatAddressHeader('Pierre Burgy ๐Ÿฅ '), '=?UTF-8?B?' + encodeUtf8Base64('Pierre Burgy ๐Ÿฅ') + '?= ', ) }) }) describe('assembleComposedMime', () => { it('includes To, Cc, and Bcc headers', () => { const assembled = assembleComposedMime({ from: '"Kent C. Dodds" ', to: 'Verity ', 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" /m) assert.match(assembled.mime, /^To: Verity /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']) assert.deepEqual( extractEmails('A , B '), ['a@example.com', 'b@example.com'], ) }) it('isOnlyMailbox is true when every address is the mailbox', () => { assert.equal(isOnlyMailbox('"Kent" ', mailbox), true) assert.equal(isOnlyMailbox('jaimie@example.com', mailbox), false) }) it('excludeMailboxAddresses keeps other recipients', () => { assert.equal( excludeMailboxAddresses( '"Kent" , Jaimie ', mailbox, ), 'Jaimie ', ) }) it('defaultReplyToHeader uses From for inbound mail', () => { assert.equal( defaultReplyToHeader({ mailboxEmail: mailbox, from: 'Jaimie ', to: '"Kent" ', }), 'Jaimie ', ) }) it('defaultReplyToHeader uses original To for own outbound mail', () => { assert.equal( defaultReplyToHeader({ mailboxEmail: mailbox, from: '"Kent C. Dodds" ', to: 'Jaimie ', }), 'Jaimie ', ) }) 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: '' }), /both threadId and inReplyTo/, ) }) it('defaults references from inReplyTo when omitted', () => { assert.deepEqual( assertComposedReplyThreading({ threadId: '1a06fde84f207bc6', inReplyTo: '', }), { threadId: '1a06fde84f207bc6', inReplyTo: '', references: '', }, ) }) it('rejects literal undefined/null tokens', () => { assert.throws( () => assertComposedReplyThreading({ threadId: 'undefined', inReplyTo: '', }), /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" \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)) }) })