/**
* @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
Hi
')).toBe('Hi') }) it('does not double-decode compound entities like <', () => { expect(htmlToPlainText('< 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('')).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,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: '