← Public packages
@kentcdodds/google
Google helpers for Gmail, Calendar, Drive, People, YouTube (including upload), and Analytics.
src/gmail-mime.ts
511 lines · 17.7 KB · TypeScript/**
* 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, '>').replace(/"/g, '"')
}
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,
}
}