opengraph stuff
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: gmail
|
||||
label: Gmail
|
||||
description: Read Gmail messages, threads, and labels using the connected Google account. Use this tool to check emails, search for specific messages, read email content, list labels, or get mailbox profile info. Requires the user to have connected their Google account in Settings → Integrations.
|
||||
language: typescript
|
||||
inputs:
|
||||
action:
|
||||
type: string
|
||||
description: "Action to perform: list_messages, get_message, list_labels, get_thread, get_profile, sync_inbox"
|
||||
query:
|
||||
type: string
|
||||
description: "Gmail search query for list_messages (e.g. 'is:unread', 'from:user@example.com', 'subject:invoice after:2024/01/01')"
|
||||
optional: true
|
||||
message_id:
|
||||
type: string
|
||||
description: Message ID for get_message
|
||||
optional: true
|
||||
thread_id:
|
||||
type: string
|
||||
description: Thread ID for get_thread
|
||||
optional: true
|
||||
max_results:
|
||||
type: string
|
||||
description: Maximum number of results for list actions (default 10, max 50)
|
||||
optional: true
|
||||
output_dir:
|
||||
type: string
|
||||
description: Directory path to save email files (for sync_inbox action)
|
||||
optional: true
|
||||
---
|
||||
|
||||
# Gmail Tool
|
||||
|
||||
Read-only access to the user's Gmail account via the Gmail REST API.
|
||||
|
||||
## Available Actions
|
||||
|
||||
- **list_messages**: List or search messages. Use `query` for Gmail search syntax.
|
||||
- **get_message**: Get full message content by `message_id`.
|
||||
- **list_labels**: List all Gmail labels with message counts.
|
||||
- **get_thread**: Get all messages in a thread by `thread_id`.
|
||||
- **get_profile**: Get the user's Gmail profile info.
|
||||
- **sync_inbox**: Bulk-download emails to disk. Requires `output_dir`. Use `query` to filter (e.g. `after:2026/02/01 before:2026/03/01`). Handles pagination internally, saves each email as a markdown file, skips already-saved messages. Returns only a summary count — does NOT flood context with email bodies.
|
||||
|
||||
## Query Syntax
|
||||
|
||||
Gmail search operators for the `query` parameter:
|
||||
- `is:unread`, `is:read`, `is:starred`
|
||||
- `from:email@example.com`, `to:email@example.com`
|
||||
- `subject:"search term"`, `has:attachment`
|
||||
- `after:YYYY/MM/DD`, `before:YYYY/MM/DD`
|
||||
- `in:inbox`, `in:sent`, `in:trash`
|
||||
- `larger:1M`, `smaller:100K`
|
||||
- Combine with AND, OR, - (NOT)
|
||||
|
||||
## Scope
|
||||
|
||||
This tool has **read-only** access (gmail.readonly scope). It cannot send, modify, or delete messages.
|
||||
@@ -0,0 +1,446 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user