import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; type GoogleCredentials = { accessToken: string; refreshToken: string; expiresAt: number; clientId: string; clientSecret: string; }; type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean; }; type Params = { action: string; query?: string; message_id?: string; thread_id?: string; max_results?: string | number; output_dir?: string; }; function readCredentials(): GoogleCredentials | null { try { const configPath = process.env.OFFICER_GOOGLE_CONFIG_PATH; const tokenPath = process.env.OFFICER_GOOGLE_TOKEN_PATH; if (!configPath || !tokenPath) return null; const config = JSON.parse(readFileSync(configPath, 'utf-8')) as { clientId?: string; clientSecret?: string }; const token = JSON.parse(readFileSync(tokenPath, 'utf-8')) as { accessToken?: string; refreshToken?: string; expiresAt?: number }; if (!config.clientId || !config.clientSecret || !token.accessToken) return null; return { accessToken: token.accessToken, refreshToken: token.refreshToken ?? '', expiresAt: token.expiresAt ?? 0, clientId: config.clientId, clientSecret: config.clientSecret, }; } catch { return null; } } let cachedAccessToken: string | null = null; let cachedExpiresAt = 0; async function getValidAccessToken(creds: GoogleCredentials): Promise { if (cachedAccessToken && cachedExpiresAt > Date.now() + 5 * 60 * 1000) { return cachedAccessToken; } if (creds.expiresAt > Date.now() + 5 * 60 * 1000) { cachedAccessToken = creds.accessToken; cachedExpiresAt = creds.expiresAt; return creds.accessToken; } if (!creds.refreshToken) throw new Error('Token expired and no refresh token available'); const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: creds.clientId, client_secret: creds.clientSecret, refresh_token: creds.refreshToken, grant_type: 'refresh_token', }), }); if (!res.ok) { const error = await res.text().catch(() => ''); throw new Error(`Token refresh failed (${res.status}): ${error}`); } const data = (await res.json()) as { access_token: string; expires_in?: number }; cachedAccessToken = data.access_token; cachedExpiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; return data.access_token; } const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; async function gmailGet(token: string, path: string, params?: Record): Promise { const url = new URL(`${GMAIL_BASE}${path}`); if (params) { for (const [k, v] of Object.entries(params)) { if (v) url.searchParams.set(k, v); } } const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) { const error = await res.text().catch(() => ''); throw new Error(`Gmail API error (${res.status}): ${error}`); } return res.json(); } function decodeBase64Url(data: string): string { if (!data) return ''; const base64 = data.replace(/-/g, '+').replace(/_/g, '/'); return Buffer.from(base64, 'base64').toString('utf-8'); } type GmailHeader = { name: string; value: string }; type GmailPayload = { mimeType?: string; headers?: GmailHeader[]; body?: { data?: string; attachmentId?: string }; parts?: GmailPayload[]; }; function getHeader(headers: GmailHeader[], name: string): string { return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? ''; } function extractBody(payload: GmailPayload): { text: string; html: string } { const result = { text: '', html: '' }; if (payload.mimeType?.startsWith('multipart')) { for (const part of payload.parts ?? []) { if (part.mimeType === 'text/plain' && !result.text) { result.text = decodeBase64Url(part.body?.data ?? ''); } else if (part.mimeType === 'text/html' && !result.html) { result.html = decodeBase64Url(part.body?.data ?? ''); } else if (part.mimeType?.startsWith('multipart')) { const nested = extractBody(part); if (!result.text && nested.text) result.text = nested.text; if (!result.html && nested.html) result.html = nested.html; } } } else if (payload.mimeType === 'text/html') { result.html = decodeBase64Url(payload.body?.data ?? ''); } else { result.text = decodeBase64Url(payload.body?.data ?? ''); } return result; } function listAttachments(payload: GmailPayload): string[] { const names: string[] = []; for (const part of payload.parts ?? []) { if (part.body?.attachmentId && (part as { filename?: string }).filename) { names.push((part as { filename: string }).filename); } if (part.parts) names.push(...listAttachments(part)); } return names; } // --- Actions --- async function listMessages(token: string, query?: string, maxResults = 10): Promise { const params: Record = { maxResults: String(Math.min(maxResults, 50)) }; if (query) params.q = query; const list = (await gmailGet(token, '/messages', params)) as { messages?: Array<{ id: string; threadId: string }>; resultSizeEstimate?: number; }; const messages = list.messages ?? []; if (messages.length === 0) return 'No messages found.'; const details = await Promise.all( messages.map((m) => gmailGet(token, `/messages/${m.id}`, { format: 'metadata', metadataHeaders: 'Subject,From,Date', }), ), ); const lines = details.map((msg: any) => { const headers: GmailHeader[] = msg.payload?.headers ?? []; const from = getHeader(headers, 'From'); const subject = getHeader(headers, 'Subject') || '(no subject)'; const date = getHeader(headers, 'Date'); const labels = (msg.labelIds ?? []).join(', '); const snippet = msg.snippet ?? ''; return [`**${subject}**`, `From: ${from}`, `Date: ${date}`, `ID: ${msg.id}`, `Labels: ${labels}`, snippet, ''].join( '\n', ); }); const header = query ? `Messages matching "${query}" (${list.resultSizeEstimate ?? '?'} estimated):` : `Messages (${list.resultSizeEstimate ?? '?'} estimated):`; return [header, '', ...lines].join('\n'); } async function getMessage(token: string, messageId: string): Promise { const msg = (await gmailGet(token, `/messages/${messageId}`, { format: 'full' })) as { id: string; threadId: string; labelIds?: string[]; snippet?: string; internalDate?: string; payload: GmailPayload; }; const headers: GmailHeader[] = msg.payload.headers ?? []; const from = getHeader(headers, 'From'); const to = getHeader(headers, 'To'); const cc = getHeader(headers, 'Cc'); const subject = getHeader(headers, 'Subject') || '(no subject)'; const date = getHeader(headers, 'Date'); const { text, html } = extractBody(msg.payload); const body = text || (html ? '[HTML content — plain text not available]' : '(empty body)'); const attachments = listAttachments(msg.payload); const parts = [ `**${subject}**`, `From: ${from}`, `To: ${to}`, cc ? `Cc: ${cc}` : '', `Date: ${date}`, `ID: ${msg.id} | Thread: ${msg.threadId}`, `Labels: ${(msg.labelIds ?? []).join(', ')}`, attachments.length > 0 ? `Attachments: ${attachments.join(', ')}` : '', '', body, ]; return parts.filter(Boolean).join('\n'); } async function listLabels(token: string): Promise { const result = (await gmailGet(token, '/labels')) as { labels?: Array<{ id: string; name: string; type: string; messagesTotal?: number; messagesUnread?: number; }>; }; const labels = result.labels ?? []; if (labels.length === 0) return 'No labels found.'; const system = labels.filter((l) => l.type === 'system'); const user = labels.filter((l) => l.type === 'user'); const formatLabel = (l: (typeof labels)[0]) => { const counts = l.messagesTotal != null ? ` (${l.messagesUnread ?? 0} unread / ${l.messagesTotal} total)` : ''; return `- ${l.name}${counts} [${l.id}]`; }; const lines = []; if (system.length) lines.push('**System Labels:**', ...system.map(formatLabel), ''); if (user.length) lines.push('**User Labels:**', ...user.map(formatLabel)); return lines.join('\n'); } async function getThread(token: string, threadId: string): Promise { const thread = (await gmailGet(token, `/threads/${threadId}`, { format: 'full' })) as { id: string; messages?: Array<{ id: string; labelIds?: string[]; payload: GmailPayload; }>; }; const messages = thread.messages ?? []; if (messages.length === 0) return 'Thread has no messages.'; const parts = messages.map((msg, i) => { const headers: GmailHeader[] = msg.payload.headers ?? []; const from = getHeader(headers, 'From'); const date = getHeader(headers, 'Date'); const subject = getHeader(headers, 'Subject'); const { text } = extractBody(msg.payload); const body = text || '(no plain text body)'; return [`--- Message ${i + 1} of ${messages.length} (${msg.id}) ---`, subject ? `Subject: ${subject}` : '', `From: ${from}`, `Date: ${date}`, '', body].filter(Boolean).join('\n'); }); return [`Thread ${thread.id} (${messages.length} messages):`, '', ...parts].join('\n\n'); } async function getProfile(token: string): Promise { const profile = (await gmailGet(token, '/profile')) as { emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string; }; return [ `Email: ${profile.emailAddress}`, `Total messages: ${profile.messagesTotal}`, `Total threads: ${profile.threadsTotal}`, ].join('\n'); } // --- Sync --- function slugify(text: string, maxLen = 60): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, maxLen) .replace(/-+$/, ''); } function buildEmlFilename(id: string, internalDate: string | undefined, rawEmail: string): string { const subjectMatch = rawEmail.match(/^Subject:\s*(.+)$/mi); const subject = subjectMatch?.[1]?.trim() || 'no-subject'; const ts = parseInt(internalDate || '0'); const d = new Date(ts); const dateStr = ts > 0 ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` : 'unknown-date'; return `${dateStr}_${slugify(subject)}_${id}.eml`; } async function syncInbox(token: string, outputDir: string, query?: string): Promise { mkdirSync(outputDir, { recursive: true }); // Build set of already-saved message IDs from filenames const existingIds = new Set(); try { for (const file of readdirSync(outputDir)) { const match = file.match(/_([a-f0-9]+)\.eml$/i); if (match) existingIds.add(match[1]!); } } catch { /* dir might not exist yet */ } let saved = 0; let skipped = 0; let errors = 0; let pageToken: string | undefined; do { const params: Record = { maxResults: '100' }; if (query) params.q = query; if (pageToken) params.pageToken = pageToken; const list = (await gmailGet(token, '/messages', params)) as { messages?: Array<{ id: string }>; nextPageToken?: string; }; const messages = list.messages ?? []; if (messages.length === 0) break; // Process in batches of 5 to avoid rate limits for (let i = 0; i < messages.length; i += 5) { const batch = messages.slice(i, i + 5); await Promise.all( batch.map(async ({ id }) => { if (existingIds.has(id)) { skipped++; return; } try { const msg = (await gmailGet(token, `/messages/${id}`, { format: 'raw' })) as { id: string; internalDate?: string; raw: string; }; const rawEmail = Buffer.from(msg.raw, 'base64url').toString('utf-8'); const filename = buildEmlFilename(msg.id, msg.internalDate, rawEmail); writeFileSync(join(outputDir, filename), rawEmail); existingIds.add(id); saved++; } catch { errors++; } }), ); } pageToken = list.nextPageToken; } while (pageToken); const parts = [`Saved ${saved} emails to ${outputDir}`]; if (skipped > 0) parts.push(`${skipped} already existed`); if (errors > 0) parts.push(`${errors} failed`); return parts.join(', '); } // --- Main --- export async function execute(_toolCallId: string, params: Params): Promise { const creds = readCredentials(); if (!creds) { return { content: [{ type: 'text', text: 'Gmail is not available. The user needs to connect their Google account in Settings → Integrations.' }], isError: true, }; } const { action, query, message_id, thread_id } = params; const maxResults = params.max_results ? Number(params.max_results) : 10; try { const token = await getValidAccessToken(creds); let result: string; switch (action) { case 'list_messages': result = await listMessages(token, query, maxResults); break; case 'get_message': if (!message_id) return { content: [{ type: 'text', text: 'message_id is required for get_message' }], isError: true }; result = await getMessage(token, message_id); break; case 'list_labels': result = await listLabels(token); break; case 'get_thread': if (!thread_id) return { content: [{ type: 'text', text: 'thread_id is required for get_thread' }], isError: true }; result = await getThread(token, thread_id); break; case 'get_profile': result = await getProfile(token); break; case 'sync_inbox': if (!params.output_dir) return { content: [{ type: 'text', text: 'output_dir is required for sync_inbox' }], isError: true }; result = await syncInbox(token, params.output_dir, query); break; default: return { content: [{ type: 'text', text: `Unknown action: ${action}. Use list_messages, get_message, list_labels, get_thread, or get_profile.` }], isError: true }; } return { content: [{ type: 'text', text: result }] }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: [{ type: 'text', text: `Gmail error: ${message}` }], isError: true }; } }