Skip to content

src/gmail-mime.ts

511 lines · 17.7 KB · TypeScript
Raw
/**
 * UTF-8-safe Gmail MIME / Base64 helpers and header-only raw MIME patching.
 *
 * Prefer `updateDraftHeaders` from `./gmail` for Bcc/Cc/To changes. Never decode
 * Gmail bodies with bare `atob` (Latin-1) — use `decodeUtf8Base64` /
 * `decodeBase64ToBytes`. See `AGENTS.md`.
 * @example
 * import { decodeUtf8Base64, applyMimeHeaderUpdates } from 'kody:@kentcdodds/google/gmail-mime'
 */
export type GmailAddressList = string | string[]

/** Convert bytes to a Latin-1 binary string for `btoa` / header round-trips. */
export function bytesToBinaryString(bytes: Uint8Array): string {
	let binary = ''
	for (const byte of bytes) binary += String.fromCharCode(byte)
	return binary
}

/** Inverse of `bytesToBinaryString` — each char code is one byte (0–255). */
export function binaryStringToBytes(binary: string): Uint8Array {
	const bytes = new Uint8Array(binary.length)
	for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) & 0xff
	return bytes
}

/**
 * Encode UTF-8 text as standard Base64.
 * Prefer this (or `encodeBytesToBase64`) over building MIME from bare `btoa(text)`.
 */
export function encodeUtf8Base64(text: string): string {
	return encodeBytesToBase64(new TextEncoder().encode(text))
}

/** Encode raw bytes as standard Base64 (body-preserving; no string decode). */
export function encodeBytesToBase64(bytes: Uint8Array): string {
	return btoa(bytesToBinaryString(bytes))
}

/** Encode UTF-8 text as Base64URL (Gmail `raw` field). */
export function toBase64Url(text: string): string {
	return encodeBytesToBase64Url(new TextEncoder().encode(text))
}

/** Encode raw bytes as Base64URL (Gmail `raw` field; body-preserving). */
export function encodeBytesToBase64Url(bytes: Uint8Array): string {
	return encodeBytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

function padBase64(normalized: string): string {
	return normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
}

/**
 * Decode standard or Base64URL into raw bytes.
 * Use this when you must preserve exact MIME body bytes (header-only edits).
 */
export function decodeBase64ToBytes(data: string): Uint8Array {
	const normalized = String(data).replace(/-/g, '+').replace(/_/g, '/')
	const binary = atob(padBase64(normalized))
	return binaryStringToBytes(binary)
}

/** Alias for {@link decodeBase64ToBytes} — accepts Gmail Base64URL. */
export function decodeBase64UrlToBytes(data: string): Uint8Array {
	return decodeBase64ToBytes(data)
}

/**
 * Decode Base64/Base64URL as UTF-8 text via `Uint8Array` + `TextDecoder`.
 * Never use bare `atob` for Gmail bodies: `atob` is Latin-1-per-byte and turns
 * UTF-8 en-dashes (U+2013, bytes E2 80 93) into mojibake (–) when re-encoded.
 */
export function decodeUtf8Base64(data: string): string {
	return new TextDecoder('utf-8', { fatal: false }).decode(decodeBase64ToBytes(data))
}

/** Decode Gmail Base64URL payload data as UTF-8 text. */
export function decodeUtf8Base64Url(data: string): string {
	return decodeUtf8Base64(data)
}

/**
 * Loud footgun: bare `atob` / Latin-1 string decode of MIME bodies.
 * Always throws — use `decodeUtf8Base64`, `decodeUtf8Base64Url`, or
 * `decodeBase64ToBytes` instead.
 */
export function decodeBase64Latin1_DO_NOT_USE_FOR_MIME(_data: string): never {
	throw new Error(
		'Do not decode Gmail MIME bodies with bare atob / Latin-1. That corrupts UTF-8 (en-dash U+2013 → –). Use decodeUtf8Base64, decodeUtf8Base64Url, or decodeBase64ToBytes from kody:@kentcdodds/google/gmail (or ./gmail-mime). Prefer updateDraftHeaders for Bcc/Cc/To changes so body bytes are never rewritten.',
	)
}

export type MimeHeaderUpdates = {
	to?: GmailAddressList | null
	cc?: GmailAddressList | null
	bcc?: GmailAddressList | null
	from?: string | null
	subject?: string | null
}

export type SplitMimeRaw = {
	/** Header block without the blank-line separator (binary/Latin-1 string). */
	headersText: string
	/** Exact body bytes after the header/body separator — never re-encode these. */
	bodyBytes: Uint8Array
	/** Separator that was found (`\r\n\r\n` or `\n\n`). */
	separator: '\r\n\r\n' | '\n\n'
}

/**
 * Split RFC 2822 raw bytes into headers + body without decoding the body.
 * Body bytes are returned unchanged for header-only draft updates.
 */
export function splitMimeRaw(raw: Uint8Array): SplitMimeRaw {
	const crlf = indexOfBytes(raw, [0x0d, 0x0a, 0x0d, 0x0a])
	if (crlf >= 0) {
		return {
			headersText: bytesToBinaryString(raw.subarray(0, crlf)),
			bodyBytes: raw.subarray(crlf + 4),
			separator: '\r\n\r\n',
		}
	}
	const lf = indexOfBytes(raw, [0x0a, 0x0a])
	if (lf >= 0) {
		return {
			headersText: bytesToBinaryString(raw.subarray(0, lf)),
			bodyBytes: raw.subarray(lf + 2),
			separator: '\n\n',
		}
	}
	return {
		headersText: bytesToBinaryString(raw),
		bodyBytes: new Uint8Array(0),
		separator: '\r\n\r\n',
	}
}

function indexOfBytes(haystack: Uint8Array, needle: number[]): number {
	outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
		for (let j = 0; j < needle.length; j++) {
			if (haystack[i + j] !== needle[j]) continue outer
		}
		return i
	}
	return -1
}

/** Unfold + split header block into logical header lines (joins folded continuations). */
export function splitHeaderLines(headersText: string): string[] {
	const normalized = headersText.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
	const rawLines = normalized.split('\n')
	const lines: string[] = []
	for (const line of rawLines) {
		if (/^[ \t]/.test(line) && lines.length > 0) {
			lines[lines.length - 1] += '\r\n' + line
			continue
		}
		lines.push(line)
	}
	return lines.filter((line, i, arr) => line.length > 0 || i < arr.length - 1)
}

/**
 * Set, replace, or remove one header (case-insensitive name match).
 * Pass `value: null` to remove. Preserves other header lines byte-for-byte
 * (via Latin-1) when rejoined with CRLF.
 */
export function setMimeHeader(headersText: string, name: string, value: string | null): string {
	const target = name.toLowerCase()
	const kept = splitHeaderLines(headersText).filter((line) => {
		const idx = line.indexOf(':')
		if (idx < 0) return true
		return line.slice(0, idx).trim().toLowerCase() !== target
	})
	if (value != null) kept.push(name + ': ' + value)
	return kept.join('\r\n')
}

/**
 * Apply To/Cc/Bcc/From/Subject updates to raw MIME bytes without touching body
 * bytes. Omitted keys are left alone; `null` removes that header.
 */
export function applyMimeHeaderUpdates(raw: Uint8Array, updates: MimeHeaderUpdates): Uint8Array {
	const { headersText, bodyBytes, separator } = splitMimeRaw(raw)
	let headers = headersText
	const applyAddress = (key: 'to' | 'cc' | 'bcc' | 'from', headerName: string) => {
		if (!(key in updates)) return
		const value = updates[key]
		if (value === null) {
			headers = setMimeHeader(headers, headerName, null)
			return
		}
		if (key === 'from') {
			const encoded = encodeHeaderValue(String(value ?? ''))
			headers = setMimeHeader(headers, headerName, encoded || null)
			return
		}
		const formatted = formatAddressHeader(value as GmailAddressList | null | undefined)
		headers = setMimeHeader(headers, headerName, formatted)
	}
	applyAddress('from', 'From')
	applyAddress('to', 'To')
	applyAddress('cc', 'Cc')
	applyAddress('bcc', 'Bcc')
	if ('subject' in updates) {
		const subject = updates.subject
		if (subject === null) headers = setMimeHeader(headers, 'Subject', null)
		else if (subject != null) headers = setMimeHeader(headers, 'Subject', encodeHeaderValue(requireRealSubject(subject)))
	}
	// Headers are rejoined with CRLF; always use a CRLF blank line before the preserved body.
	void separator
	const sepBytes = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a])
	const headerBytes = binaryStringToBytes(headers)
	const out = new Uint8Array(headerBytes.length + sepBytes.length + bodyBytes.length)
	out.set(headerBytes, 0)
	out.set(sepBytes, headerBytes.length)
	out.set(bodyBytes, headerBytes.length + sepBytes.length)
	return out
}

export function encodeHeaderValue(value: string): string {
	if (!value || !/[^\x20-\x7E]/.test(value)) return value
	const match = /^(.*?)\s*(<[^>]+>)\s*$/.exec(value)
	if (!match) return '=?UTF-8?B?' + encodeUtf8Base64(value) + '?='
	const name = match[1].replace(/^"|"$/g, '')
	return '=?UTF-8?B?' + encodeUtf8Base64(name) + '?= ' + match[2]
}

/** Encode one address or a list into a single RFC 5322 header value. */
export function formatAddressHeader(value: GmailAddressList | null | undefined): string | null {
	if (value == null) return null
	const parts = (Array.isArray(value) ? value : [value]).map((part) => String(part).trim()).filter(Boolean)
	if (parts.length === 0) return null
	return parts.map((part) => encodeHeaderValue(part)).join(', ')
}

/** Lowercased bare emails found in an RFC 5322 address header/value. */
export function extractEmails(value: string | null | undefined): string[] {
	if (!value) return []
	const matches = String(value).match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) ?? []
	return matches.map((email) => email.toLowerCase())
}

/** Split a To/Cc header into addresses without breaking quoted display names. */
export function splitAddressList(header: string | null | undefined): string[] {
	if (!header) return []
	const parts: string[] = []
	let current = ''
	let inQuotes = false
	for (const ch of header) {
		if (ch === '"') inQuotes = !inQuotes
		if (ch === ',' && !inQuotes) {
			const trimmed = current.trim()
			if (trimmed) parts.push(trimmed)
			current = ''
			continue
		}
		current += ch
	}
	const trimmed = current.trim()
	if (trimmed) parts.push(trimmed)
	return parts
}

export function isOnlyMailbox(value: string | null | undefined, mailboxEmail: string): boolean {
	const emails = extractEmails(value)
	const box = mailboxEmail.trim().toLowerCase()
	return Boolean(box) && emails.length > 0 && emails.every((email) => email === box)
}

/** Drop address parts whose only emails are the mailbox. */
export function excludeMailboxAddresses(
	header: string | null | undefined,
	mailboxEmail: string,
): string | null {
	const box = mailboxEmail.trim().toLowerCase()
	const kept = splitAddressList(header).filter((part) => {
		const emails = extractEmails(part)
		if (emails.length === 0) return true
		return emails.some((email) => email !== box)
	})
	return kept.length > 0 ? kept.join(', ') : null
}

/**
 * Default reply To: Reply-To else From for inbound mail.
 * For own outbound (From is the mailbox), use original To (minus self).
 */
export function defaultReplyToHeader(input: {
	mailboxEmail: string
	from: string
	to: string
	replyTo?: string
}): string | null {
	const mailbox = input.mailboxEmail.trim().toLowerCase()
	const replyTo = (input.replyTo ?? '').trim()
	const from = (input.from ?? '').trim()
	const to = (input.to ?? '').trim()
	if (mailbox && isOnlyMailbox(from, mailbox)) {
		return (
			excludeMailboxAddresses(to, mailbox) ||
			(replyTo && !isOnlyMailbox(replyTo, mailbox) ? replyTo : null)
		)
	}
	if (replyTo && !isOnlyMailbox(replyTo, mailbox)) return replyTo
	if (from && !isOnlyMailbox(from, mailbox)) return from
	return excludeMailboxAddresses(to, mailbox)
}

export const SELF_SEND_TRAP_MESSAGE =
	'Refusing To set only to this mailbox (Gmail self-send trap). Pass allowSelfSend: true if intentional. For replies to your own outbound message, omit `to` on createReplyDraft — it defaults to the original recipient(s), not From.'

export function assertNotSelfOnlyTo(input: {
	to: GmailAddressList | string | null | undefined
	mailboxEmail: string
	allowSelfSend?: boolean
	action?: string
}): void {
	if (input.allowSelfSend === true) return
	const header =
		typeof input.to === 'string' || input.to == null
			? formatAddressHeader(input.to as string | null | undefined)
			: formatAddressHeader(input.to)
	if (!header || !isOnlyMailbox(header, input.mailboxEmail)) return
	const action = input.action ? ` ${input.action}` : ''
	throw new Error(SELF_SEND_TRAP_MESSAGE + (action ? ` (during${action})` : ''))
}

export function bodyParagraphs(body: string | string[]): string[] {
	return (Array.isArray(body) ? body : [body]).map((part) => String(part).trim()).filter(Boolean)
}

export function escapeHtml(text: string): string {
	return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

export function linkify(escaped: string): string {
	return escaped.replace(/(https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)])/g, '<a href="$1">$1</a>')
}

export function randomBoundary(label: string): string {
	return '=_' + label + '_' + crypto.randomUUID().replace(/-/g, '')
}

export function base64Body(standardBase64: string): string {
	return (standardBase64.match(/.{1,76}/g) ?? []).join('\r\n')
}

/** Reject missing/empty/coerced subjects so MIME never ships `Subject: undefined`. */
export function requireRealSubject(subject: unknown): string {
	if (typeof subject !== 'string') {
		const got =
			subject === undefined ? 'undefined' : subject === null ? 'null' : typeof subject
		throw new Error(
			'Composed Gmail message requires a real subject string (got ' +
				got +
				'). Pass the actual subject (e.g. "Re: …"); do not omit it or pass undefined/null.',
		)
	}
	const trimmed = subject.trim()
	if (!trimmed) {
		throw new Error('Composed Gmail message requires a non-empty subject string.')
	}
	if (trimmed === 'undefined' || trimmed === 'null') {
		throw new Error(
			'Composed Gmail message subject must not be the literal string "undefined" or "null". Pass the real subject (e.g. from the thread or "Re: …").',
		)
	}
	return trimmed
}

/** Normalize optional reply tokens; reject JS-coercion footguns ("undefined"/"null"). */
export function normalizeOptionalToken(value: unknown, field: string): string | undefined {
	if (value == null) return undefined
	if (typeof value !== 'string') {
		throw new Error('Gmail ' + field + ' must be a string when provided (got ' + typeof value + ').')
	}
	const trimmed = value.trim()
	if (!trimmed) return undefined
	if (trimmed === 'undefined' || trimmed === 'null') {
		throw new Error(
			'Gmail ' + field + ' must not be the literal string "undefined" or "null".',
		)
	}
	return trimmed
}

/**
 * When any reply-threading field is set, require both `threadId` and `inReplyTo`
 * so Gmail does not silently open a new thread. `references` defaults to `inReplyTo`
 * when omitted. New messages that omit all three are unchanged.
 */
export function assertComposedReplyThreading(input: {
	threadId?: string | null
	inReplyTo?: string | null
	references?: string | null
}): { threadId?: string; inReplyTo?: string; references?: string } {
	const threadId = normalizeOptionalToken(input.threadId, 'threadId')
	const inReplyTo = normalizeOptionalToken(input.inReplyTo, 'inReplyTo')
	const references = normalizeOptionalToken(input.references, 'references')
	if (!threadId && !inReplyTo && !references) return {}
	if (!threadId || !inReplyTo) {
		throw new Error(
			'Composed Gmail reply requires both threadId and inReplyTo (RFC Message-ID) so Gmail stays on the existing thread. Prefer createReplyDraft({ replyToMessageId, body }), which derives subject, threadId, In-Reply-To, and References. Bare sendMessage without those fields starts a new thread.',
		)
	}
	return { threadId, inReplyTo, references: references ?? inReplyTo }
}

export type GmailComposedFields = {
	from: string
	to: GmailAddressList
	cc?: GmailAddressList | null
	bcc?: GmailAddressList | null
	subject: string
	body: string | string[]
	inReplyTo?: string | null
	references?: string | null
}

export type AssembledMime = {
	mime: string
	text: string
	html: string
	from: string
	to: string
	cc: string | null
	bcc: string | null
	subject: string
}

export function assembleComposedMime(fields: GmailComposedFields): AssembledMime {
	const paragraphs = bodyParagraphs(fields.body)
	if (paragraphs.length === 0) throw new Error('Composed Gmail message requires non-empty body text.')
	const to = formatAddressHeader(fields.to)
	if (!to) throw new Error('Composed Gmail message requires a To address.')
	const from = encodeHeaderValue(fields.from)
	if (!from) throw new Error('Composed Gmail message requires a From address.')
	const cc = formatAddressHeader(fields.cc)
	const bcc = formatAddressHeader(fields.bcc)
	const subject = encodeHeaderValue(requireRealSubject(fields.subject))
	const text = paragraphs.join('\n\n') + '\n'
	const html = paragraphs
		.map((paragraph) => '<div dir="ltr">' + linkify(escapeHtml(paragraph)).replace(/\n/g, '<br>') + '</div>')
		.join('<div dir="ltr"><br></div>')
	const altBoundary = randomBoundary('alt')
	const alternative = [
		'--' + altBoundary,
		'Content-Type: text/plain; charset="UTF-8"',
		'Content-Transfer-Encoding: base64',
		'',
		base64Body(encodeUtf8Base64(text)),
		'--' + altBoundary,
		'Content-Type: text/html; charset="UTF-8"',
		'Content-Transfer-Encoding: base64',
		'',
		base64Body(encodeUtf8Base64(html)),
		'--' + altBoundary + '--',
	].join('\r\n')
	const headers = [
		'From: ' + from,
		'To: ' + to,
		...(cc ? ['Cc: ' + cc] : []),
		...(bcc ? ['Bcc: ' + bcc] : []),
		'Subject: ' + subject,
		...(fields.inReplyTo ? ['In-Reply-To: ' + fields.inReplyTo] : []),
		...(fields.references ? ['References: ' + fields.references] : []),
		'MIME-Version: 1.0',
		'Content-Type: multipart/alternative; boundary="' + altBoundary + '"',
	]
	return {
		mime: [...headers, '', alternative].join('\r\n'),
		text,
		html,
		from,
		to,
		cc,
		bcc,
		subject,
	}
}

/**
 * Return UTF-8-safe MIME / Base64 helpers (prefer `updateDraftHeaders` from `./gmail` for Bcc).
 * @example
 * import gmailMime from 'kody:@kentcdodds/google/gmail-mime'
 * const text = gmailMime().decodeUtf8Base64(partData)
 */
export default function gmailMime() {
	return {
		bytesToBinaryString,
		binaryStringToBytes,
		encodeUtf8Base64,
		encodeBytesToBase64,
		toBase64Url,
		encodeBytesToBase64Url,
		decodeBase64ToBytes,
		decodeBase64UrlToBytes,
		decodeUtf8Base64,
		decodeUtf8Base64Url,
		decodeBase64Latin1_DO_NOT_USE_FOR_MIME,
		splitMimeRaw,
		splitHeaderLines,
		setMimeHeader,
		applyMimeHeaderUpdates,
		encodeHeaderValue,
		formatAddressHeader,
		assembleComposedMime,
	}
}