import { getUserInfo, requestGoogle } from './core.ts' import { applyMimeHeaderUpdates, assembleComposedMime, assertComposedReplyThreading, assertNotSelfOnlyTo, decodeBase64UrlToBytes, decodeUtf8Base64Url, defaultReplyToHeader, encodeBytesToBase64Url, encodeHeaderValue, encodeUtf8Base64, escapeHtml, formatAddressHeader, linkify, normalizeOptionalToken, randomBoundary, splitMimeRaw, toBase64Url, type GmailAddressList, type MimeHeaderUpdates, } from './gmail-mime.ts' import type { JsonObject } from './types.ts' import { withSendIdempotency } from './idempotency.ts' export type { GmailAddressList, MimeHeaderUpdates, SplitMimeRaw } from './gmail-mime.ts' export { applyMimeHeaderUpdates, assembleComposedMime, bytesToBinaryString, binaryStringToBytes, decodeBase64Latin1_DO_NOT_USE_FOR_MIME, decodeBase64ToBytes, decodeBase64UrlToBytes, decodeUtf8Base64, decodeUtf8Base64Url, encodeBytesToBase64, encodeBytesToBase64Url, encodeHeaderValue, encodeUtf8Base64, formatAddressHeader, setMimeHeader, splitHeaderLines, splitMimeRaw, toBase64Url, } 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 /** * Optional stable key for one logical send. Same `(account, idempotencyKey)` * within 7 days returns the prior successful `sendMessage` result instead of * sending again. Different keys are different sends. Drafts ignore this. */ idempotencyKey?: string } export type GmailComposedMutationParams = { account: string to: GmailAddressList cc?: GmailAddressList bcc?: GmailAddressList from?: string subject: string body: string | string[] threadId?: string inReplyTo?: string references?: string /** Required when To is only this mailbox — otherwise send/draft refuse the self-send trap. */ allowSelfSend?: boolean dryRun?: boolean /** * Optional stable key for one logical send. Same `(account, idempotencyKey)` * within 7 days returns the prior successful `sendMessage` result instead of * sending again. Different keys are different sends. Drafts ignore this. */ idempotencyKey?: string } 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 { return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/profile' }) as GmailProfile } export async function searchMessages(params: GmailSearchParams): Promise { 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 { 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 { 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 { return await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/labels' }) as JsonObject } export async function getAttachment(params: GmailAttachmentParams): Promise { 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 mailboxEmail = String( ((await getGmailProfile({ account: params.account })) as { emailAddress?: string }).emailAddress ?? '', ) assertNotSelfOnlyTo({ to: params.to, mailboxEmail, allowSelfSend: params.allowSelfSend, action: 'composed Gmail mutation', }) const threading = assertComposedReplyThreading({ threadId: params.threadId, inReplyTo: params.inReplyTo, references: params.references, }) const assembled = assembleComposedMime({ from, to: params.to, cc: params.cc, bcc: params.bcc, subject: params.subject, body: params.body, inReplyTo: threading.inReplyTo, references: threading.references, }) return { assembled, raw: toBase64Url(assembled.mime), threadId: threading.threadId } } /** * Create a Gmail draft (composed fields or raw RFC 2822). * Composed drafts use UTF-8-safe Base64 (`charset="UTF-8"`). For BCC/CC/To on an * *existing* draft, prefer {@link updateDraftHeaders} so body bytes are never rewritten. */ export async function createDraft(params: GmailMessageMutationParams): Promise { if (isRawMessageParams(params)) { const { raw, dryRun = false } = params if (!raw) throw new Error('createDraft requires raw base64url RFC 2822 message content.') const threadId = normalizeOptionalToken(params.threadId, 'threadId') if (dryRun) return { dryRun: true, account: params.account, wouldCreateDraft: true, threadId: threadId ?? null } const message: Record = { 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, threadId } = await resolveComposedRaw(params) if (params.dryRun) { return { dryRun: true, account: params.account, wouldCreateDraft: true, threadId: threadId ?? null, ...assembled } } const message: Record = { raw } if (threadId) message.threadId = threadId return await requestGoogle({ ...params, api: 'gmail', method: 'POST', path: '/gmail/v1/users/me/drafts', body: { message } }) as JsonObject } export type GmailGetDraftParams = { account: string; id: string; format?: string } export type GmailUpdateDraftHeadersParams = { account: string /** Gmail draft id (not the message id). */ id: string to?: GmailAddressList | null cc?: GmailAddressList | null bcc?: GmailAddressList | null from?: string | null subject?: string | null dryRun?: boolean } export type GmailUpdateDraftRawParams = { account: string id: string raw: string threadId?: string dryRun?: boolean } export type GmailUpdateDraftComposedParams = { account: string id: string to: GmailAddressList cc?: GmailAddressList bcc?: GmailAddressList from?: string subject: string body: string | string[] threadId?: string inReplyTo?: string references?: string allowSelfSend?: boolean dryRun?: boolean } export type GmailUpdateDraftParams = | GmailUpdateDraftHeadersParams | GmailUpdateDraftRawParams | GmailUpdateDraftComposedParams /** Fetch one Gmail draft. Pass `format: 'raw'` to get `message.raw` for header-only edits. */ export async function getDraft(params: GmailGetDraftParams): Promise { const { id, format = 'full' } = params if (!id) throw new Error('getDraft requires id (Gmail draft id).') return (await requestGoogle({ ...params, api: 'gmail', path: '/gmail/v1/users/me/drafts/' + encodeURIComponent(id), query: { format }, })) as JsonObject } function isUpdateDraftRawParams(params: GmailUpdateDraftParams): params is GmailUpdateDraftRawParams { return typeof (params as GmailUpdateDraftRawParams).raw === 'string' } function isUpdateDraftComposedParams(params: GmailUpdateDraftParams): params is GmailUpdateDraftComposedParams { return typeof (params as GmailUpdateDraftComposedParams).body !== 'undefined' && !isUpdateDraftRawParams(params) } /** * Add/replace/remove draft headers (Bcc, Cc, To, From, Subject) without decoding * or rewriting body bytes. Prefer this over `users.drafts.update` with a rebuilt * body when you only need to BCC/CC/To an existing draft — bare `atob` rebuilds * corrupt UTF-8 (Mimestream en-dashes → mojibake). * * Pass `null` for a header key to remove it; omit a key to leave it unchanged. * * @example * import { updateDraftHeaders } from 'kody:@kentcdodds/google/gmail' * await updateDraftHeaders({ account: 'business', id: draftId, bcc: 'me+log@kentcdodds.com' }) */ export async function updateDraftHeaders(params: GmailUpdateDraftHeadersParams): Promise { const { account, id, dryRun = false } = params if (!id) throw new Error('updateDraftHeaders requires id (Gmail draft id).') const updates: MimeHeaderUpdates = {} if ('to' in params) updates.to = params.to if ('cc' in params) updates.cc = params.cc if ('bcc' in params) updates.bcc = params.bcc if ('from' in params) updates.from = params.from if ('subject' in params) updates.subject = params.subject if (Object.keys(updates).length === 0) { throw new Error('updateDraftHeaders requires at least one of: to, cc, bcc, from, subject.') } const draft = (await getDraft({ account, id, format: 'raw' })) as { id?: string message?: { id?: string; threadId?: string; raw?: string } } const existingRaw = draft.message?.raw if (!existingRaw) throw new Error('updateDraftHeaders: draft did not include message.raw (format=raw).') const originalBytes = decodeBase64UrlToBytes(existingRaw) const { bodyBytes: originalBody } = splitMimeRaw(originalBytes) const patchedBytes = applyMimeHeaderUpdates(originalBytes, updates) const { bodyBytes: patchedBody } = splitMimeRaw(patchedBytes) const bodyBytesUnchanged = originalBody.length === patchedBody.length && originalBody.every((b, i) => b === patchedBody[i]) if (!bodyBytesUnchanged) { throw new Error('updateDraftHeaders refused to apply: body bytes would change (bug).') } const raw = encodeBytesToBase64Url(patchedBytes) const threadId = typeof draft.message?.threadId === 'string' ? draft.message.threadId : undefined if (dryRun) { return { dryRun: true, account, id, wouldUpdateDraftHeaders: true, threadId: threadId ?? null, bodyBytesUnchanged: true, originalBodyByteLength: originalBody.length, patchedRawLength: raw.length, headerUpdates: updates, } } const message: Record = { raw } if (threadId) message.threadId = threadId if (draft.message?.id) message.id = draft.message.id return (await requestGoogle({ account, api: 'gmail', method: 'PUT', path: '/gmail/v1/users/me/drafts/' + encodeURIComponent(id), body: { id, message }, })) as JsonObject } /** * Update an existing draft. Prefer {@link updateDraftHeaders} when only headers * change. Composed updates rebuild MIME with UTF-8-safe helpers (`charset=UTF-8` * + `encodeUtf8Base64`); never rebuild from bare `atob` strings. */ export async function updateDraft(params: GmailUpdateDraftParams): Promise { if (isUpdateDraftRawParams(params)) { const { account, id, raw, dryRun = false } = params if (!id) throw new Error('updateDraft requires id (Gmail draft id).') if (!raw) throw new Error('updateDraft requires raw base64url RFC 2822 message content.') const threadId = normalizeOptionalToken(params.threadId, 'threadId') if (dryRun) return { dryRun: true, account, id, wouldUpdateDraft: true, threadId: threadId ?? null } const existing = (await getDraft({ account, id, format: 'minimal' })) as { message?: { id?: string; threadId?: string } } const message: Record = { raw } const tid = threadId ?? (typeof existing.message?.threadId === 'string' ? existing.message.threadId : undefined) if (tid) message.threadId = tid if (existing.message?.id) message.id = existing.message.id return (await requestGoogle({ account, api: 'gmail', method: 'PUT', path: '/gmail/v1/users/me/drafts/' + encodeURIComponent(id), body: { id, message }, })) as JsonObject } if (isUpdateDraftComposedParams(params)) { const { account, id } = params if (!id) throw new Error('updateDraft requires id (Gmail draft id).') const { assembled, raw, threadId } = await resolveComposedRaw(params) if (params.dryRun) { return { dryRun: true, account, id, wouldUpdateDraft: true, threadId: threadId ?? null, ...assembled } } const existing = (await getDraft({ account, id, format: 'minimal' })) as { message?: { id?: string; threadId?: string } } const message: Record = { raw } const tid = threadId ?? (typeof existing.message?.threadId === 'string' ? existing.message.threadId : undefined) if (tid) message.threadId = tid if (existing.message?.id) message.id = existing.message.id return (await requestGoogle({ account, api: 'gmail', method: 'PUT', path: '/gmail/v1/users/me/drafts/' + encodeURIComponent(id), body: { id, message }, })) as JsonObject } return await updateDraftHeaders(params) } /** * Send a Gmail message (composed fields or raw RFC 2822). * * Pass optional `idempotencyKey` so retries / parallel workers reuse the prior * successful send for the same `(account, key)` within 7 days. */ export async function sendMessage(params: GmailMessageMutationParams): Promise { if (isRawMessageParams(params)) { const { raw, dryRun = false } = params if (!raw) throw new Error('sendMessage requires raw base64url RFC 2822 message content.') const threadId = normalizeOptionalToken(params.threadId, 'threadId') if (dryRun) return { dryRun: true, account: params.account, wouldSendMessage: true, threadId: threadId ?? null } const body: Record = { raw } if (threadId) body.threadId = threadId return await withSendIdempotency({ action: 'sendMessage', account: params.account, idempotencyKey: params.idempotencyKey, run: async () => (await requestGoogle({ ...params, api: 'gmail', method: 'POST', path: '/gmail/v1/users/me/messages/send', body, })) as JsonObject, }) } const { assembled, raw, threadId } = await resolveComposedRaw(params) if (params.dryRun) { return { dryRun: true, account: params.account, wouldSendMessage: true, threadId: threadId ?? null, ...assembled } } const body: Record = { raw } if (threadId) body.threadId = threadId return await withSendIdempotency({ action: 'sendMessage', account: params.account, idempotencyKey: params.idempotencyKey, run: async () => (await requestGoogle({ ...params, api: 'gmail', method: 'POST', path: '/gmail/v1/users/me/messages/send', body, })) as JsonObject, }) } export async function modifyMessage(params: GmailModifyMessageParams): Promise { 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 { 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 { 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; /** Required when To would be only this mailbox. */ allowSelfSend?: boolean; 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 { return decodeUtf8Base64Url(data) } 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 }; 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(//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 { 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 = /]*>([\s\S]*)<\/body>/i.exec(html); return (match ? match[1] : html).trim() } function collectInlineImageParts(payload: unknown, out: Array): void { if (!payload || typeof payload !== 'object') return; const part = payload as JsonObject & { mimeType?: string; filename?: string; body?: { size?: number; attachmentId?: string }; parts?: Array; 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 { 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. * * **Self-send trap:** when the message being replied to was *sent by this * mailbox*, default `To` is the original recipient(s), not `From`. Do not set * `to` to the mailbox From address. Composed `createDraft` / `sendMessage` also * refuse To-only-self unless `allowSelfSend: true`. * * Prefer this over bare `sendMessage` for replies: `sendMessage` will not infer * subject / threadId / In-Reply-To, and composed replies must pass all three * (plus a real subject) or they throw / start a new thread. * * @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 `Reply-To` else `From` for inbound mail; for own outbound mail, defaults to the original `To` (minus self). * @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.allowSelfSend - Required if resolved To is only this mailbox. * @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 { 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 mailboxEmail = String( ((await getGmailProfile({ account })) as { emailAddress?: string }).emailAddress ?? '', ) const inferredTo = defaultReplyToHeader({ mailboxEmail, from: originalFrom, to: readHeader(original, 'To'), replyTo: readHeader(original, 'Reply-To'), }) const to = formatAddressHeader(params.to ?? inferredTo) if (!to) { throw new Error( 'createReplyDraft could not resolve a To address from the original message. If this was your own outbound mail with no other recipients, pass `to` explicitly (and allowSelfSend if To is only yourself).', ) } assertNotSelfOnlyTo({ to, mailboxEmail, allowSelfSend: params.allowSelfSend, action: 'createReplyDraft', }) 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, '
') const replyHtml = paragraphs.map(paragraph => '
' + linkify(escapeHtml(paragraph)).replace(/\n/g, '
') + '
').join('

') const html = quote ? replyHtml + '

' + escapeHtml(attribution) + '
' + quotedHtmlSource + '
' : replyHtml // Re-attach only the inline images the quoted markup actually references. const maxInlineBytes = params.maxInlineImageBytes ?? DEFAULT_MAX_INLINE_IMAGE_BYTES const candidates: Array = [] if (quote) collectInlineImageParts(original.payload, candidates) const referenced = candidates.filter(part => html.includes('cid:' + part.cid)) const attachable: Array = [] 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 = [] 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, header-only draft update, reply, send, MIME helpers, 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, getDraft, listLabels, getAttachment, createDraft, updateDraft, updateDraftHeaders, createReplyDraft, sendMessage, modifyMessage, createLabel, ensureLabel, parseGmailUrl, decodeUtf8Base64, decodeUtf8Base64Url, encodeUtf8Base64, decodeBase64ToBytes, encodeBytesToBase64Url, applyMimeHeaderUpdates } }