import { getUserInfo, requestGoogle } from './core.ts'
import {
assembleComposedMime,
encodeHeaderValue,
encodeUtf8Base64,
escapeHtml,
formatAddressHeader,
linkify,
randomBoundary,
toBase64Url,
type GmailAddressList,
} from './gmail-mime.ts'
import type { JsonObject } from './types.ts'
export type { GmailAddressList } from './gmail-mime.ts'
export type GmailSearchParams = { account: string; query?: string; q?: string; maxResults?: number; pageToken?: string; labelIds?: string | string[]; includeSpamTrash?: boolean; fields?: string }
export type GmailMessageParams = { account: string; id: string; format?: string; metadataHeaders?: string | string[]; fields?: string }
export type GmailThreadParams = GmailMessageParams
export type GmailRawMessageMutationParams = { account: string; raw: string; threadId?: string; dryRun?: boolean }
export type GmailComposedMutationParams = {
account: string
to: GmailAddressList
cc?: GmailAddressList
bcc?: GmailAddressList
from?: string
subject: string
body: string | string[]
threadId?: string
inReplyTo?: string
references?: string
dryRun?: boolean
}
export type GmailMessageMutationParams = GmailRawMessageMutationParams | GmailComposedMutationParams
export type GmailModifyMessageParams = { account: string; id: string; addLabelIds?: string[]; removeLabelIds?: string[]; dryRun?: boolean }
export type GmailEnsureLabelParams = { account: string; name: string; dryRun?: boolean }
export type GmailProfile = JsonObject
export async function getGmailProfile(params: { account: string }): Promise<GmailProfile> { return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/profile' }) as GmailProfile }
export async function searchMessages(params: GmailSearchParams): Promise<JsonObject> { const { query, q = query, maxResults = 10, pageToken, labelIds, includeSpamTrash, fields } = params; return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/messages', query: { q, maxResults, pageToken, labelIds, includeSpamTrash, fields } }) as JsonObject }
export async function getMessage(params: GmailMessageParams): Promise<JsonObject> { const { id, format = 'metadata', metadataHeaders, fields } = params; if (!id) throw new Error('getMessage requires id.'); return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/messages/' + encodeURIComponent(id), query: { format, metadataHeaders, fields } }) as JsonObject }
export async function getThread(params: GmailThreadParams): Promise<JsonObject> { const { id, format = 'metadata', metadataHeaders, fields } = params; if (!id) throw new Error('getThread requires id (Gmail thread id).'); return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/threads/' + encodeURIComponent(id), query: { format, metadataHeaders, fields } }) as JsonObject }
export async function listLabels(params: { account: string }): Promise<JsonObject> { return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/labels' }) as JsonObject }
export async function getAttachment(params: GmailAttachmentParams): Promise<JsonObject> { const { messageId, attachmentId } = params; if (!messageId || !attachmentId) throw new Error('getAttachment requires messageId and attachmentId.'); return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/messages/' + encodeURIComponent(messageId) + '/attachments/' + encodeURIComponent(attachmentId) }) as JsonObject }
function isRawMessageParams(params: GmailMessageMutationParams): params is GmailRawMessageMutationParams {
return typeof (params as GmailRawMessageMutationParams).raw === 'string'
}
async function resolveComposedRaw(params: GmailComposedMutationParams) {
const from = params.from ?? (await resolveFromAddress(params.account))
if (!from) throw new Error('Composed Gmail message could not resolve a From address.')
const assembled = assembleComposedMime({
from,
to: params.to,
cc: params.cc,
bcc: params.bcc,
subject: params.subject,
body: params.body,
inReplyTo: params.inReplyTo,
references: params.references,
})
return { assembled, raw: toBase64Url(assembled.mime) }
}
export async function createDraft(params: GmailMessageMutationParams): Promise<JsonObject> {
if (isRawMessageParams(params)) {
const { raw, dryRun = false, threadId } = params
if (!raw) throw new Error('createDraft requires raw base64url RFC 2822 message content.')
if (dryRun) return { dryRun: true, account: params.account, wouldCreateDraft: true, threadId: threadId ?? null }
const message: Record<string, string> = { raw }
if (threadId) message.threadId = threadId
return await requestGoogle({ ...params, api: 'gmail', method: 'POST', path: '/gmail/v1/users/me/drafts', body: { message } }) as JsonObject
}
const { assembled, raw } = await resolveComposedRaw(params)
if (params.dryRun) {
return { dryRun: true, account: params.account, wouldCreateDraft: true, threadId: params.threadId ?? null, ...assembled }
}
const message: Record<string, string> = { raw }
if (params.threadId) message.threadId = params.threadId
return await requestGoogle({ ...params, api: 'gmail', method: 'POST', path: '/gmail/v1/users/me/drafts', body: { message } }) as JsonObject
}
export async function sendMessage(params: GmailMessageMutationParams): Promise<JsonObject> {
if (isRawMessageParams(params)) {
const { raw, dryRun = false, threadId } = params
if (!raw) throw new Error('sendMessage requires raw base64url RFC 2822 message content.')
if (dryRun) return { dryRun: true, account: params.account, wouldSendMessage: true }
const body: Record<string, string> = { raw }
if (threadId) body.threadId = threadId
return await requestGoogle({ ...params, api: 'gmail', method: 'POST', path: '/gmail/v1/users/me/messages/send', body }) as JsonObject
}
const { assembled, raw } = await resolveComposedRaw(params)
if (params.dryRun) {
return { dryRun: true, account: params.account, wouldSendMessage: true, threadId: params.threadId ?? null, ...assembled }
}
const body: Record<string, string> = { raw }
if (params.threadId) body.threadId = params.threadId
return await requestGoogle({ ...params, api: 'gmail', method: 'POST', path: '/gmail/v1/users/me/messages/send', body }) as JsonObject
}
export async function modifyMessage(params: GmailModifyMessageParams): Promise<JsonObject> {
const { id, addLabelIds = [], removeLabelIds = [], dryRun = false } = params
if (!id) throw new Error('modifyMessage requires id.')
if (dryRun) return { dryRun: true, account: params.account, id, addLabelIds, removeLabelIds, wouldModifyMessage: true }
return await requestGoogle({
...params,
api: 'gmail',
method: 'POST',
path: '/gmail/v1/users/me/messages/' + encodeURIComponent(id) + '/modify',
body: { addLabelIds, removeLabelIds },
}) as JsonObject
}
export async function createLabel(params: { account: string; name: string; dryRun?: boolean }): Promise<JsonObject> {
const { name, dryRun = false } = params
if (!name) throw new Error('createLabel requires name.')
if (dryRun) return { dryRun: true, account: params.account, name, wouldCreateLabel: true }
return await requestGoogle({
...params,
api: 'gmail',
method: 'POST',
path: '/gmail/v1/users/me/labels',
body: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' },
}) as JsonObject
}
export async function ensureLabel(params: GmailEnsureLabelParams): Promise<JsonObject> {
const { account, name, dryRun = false } = params
if (!name) throw new Error('ensureLabel requires name.')
const listed = (await listLabels({ account })) as { labels?: Array<{ id?: string; name?: string }> }
const existing = (listed.labels ?? []).find((label) => label.name === name)
if (existing?.id) return { id: existing.id, name: existing.name ?? name, created: false }
if (dryRun) return { dryRun: true, account, name, wouldCreateLabel: true }
const created = (await createLabel({ account, name })) as { id?: string; name?: string }
return { id: created.id, name: created.name ?? name, created: true }
}
export type GmailReplyDraftParams = { account: string; replyToMessageId: string; body: string | string[]; from?: string; to?: GmailAddressList; cc?: GmailAddressList; bcc?: GmailAddressList; quote?: boolean; timeZone?: string; maxInlineImageBytes?: number; dryRun?: boolean }
export type GmailAttachmentParams = { account: string; messageId: string; attachmentId: string }
type InlineImagePart = { cid: string; mimeType: string; filename: string; attachmentId: string; size: number }
/** Inline images are re-attached to replies, matching what mail clients do, so quoted `cid:` references still resolve. */
const DEFAULT_MAX_INLINE_IMAGE_BYTES = 12 * 1024 * 1024
function decodeBase64Url(data: string): string { const normalized = data.replace(/-/g, '+').replace(/_/g, '/'); const binary = atob(normalized + '='.repeat((4 - (normalized.length % 4)) % 4)); return new TextDecoder().decode(Uint8Array.from(binary, c => c.charCodeAt(0))) }
function readHeader(message: JsonObject, name: string): string { const payload = message.payload as JsonObject | undefined; const headers = (payload?.headers ?? []) as Array<{ name?: string; value?: string }>; return headers.find(h => String(h.name ?? '').toLowerCase() === name.toLowerCase())?.value ?? '' }
function findBodyPart(payload: unknown, mimeType: string): string { if (!payload || typeof payload !== 'object') return ''; const part = payload as JsonObject & { mimeType?: string; body?: { data?: string }; parts?: Array<unknown> }; if (part.mimeType === mimeType && part.body?.data) return decodeBase64Url(part.body.data); for (const child of part.parts ?? []) { const found = findBodyPart(child, mimeType); if (found) return found } return '' }
function htmlToText(html: string): string { return html.replace(/<blockquote[\s\S]*$/i, '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&(?:#39|apos|rsquo|#8217);/g, "'").replace(/&(?:quot|ldquo|rdquo);/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/\n{3,}/g, '\n\n').trim() }
/** Original text as the sender wrote it: prefer text/plain, fall back to de-tagged HTML. */
function originalBodyText(message: JsonObject): string { const plain = findBodyPart(message.payload, 'text/plain'); if (plain) return plain.replace(/\r\n/g, '\n'); return htmlToText(findBodyPart(message.payload, 'text/html')) }
/**
* Render the attribution stamp the way mail clients do: in the reading mailbox's
* own time zone. Falls back to the sender's UTC offset when no zone is known.
*/
function attributionLine(dateHeader: string, fromHeader: string, timeZone: string | null): string { const parsed = Date.parse(dateHeader); if (Number.isNaN(parsed)) return 'On an earlier date ' + fromHeader + ' wrote:'; let stamp: Date; let zone: string; if (timeZone) { stamp = new Date(parsed); zone = timeZone } else { const offsetMatch = /([+-])(\d{2})(\d{2})\s*$/.exec(dateHeader.trim()); const offsetMinutes = offsetMatch ? (offsetMatch[1] === '-' ? -1 : 1) * (Number(offsetMatch[2]) * 60 + Number(offsetMatch[3])) : 0; stamp = new Date(parsed + offsetMinutes * 60_000); zone = 'UTC' } const format = (options: Intl.DateTimeFormatOptions) => stamp.toLocaleString('en-US', { ...options, timeZone: zone }); return 'On ' + format({ weekday: 'short' }) + ', ' + format({ month: 'short', day: 'numeric', year: 'numeric' }) + ' at ' + format({ hour: 'numeric', minute: '2-digit' }) + ' ' + fromHeader + ' wrote:' }
/** Time zone of the mailbox composing the reply, read from Calendar settings. */
async function resolveMailboxTimeZone(account: string): Promise<string | null> { try { const settings = (await requestGoogle({ account, api: 'google', path: '/calendar/v3/users/me/settings/timezone' })) as { value?: string }; return settings.value ?? null } catch { return null } }
/** Strip the document wrapper so quoted markup nests cleanly inside a blockquote. */
function innerBodyHtml(html: string): string { const match = /<body[^>]*>([\s\S]*)<\/body>/i.exec(html); return (match ? match[1] : html).trim() }
function collectInlineImageParts(payload: unknown, out: Array<InlineImagePart>): void { if (!payload || typeof payload !== 'object') return; const part = payload as JsonObject & { mimeType?: string; filename?: string; body?: { size?: number; attachmentId?: string }; parts?: Array<unknown>; headers?: Array<{ name?: string; value?: string }> }; const contentId = (part.headers ?? []).find(h => String(h.name ?? '').toLowerCase() === 'content-id')?.value; if (contentId && part.body?.attachmentId && String(part.mimeType ?? '').startsWith('image/')) { out.push({ cid: contentId.replace(/^<|>$/g, ''), mimeType: String(part.mimeType), filename: String(part.filename || 'image'), attachmentId: String(part.body.attachmentId), size: Number(part.body.size ?? 0) }) } for (const child of part.parts ?? []) collectInlineImageParts(child, out) }
function base64Body(standardBase64: string): string { return (standardBase64.match(/.{1,76}/g) ?? []).join('\r\n') }
/** Prefix every line with `>`, deepening markers already present so nested history survives. */
function quoteLines(text: string): string { return text.replace(/\s+$/, '').split('\n').map(line => (line.startsWith('>') ? '>' + line : '> ' + line).replace(/\s+$/, '')).join('\n') }
function replySubject(subject: string): string { const trimmed = subject.trim(); return /^re:/i.test(trimmed) ? trimmed : 'Re: ' + (trimmed || '(no subject)') }
/** Mailbox address for the authenticated account, with a display name when the openid scope allows it. */
async function resolveFromAddress(account: string): Promise<string> { const address = String(((await getGmailProfile({ account })) as { emailAddress?: string }).emailAddress ?? ''); if (!address) return ''; try { const name = (await getUserInfo({ account })).name?.trim(); return name ? '"' + name.replace(/"/g, '') + '" <' + address + '>' : address } catch { return address } }
const GMAIL_HEX_ID = /^[a-f0-9]{8,}$/i
export type GmailUrlInfo = { kind: string; id: string | null; threadId: string | null; messageId: string | null; folder: string | null; authuser: string | null; userIndex: number | null; url: string }
export function parseGmailUrl(input: string): GmailUrlInfo { const base: GmailUrlInfo = { kind: 'unknown', id: null, threadId: null, messageId: null, folder: null, authuser: null, userIndex: null, url: input }; let parsed: URL; try { parsed = new URL(input) } catch { return base } if (!(parsed.hostname === 'mail.google.com' || parsed.hostname.endsWith('.mail.google.com'))) return base; const authuser = parsed.searchParams.get('authuser'); const userIndexMatch = parsed.pathname.match(new RegExp('/mail/u/(\d+)')); const userIndex = userIndexMatch ? Number(userIndexMatch[1]) : null; const hash = (parsed.hash || '').replace(/^#/, ''); const segments = hash.split('/').filter(Boolean).map(s => decodeURIComponent(s)); if (segments.length === 0) return { ...base, folder: null, authuser, userIndex }; const folder = segments[0]; const tail = segments.slice(1); const out = { ...base, folder, authuser, userIndex }; if (folder === 'compose') { const draftId = tail[0] && tail[0] !== 'new' ? tail[0] : null; return { ...out, kind: 'compose', id: draftId } } if (folder === 'label') { const label = tail[0] || null; const threadCandidate = tail.find((seg, i) => i > 0 && GMAIL_HEX_ID.test(seg)) || null; const afterThread = threadCandidate ? tail[tail.indexOf(threadCandidate) + 1] : null; const messageCandidate = afterThread && GMAIL_HEX_ID.test(afterThread) ? afterThread : null; if (messageCandidate) return { ...out, kind: 'message', id: messageCandidate, threadId: threadCandidate, messageId: messageCandidate }; if (threadCandidate) return { ...out, kind: 'thread', id: threadCandidate, threadId: threadCandidate }; return { ...out, kind: 'label', id: label } } if (folder === 'search') { const query = tail[0] || null; const threadCandidate = tail.find((seg, i) => i > 0 && GMAIL_HEX_ID.test(seg)) || null; const afterThread = threadCandidate ? tail[tail.indexOf(threadCandidate) + 1] : null; const messageCandidate = afterThread && GMAIL_HEX_ID.test(afterThread) ? afterThread : null; if (messageCandidate) return { ...out, kind: 'message', id: messageCandidate, threadId: threadCandidate, messageId: messageCandidate }; if (threadCandidate) return { ...out, kind: 'thread', id: threadCandidate, threadId: threadCandidate }; return { ...out, kind: 'search', id: query } } if (tail.length === 0) return { ...out, kind: 'folder' }; const threadCandidate = tail[0] && GMAIL_HEX_ID.test(tail[0]) ? tail[0] : null; const messageCandidate = tail[1] && GMAIL_HEX_ID.test(tail[1]) ? tail[1] : null; if (threadCandidate && messageCandidate) return { ...out, kind: 'message', id: messageCandidate, threadId: threadCandidate, messageId: messageCandidate }; if (threadCandidate) return { ...out, kind: 'thread', id: threadCandidate, threadId: threadCandidate }; return out }
/**
* Create a threaded reply draft that quotes the original message.
*
* Everything except `body` is derived from the message being replied to, so the
* quoted text, recipient, subject, and threading headers are copied by code
* rather than retyped by a caller. Only `body` is authored.
*
* @param params.replyToMessageId - Gmail message id being replied to.
* @param params.body - Reply text. An array is joined as blank-line-separated paragraphs.
* @param params.to - Override the recipient. Defaults to the original `Reply-To`, else `From`.
* @param params.cc - Optional Cc address or list. Not inferred from the original message.
* @param params.bcc - Optional Bcc address or list. Included on the draft; Gmail strips it from recipients on send.
* @param params.from - Override the sender. Defaults to the authenticated mailbox address.
* @param params.quote - Set false to omit the quoted original. Defaults to true.
* @param params.dryRun - Return the assembled message without creating a draft.
* @example
* import { createReplyDraft } from 'kody:@kentcdodds/google/gmail'
* const draft = await createReplyDraft({ account: 'business', replyToMessageId: '19fd...', body: ['Hi Casey,', 'Thanks!', 'Talk soon'] })
* // => { id: 'r-123...', message: { id: '...', threadId: '...' } }
*/
export async function createReplyDraft(params: GmailReplyDraftParams): Promise<JsonObject> {
const { account, replyToMessageId, body, quote = true, dryRun = false } = params
if (!replyToMessageId) throw new Error('createReplyDraft requires replyToMessageId.')
const paragraphs = (Array.isArray(body) ? body : [body]).map(part => String(part).trim()).filter(Boolean)
if (paragraphs.length === 0) throw new Error('createReplyDraft requires non-empty body text.')
const original = await getMessage({ account, id: replyToMessageId, format: 'full' })
const originalMessageId = readHeader(original, 'Message-ID')
const originalFrom = readHeader(original, 'From')
const to = formatAddressHeader(params.to ?? (readHeader(original, 'Reply-To') || originalFrom))
if (!to) throw new Error('createReplyDraft could not resolve a To address from the original message.')
const cc = formatAddressHeader(params.cc)
const bcc = formatAddressHeader(params.bcc)
const from = params.from ?? (await resolveFromAddress(account))
if (!from) throw new Error('createReplyDraft could not resolve a From address.')
// References carries the full chain: prior chain plus the message we answer.
const references = [readHeader(original, 'References'), originalMessageId].filter(Boolean).join(' ')
const timeZone = params.timeZone ?? (await resolveMailboxTimeZone(account))
const attribution = attributionLine(readHeader(original, 'Date'), originalFrom, timeZone)
// Plain-text alternative: the ">" convention.
const originalText = originalBodyText(original)
const quotedText = quote ? [attribution, quoteLines(originalText)].filter(Boolean).join('\n') : ''
const text = (quotedText ? paragraphs.join('\n\n') + '\n\n' + quotedText : paragraphs.join('\n\n')) + '\n'
// HTML alternative: real nested blockquotes, so quoted formatting, links, and
// inline images survive instead of collapsing into ">" markers.
const originalHtmlRaw = findBodyPart(original.payload, 'text/html')
const quotedHtmlSource = originalHtmlRaw ? innerBodyHtml(originalHtmlRaw) : escapeHtml(originalText).replace(/\n/g, '<br>')
const replyHtml = paragraphs.map(paragraph => '<div dir="ltr">' + linkify(escapeHtml(paragraph)).replace(/\n/g, '<br>') + '</div>').join('<div dir="ltr"><br></div>')
const html = quote
? replyHtml + '<div dir="ltr"><br></div><div class="gmail_quote"><div dir="ltr" class="gmail_attr">' + escapeHtml(attribution) + '<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">' + quotedHtmlSource + '</blockquote></div>'
: replyHtml
// Re-attach only the inline images the quoted markup actually references.
const maxInlineBytes = params.maxInlineImageBytes ?? DEFAULT_MAX_INLINE_IMAGE_BYTES
const candidates: Array<InlineImagePart> = []
if (quote) collectInlineImageParts(original.payload, candidates)
const referenced = candidates.filter(part => html.includes('cid:' + part.cid))
const attachable: Array<InlineImagePart> = []
let budget = maxInlineBytes
for (const part of referenced) { if (part.size <= budget) { attachable.push(part); budget -= part.size } }
const skippedInlineImages = referenced.length - attachable.length
const inlineImages: Array<InlineImagePart & { data: string }> = []
for (const part of attachable) {
const attachment = (await getAttachment({ account, messageId: replyToMessageId, attachmentId: part.attachmentId })) as { data?: string }
if (attachment.data) inlineImages.push({ ...part, data: attachment.data.replace(/-/g, '+').replace(/_/g, '/') })
}
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: ' + encodeHeaderValue(from),
'To: ' + to,
...(cc ? ['Cc: ' + cc] : []),
...(bcc ? ['Bcc: ' + bcc] : []),
'Subject: ' + encodeHeaderValue(replySubject(readHeader(original, 'Subject'))),
...(originalMessageId ? ['In-Reply-To: ' + originalMessageId] : []),
...(references ? ['References: ' + references] : []),
'MIME-Version: 1.0',
]
let mime: string
if (inlineImages.length === 0) {
mime = [...headers, 'Content-Type: multipart/alternative; boundary="' + altBoundary + '"', '', alternative].join('\r\n')
} else {
const relBoundary = randomBoundary('rel')
const relatedParts = inlineImages.map(image => ['--' + relBoundary, 'Content-Type: ' + image.mimeType, 'Content-Transfer-Encoding: base64', 'Content-ID: <' + image.cid + '>', 'Content-Disposition: inline; filename="' + image.filename.replace(/"/g, '') + '"', '', base64Body(image.data)].join('\r\n'))
mime = [
...headers,
'Content-Type: multipart/related; type="multipart/alternative"; boundary="' + relBoundary + '"',
'',
'--' + relBoundary,
'Content-Type: multipart/alternative; boundary="' + altBoundary + '"',
'',
alternative,
...relatedParts,
'--' + relBoundary + '--',
].join('\r\n')
}
const threadId = typeof original.threadId === 'string' ? original.threadId : undefined
if (dryRun) return { dryRun: true, account, threadId: threadId ?? null, from, to, cc, bcc, timeZone, inlineImages: inlineImages.map(i => ({ cid: i.cid, filename: i.filename, size: i.size })), skippedInlineImages, mime, text, html }
const draft = await createDraft({ account, raw: toBase64Url(mime), threadId })
return { ...draft, inlineImageCount: inlineImages.length, skippedInlineImages }
}
/**
* Return the Gmail helper namespace (search, read, draft, reply, send, and URL parsing).
* @example
* import gmail from 'kody:@kentcdodds/google/gmail'
* const profile = await gmail().getGmailProfile({ account: 'personal' })
* // => { emailAddress: '...', messagesTotal: 1234, ... }
*/
export default function gmail() { return { getGmailProfile, searchMessages, getMessage, getThread, listLabels, getAttachment, createDraft, createReplyDraft, sendMessage, modifyMessage, createLabel, ensureLabel, parseGmailUrl } }