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