opengraph stuff

This commit is contained in:
2026-02-24 21:47:36 +00:00
parent 05f0d0e8f7
commit e36908cb0b
61 changed files with 5870 additions and 178 deletions
+58
View File
@@ -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.
+446
View File
@@ -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 };
}
}