import { generateRandomString } from '@sim/utils/random' import { convert } from 'html-to-text' import type { GmailAttachment, GmailMessage, GmailReadParams, GmailToolResponse, } from '@/tools/gmail/types' export const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me' /** * Fetch original message headers for threading * @param messageId Gmail message ID to fetch headers from * @param accessToken Gmail access token * @returns Object containing threading headers (messageId, references, subject) */ export async function fetchThreadingHeaders( messageId: string, accessToken: string ): Promise<{ messageId?: string references?: string subject?: string }> { try { const messageResponse = await fetch( `${GMAIL_API_BASE}/messages/${messageId}?format=metadata&metadataHeaders=Message-ID&metadataHeaders=References&metadataHeaders=Subject`, { headers: { Authorization: `Bearer ${accessToken}`, }, } ) if (messageResponse.ok) { const messageData = await messageResponse.json() const headers = messageData.payload?.headers || [] return { messageId: headers.find((h: any) => h.name.toLowerCase() === 'message-id')?.value, references: headers.find((h: any) => h.name.toLowerCase() === 'references')?.value, subject: headers.find((h: any) => h.name.toLowerCase() === 'subject')?.value, } } } catch (error) { // Continue without threading headers rather than failing } return {} } // Helper function to process a Gmail message export async function processMessage( message: GmailMessage, params?: GmailReadParams ): Promise { // Check if message and payload exist if (!message || !message.payload) { return { success: true, output: { content: 'Unable to process email: Invalid message format', metadata: { id: message?.id || '', threadId: message?.threadId || '', labelIds: message?.labelIds || [], }, }, } } const headers = message.payload.headers || [] const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || '' const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || '' const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || '' const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || '' // Extract the message body const body = extractMessageBody(message.payload) // Check for attachments const attachmentInfo = extractAttachmentInfo(message.payload) const hasAttachments = attachmentInfo.length > 0 // Download attachments if requested let attachments: GmailAttachment[] | undefined if (params?.includeAttachments && hasAttachments && params.accessToken) { try { attachments = await downloadAttachments(message.id, attachmentInfo, params.accessToken) } catch (error) { // Continue without attachments rather than failing the entire request } } const result: GmailToolResponse = { success: true, output: { content: body || 'No content found in email', metadata: { id: message.id || '', threadId: message.threadId || '', labelIds: message.labelIds || [], from, to, subject, date, hasAttachments, attachmentCount: attachmentInfo.length, }, // Always include attachments array (empty if none downloaded) attachments: attachments || [], }, } return result } // Helper function to process a message for summary (without full content) export function processMessageForSummary(message: GmailMessage): any { if (!message || !message.payload) { return { id: message?.id || '', threadId: message?.threadId || '', subject: 'Unknown Subject', from: 'Unknown Sender', to: '', date: '', snippet: message?.snippet || '', } } const headers = message.payload.headers || [] const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || 'No Subject' const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || 'Unknown Sender' const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || '' const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || '' return { id: message.id, threadId: message.threadId, subject, from, to, date, snippet: message.snippet || '', } } // Helper function to recursively extract message body from MIME parts export function extractMessageBody(payload: any): string { // If the payload has a body with data, decode it if (payload.body?.data) { return Buffer.from(payload.body.data, 'base64').toString() } // If there are no parts, return empty string if (!payload.parts || !Array.isArray(payload.parts) || payload.parts.length === 0) { return '' } // First try to find a text/plain part const textPart = payload.parts.find((part: any) => part.mimeType === 'text/plain') if (textPart?.body?.data) { return Buffer.from(textPart.body.data, 'base64').toString() } // If no text/plain, try to find text/html const htmlPart = payload.parts.find((part: any) => part.mimeType === 'text/html') if (htmlPart?.body?.data) { return Buffer.from(htmlPart.body.data, 'base64').toString() } // If we have multipart/alternative or other complex types, recursively check parts for (const part of payload.parts) { if (part.parts) { const nestedBody = extractMessageBody(part) if (nestedBody) { return nestedBody } } } // If we couldn't find any text content, return empty string return '' } // Helper function to extract attachment information from message payload export function extractAttachmentInfo( payload: any ): Array<{ attachmentId: string; filename: string; mimeType: string; size: number }> { const attachments: Array<{ attachmentId: string filename: string mimeType: string size: number }> = [] function processPayloadPart(part: any) { // Check if this part has an attachment if (part.body?.attachmentId && part.filename) { attachments.push({ attachmentId: part.body.attachmentId, filename: part.filename, mimeType: part.mimeType || 'application/octet-stream', size: part.body.size || 0, }) } // Recursively process nested parts if (part.parts && Array.isArray(part.parts)) { part.parts.forEach(processPayloadPart) } } // Process the main payload processPayloadPart(payload) return attachments } // Helper function to download attachments from Gmail API export async function downloadAttachments( messageId: string, attachmentInfo: Array<{ attachmentId: string; filename: string; mimeType: string; size: number }>, accessToken: string ): Promise { const downloadedAttachments: GmailAttachment[] = [] for (const attachment of attachmentInfo) { try { // Download attachment from Gmail API const attachmentResponse = await fetch( `${GMAIL_API_BASE}/messages/${messageId}/attachments/${attachment.attachmentId}`, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, } ) if (!attachmentResponse.ok) { await attachmentResponse.body?.cancel().catch(() => {}) continue } const attachmentData = (await attachmentResponse.json()) as { data: string; size: number } // Decode base64url data to buffer // Gmail API returns data in base64url format (URL-safe base64) const base64Data = attachmentData.data.replace(/-/g, '+').replace(/_/g, '/') const buffer = Buffer.from(base64Data, 'base64') downloadedAttachments.push({ name: attachment.filename, data: buffer.toString('base64'), mimeType: attachment.mimeType, size: attachment.size, }) } catch (error) { // Continue with other attachments } } return downloadedAttachments } // Helper function to create a summary of multiple messages export function createMessagesSummary(messages: any[]): string { if (messages.length === 0) { return 'No messages found.' } let summary = `Found ${messages.length} messages:\n\n` messages.forEach((msg, index) => { summary += `${index + 1}. Subject: ${msg.subject}\n` summary += ` From: ${msg.from}\n` summary += ` To: ${msg.to}\n` summary += ` Date: ${msg.date}\n` summary += ` ID: ${msg.id}\n` summary += ` Thread ID: ${msg.threadId}\n` summary += ` Preview: ${msg.snippet}\n\n` }) summary += `To read full content of a specific message, use the gmail_read tool with messageId: ${messages.map((m) => m.id).join(', ')}` return summary } /** * Generate a unique MIME boundary string */ function generateBoundary(): string { return `----=_Part_${Date.now()}_${generateRandomString(13)}` } /** * Encode a header value using RFC 2047 Base64 encoding if it contains non-ASCII characters. * This matches Google's own Gmail API sample: `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=` * @see https://github.com/googleapis/google-api-nodejs-client/blob/main/samples/gmail/send.js */ export function encodeRfc2047(value: string): string { // eslint-disable-next-line no-control-regex if (/^[\x00-\x7F]*$/.test(value)) { return value } return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=` } /** * Strips CR/LF so a value can't introduce extra lines when placed into a MIME header. */ export function sanitizeHeaderValue(value: string): string { return value.replace(/[\r\n]+/g, ' ') } /** * Encode string or buffer to base64url format (URL-safe base64) * Gmail API requires base64url encoding for the raw message field */ export function base64UrlEncode(data: string | Buffer): string { const base64 = Buffer.from(data).toString('base64') return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } /** * Escape HTML special characters so user-supplied text renders safely inside an HTML body. */ export function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** * Convert a plain-text body to an HTML body that flows naturally in Gmail. * Newlines become `
` rather than per-paragraph `

` tags or a CSS * `white-space` rule — `

` margins are what Gmail's "Remove formatting" * button strips, and `white-space` has inconsistent support across email * clients (including Gmail for non-Google accounts). Plain `
` line * breaks are the standard, client-agnostic way to preserve plain-text * formatting in an HTML alternative part. * This avoids the narrow hard-wrapped rendering Gmail uses for `text/plain`. */ export function plainTextToHtml(body: string): string { const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n') const escaped = escapeHtml(normalized).replace(/\n/g, '
') return `${escaped}` } /** * Best-effort conversion of an HTML body to a plain-text fallback. Used so we * always include a plain-text part alongside HTML for clients that don't render * HTML. Delegates to the `html-to-text` library for robust tag stripping and * entity decoding (also used elsewhere in the repo for the same purpose). */ export function htmlToPlainText(html: string): string { return convert(html, { wordwrap: false, selectors: [ { selector: 'a', options: { hideLinkHrefIfSameAsText: true, noAnchorUrl: true } }, { selector: 'img', format: 'skip' }, { selector: 'script', format: 'skip' }, { selector: 'style', format: 'skip' }, ], }) } /** * Produce the plain-text and HTML representations of a body so we can emit a * `multipart/alternative` section. Gmail renders the HTML version full-width * (matching how a manually-composed email looks); the plain-text part is the * fallback for clients that don't render HTML. */ export function buildBodyAlternatives( body: string, contentType: 'text' | 'html' | undefined ): { plain: string; html: string } { if (contentType === 'html') { return { plain: htmlToPlainText(body) || body, html: body } } return { plain: body, html: plainTextToHtml(body) } } /** * Encode a text body as base64 with RFC 2045 line wrapping (max 76 chars). * Using base64 lets us safely transport arbitrary UTF-8 (emoji, accented * characters, etc.) — `7bit` is only valid for strict 7-bit ASCII. */ function encodeBodyBase64(content: string): string[] { const base64 = Buffer.from(content, 'utf-8').toString('base64') return base64.match(/.{1,76}/g) || [''] } /** * Render the inner part of a `multipart/alternative` section (text/plain * followed by text/html, per RFC 2046 — clients pick the last format they * understand). */ function renderAlternativeParts(plain: string, html: string, boundary: string): string[] { return [ `--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: base64', '', ...encodeBodyBase64(plain), '', `--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', 'Content-Transfer-Encoding: base64', '', ...encodeBodyBase64(html), '', `--${boundary}--`, ] } /** * Build a `multipart/alternative` email (without attachments). Always emits * both text/plain and text/html parts so Gmail renders messages full-width * like a hand-composed email. * @returns Base64url encoded raw message */ export function buildSimpleEmailMessage(params: { to: string cc?: string | null bcc?: string | null subject?: string | null body: string contentType?: 'text' | 'html' inReplyTo?: string references?: string }): string { const { to, cc, bcc, subject, body, contentType, inReplyTo, references } = params const boundary = generateBoundary() const { plain, html } = buildBodyAlternatives(body, contentType) const emailHeaders = ['MIME-Version: 1.0', `To: ${sanitizeHeaderValue(to)}`] if (cc) { emailHeaders.push(`Cc: ${sanitizeHeaderValue(cc)}`) } if (bcc) { emailHeaders.push(`Bcc: ${sanitizeHeaderValue(bcc)}`) } emailHeaders.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`) if (inReplyTo) { const sanitizedInReplyTo = sanitizeHeaderValue(inReplyTo) emailHeaders.push(`In-Reply-To: ${sanitizedInReplyTo}`) const referencesChain = references ? `${sanitizeHeaderValue(references)} ${sanitizedInReplyTo}` : sanitizedInReplyTo emailHeaders.push(`References: ${referencesChain}`) } emailHeaders.push(`Content-Type: multipart/alternative; boundary="${boundary}"`) emailHeaders.push('') emailHeaders.push(...renderAlternativeParts(plain, html, boundary)) const email = emailHeaders.join('\n') return Buffer.from(email).toString('base64url') } /** * Build a MIME multipart message with optional attachments * @param params Message parameters including recipients, subject, body, and attachments * @returns Complete MIME message string ready to be base64url encoded */ export interface BuildMimeMessageParams { to: string cc?: string bcc?: string subject?: string body: string contentType?: 'text' | 'html' inReplyTo?: string references?: string attachments?: Array<{ filename: string mimeType: string content: Buffer }> } export function buildMimeMessage(params: BuildMimeMessageParams): string { const { to, cc, bcc, subject, body, contentType, inReplyTo, references, attachments } = params const messageParts: string[] = [] const { plain, html } = buildBodyAlternatives(body, contentType) messageParts.push(`To: ${sanitizeHeaderValue(to)}`) if (cc) { messageParts.push(`Cc: ${sanitizeHeaderValue(cc)}`) } if (bcc) { messageParts.push(`Bcc: ${sanitizeHeaderValue(bcc)}`) } messageParts.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`) const sanitizedInReplyTo = inReplyTo ? sanitizeHeaderValue(inReplyTo) : undefined const sanitizedReferences = references ? sanitizeHeaderValue(references) : undefined if (sanitizedInReplyTo) { messageParts.push(`In-Reply-To: ${sanitizedInReplyTo}`) } if (sanitizedReferences) { const referencesChain = sanitizedInReplyTo ? `${sanitizedReferences} ${sanitizedInReplyTo}` : sanitizedReferences messageParts.push(`References: ${referencesChain}`) } else if (sanitizedInReplyTo) { messageParts.push(`References: ${sanitizedInReplyTo}`) } messageParts.push('MIME-Version: 1.0') if (attachments && attachments.length > 0) { const mixedBoundary = generateBoundary() const altBoundary = generateBoundary() messageParts.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`) messageParts.push('') messageParts.push(`--${mixedBoundary}`) messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`) messageParts.push('') messageParts.push(...renderAlternativeParts(plain, html, altBoundary)) messageParts.push('') for (const attachment of attachments) { messageParts.push(`--${mixedBoundary}`) messageParts.push(`Content-Type: ${sanitizeHeaderValue(attachment.mimeType)}`) messageParts.push( `Content-Disposition: attachment; filename="${sanitizeHeaderValue(attachment.filename)}"` ) messageParts.push('Content-Transfer-Encoding: base64') messageParts.push('') const base64Content = attachment.content.toString('base64') const lines = base64Content.match(/.{1,76}/g) || [] messageParts.push(...lines) messageParts.push('') } messageParts.push(`--${mixedBoundary}--`) } else { const altBoundary = generateBoundary() messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`) messageParts.push('') messageParts.push(...renderAlternativeParts(plain, html, altBoundary)) } return messageParts.join('\n') }