Files
platform/seed/tools/gmail/index.ts
T
2026-02-24 21:47:36 +00:00

447 lines
14 KiB
TypeScript

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<string> {
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<string, string>): Promise<unknown> {
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<string> {
const params: Record<string, string> = { 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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
mkdirSync(outputDir, { recursive: true });
// Build set of already-saved message IDs from filenames
const existingIds = new Set<string>();
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<string, string> = { 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<ToolResult> {
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 };
}
}