447 lines
14 KiB
TypeScript
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 };
|
|
}
|
|
}
|