Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/google

src/gmail-mime.ts

121 lines · 3.9 KB · TypeScript
export type GmailAddressList = string | string[]

export function encodeUtf8Base64(text: string): string {
	const bytes = new TextEncoder().encode(text)
	let binary = ''
	for (const byte of bytes) binary += String.fromCharCode(byte)
	return btoa(binary)
}

export function toBase64Url(text: string): string {
	return encodeUtf8Base64(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

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

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

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(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,
	}
}