/** * 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, '&').replace(//g, '>').replace(/"/g, '"') } export function linkify(escaped: string): string { return escaped.replace(/(https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)])/g, '$1') } 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) => '
' + linkify(escapeHtml(paragraph)).replace(/\n/g, '
') + '
') .join('

') 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, } }