/** * @vitest-environment node */ import { describe, expect, it } from 'vitest' import { buildMimeMessage, buildSimpleEmailMessage, encodeRfc2047, escapeHtml, htmlToPlainText, plainTextToHtml, sanitizeHeaderValue, } from './utils' function decodeSimpleMessage(encoded: string): string { return Buffer.from(encoded, 'base64url').toString('utf-8') } /** * Extract and base64-decode the body of a specific MIME part identified by its * Content-Type prefix (e.g. `text/plain`, `text/html`). Returns the decoded * UTF-8 string. */ function decodePart(mime: string, contentTypePrefix: string): string { const partRegex = new RegExp( `Content-Type: ${contentTypePrefix}[^\\n]*\\nContent-Transfer-Encoding: base64\\n\\n([\\s\\S]*?)\\n\\n--` ) const match = mime.match(partRegex) if (!match) throw new Error(`No ${contentTypePrefix} part found`) return Buffer.from(match[1].replace(/\n/g, ''), 'base64').toString('utf-8') } describe('encodeRfc2047', () => { it('returns ASCII text unchanged', () => { expect(encodeRfc2047('Simple ASCII Subject')).toBe('Simple ASCII Subject') }) it('returns empty string unchanged', () => { expect(encodeRfc2047('')).toBe('') }) it('encodes emojis as RFC 2047 base64', () => { const result = encodeRfc2047('Time to Stretch! 🧘') expect(result).toBe('=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?=') }) it('round-trips non-ASCII subjects correctly', () => { const subjects = ['Hello 世界', 'Café résumé', '🎉🎊🎈 Party!', '今週のミーティング'] for (const subject of subjects) { const encoded = encodeRfc2047(subject) const match = encoded.match(/^=\?UTF-8\?B\?(.+)\?=$/) expect(match).not.toBeNull() const decoded = Buffer.from(match![1], 'base64').toString('utf-8') expect(decoded).toBe(subject) } }) it('does not double-encode already-encoded subjects', () => { const alreadyEncoded = '=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?=' expect(encodeRfc2047(alreadyEncoded)).toBe(alreadyEncoded) }) }) describe('sanitizeHeaderValue', () => { it('collapses embedded CRLF to a single space', () => { expect(sanitizeHeaderValue('foo\r\nBcc: attacker@example.com')).toBe( 'foo Bcc: attacker@example.com' ) }) it('collapses embedded bare LF or CR to a single space', () => { expect(sanitizeHeaderValue('foo\nbar')).toBe('foo bar') expect(sanitizeHeaderValue('foo\rbar')).toBe('foo bar') }) it('collapses consecutive newlines to a single space', () => { expect(sanitizeHeaderValue('foo\r\n\r\nbar')).toBe('foo bar') }) it('leaves ordinary values unchanged', () => { expect(sanitizeHeaderValue('a@example.com, b@example.com')).toBe('a@example.com, b@example.com') }) }) describe('escapeHtml', () => { it('escapes the five HTML special characters', () => { expect(escapeHtml(``)).toBe( '<script>alert("x & y's")</script>' ) }) }) describe('plainTextToHtml', () => { it('converts newlines to
without paragraph margins', () => { const html = plainTextToHtml('Hi Janice,\n\nHope you are well.\nSecond line.') expect(html).not.toContain('

') expect(html).toContain('Hi Janice,

Hope you are well.
Second line.') }) it('escapes HTML in the source text', () => { expect(plainTextToHtml('bold')).toContain('<b>bold</b>') }) }) describe('htmlToPlainText', () => { it('strips tags and decodes entities', () => { const result = htmlToPlainText('

Hi & bye

Line
break

') expect(result).toBe('Hi & bye\n\nLine\nbreak') }) it('drops

Hi

')).toBe('Hi') }) it('does not double-decode compound entities like &lt;', () => { expect(htmlToPlainText('

&lt; is the literal < entity

')).toBe( '< is the literal < entity' ) }) it('decodes decimal and hexadecimal numeric entities', () => { expect(htmlToPlainText('

“hi” and’s

')).toBe( '\u201chi\u201d and\u2019s' ) }) it('preserves   (non-breaking space) as U+00A0 for fidelity in plain-text output', () => { expect(htmlToPlainText('

a b

')).toBe('a\u00a0b') }) it('elides anchor URLs that exactly match link text, and drops bare # anchors', () => { expect( htmlToPlainText('

Visit https://example.com

') ).toBe('Visit https://example.com') expect(htmlToPlainText('

Anchor

')).toBe('Anchor') }) }) describe('buildSimpleEmailMessage', () => { it('emits multipart/alternative with text/plain then text/html for plain-text input', () => { const encoded = buildSimpleEmailMessage({ to: 'a@example.com', subject: 'Hi', body: 'Hi Janice,\n\nQuick question.', }) const decoded = decodeSimpleMessage(encoded) expect(decoded).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/) const plainIdx = decoded.indexOf('text/plain') const htmlIdx = decoded.indexOf('text/html') expect(plainIdx).toBeGreaterThan(-1) expect(htmlIdx).toBeGreaterThan(plainIdx) expect(decodePart(decoded, 'text/plain')).toBe('Hi Janice,\n\nQuick question.') expect(decodePart(decoded, 'text/html')).toContain('Hi Janice,

Quick question.') }) it('encodes bodies as base64 so UTF-8 (emoji, accents) round-trips cleanly', () => { const body = 'Café 🎉 — résumé' const encoded = buildSimpleEmailMessage({ to: 'a@example.com', subject: 'Hi', body, }) const decoded = decodeSimpleMessage(encoded) expect(decoded).toContain('Content-Transfer-Encoding: base64') expect(decodePart(decoded, 'text/plain')).toBe(body) expect(decodePart(decoded, 'text/html')).toContain('Café 🎉 — résumé') }) it('uses the supplied HTML body and derives a plain-text fallback when contentType is html', () => { const encoded = buildSimpleEmailMessage({ to: 'a@example.com', subject: 'Hi', body: '

Hello there

', contentType: 'html', }) const decoded = decodeSimpleMessage(encoded) expect(decodePart(decoded, 'text/html')).toBe('

Hello there

') expect(decodePart(decoded, 'text/plain')).toBe('Hello there') }) it('includes threading headers when replying', () => { const encoded = buildSimpleEmailMessage({ to: 'a@example.com', body: 'reply', inReplyTo: '', references: '', }) const decoded = decodeSimpleMessage(encoded) expect(decoded).toContain('In-Reply-To: ') expect(decoded).toContain('References: ') }) it('strips embedded CRLF from to/cc/bcc/subject/inReplyTo/references so no extra header line is produced', () => { const injected = 'innocuous\r\nBcc: attacker@example.com' const encoded = buildSimpleEmailMessage({ to: injected, cc: injected, bcc: injected, subject: injected, body: 'hello', inReplyTo: injected, references: injected, }) const decoded = decodeSimpleMessage(encoded) const lines = decoded.split('\n') const bccLines = lines.filter((line) => line.startsWith('Bcc:')) // Exactly one Bcc header line (the legitimate one), holding the sanitized, single-line value. expect(bccLines).toHaveLength(1) expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com') expect(lines).not.toContain('Bcc: attacker@example.com') }) it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => { const encoded = buildSimpleEmailMessage({ to: 'a@example.com, b@example.com', cc: 'c@example.com', subject: 'Café meeting 🎉', body: 'hello', }) const decoded = decodeSimpleMessage(encoded) expect(decoded).toContain('To: a@example.com, b@example.com') expect(decoded).toContain('Cc: c@example.com') expect(decoded).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`) }) }) describe('buildMimeMessage', () => { it('nests multipart/alternative inside multipart/mixed when attachments are present', () => { const message = buildMimeMessage({ to: 'a@example.com', subject: 'Hi', body: 'Hello', attachments: [ { filename: 'note.txt', mimeType: 'text/plain', content: Buffer.from('hi'), }, ], }) expect(message).toMatch(/Content-Type: multipart\/mixed; boundary="([^"]+)"/) expect(message).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/) expect(message).toContain('Content-Disposition: attachment; filename="note.txt"') expect(decodePart(message, 'text/plain')).toBe('Hello') expect(decodePart(message, 'text/html')).toContain('Hello') }) it('emits multipart/alternative without multipart/mixed when no attachments', () => { const message = buildMimeMessage({ to: 'a@example.com', subject: 'Hi', body: 'Hello', }) expect(message).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/) expect(message).not.toContain('multipart/mixed') }) it('strips embedded CRLF from header fields and the attachment filename', () => { const injected = 'innocuous\r\nBcc: attacker@example.com' const message = buildMimeMessage({ to: injected, cc: injected, bcc: injected, subject: injected, body: 'hello', inReplyTo: injected, references: injected, attachments: [ { filename: injected, mimeType: 'text/plain', content: Buffer.from('hi'), }, ], }) const lines = message.split('\n') const bccLines = lines.filter((line) => line.startsWith('Bcc:')) expect(bccLines).toHaveLength(1) expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com') expect(lines).not.toContain('Bcc: attacker@example.com') expect(message).toContain( 'Content-Disposition: attachment; filename="innocuous Bcc: attacker@example.com"' ) }) it('strips embedded CRLF from the attachment mimeType', () => { const injected = 'text/plain\r\nBcc: attacker@example.com' const message = buildMimeMessage({ to: 'a@example.com', body: 'hello', attachments: [ { filename: 'note.txt', mimeType: injected, content: Buffer.from('hi'), }, ], }) const lines = message.split('\n') const bccLines = lines.filter((line) => line.startsWith('Bcc:')) expect(bccLines).toHaveLength(0) expect(message).toContain('Content-Type: text/plain Bcc: attacker@example.com') }) it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => { const message = buildMimeMessage({ to: 'a@example.com, b@example.com', cc: 'c@example.com', subject: 'Café meeting 🎉', body: 'hello', attachments: [ { filename: 'note.txt', mimeType: 'text/plain', content: Buffer.from('hi'), }, ], }) expect(message).toContain('To: a@example.com, b@example.com') expect(message).toContain('Cc: c@example.com') expect(message).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`) expect(message).toContain('Content-Disposition: attachment; filename="note.txt"') }) })