Spaces:
Sleeping
Sleeping
File size: 7,138 Bytes
7dc28be | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | import type { FastMCP } from 'fastmcp';
import { UserError } from 'fastmcp';
import { z } from 'zod';
import { gmail_v1 } from 'googleapis';
import { getGmailClient } from '../../clients.js';
import { findHeaderValue, extractMessageBody, extractDomain } from './helpers.js';
// "reschedul" is intentionally truncated to catch both "reschedule" and "rescheduling".
const MEETING_KEYWORD_PATTERN =
/\b(meeting|call|invite|invitation|calendar|schedule|reschedul|zoom|google meet|teams)\b/i;
const QUESTION_PATTERN = /\?/;
const ACTION_PATTERN =
/\b(please|could you|can you|let me know|need|review|approve|sign|deadline|by (mon|tue|wed|thu|fri|sat|sun|today|tomorrow|next week|eod|cob))\b/i;
function extractTextBody(payload?: gmail_v1.Schema$MessagePart): string {
const { text, html } = extractMessageBody(payload);
if (text) return text;
// No text/plain part — strip HTML tags as a best-effort fallback so the
// heuristic regexes still have something to match against.
return html.replace(/<style[\s\S]*?<\/style>|<script[\s\S]*?<\/script>|<[^>]+>/g, ' ');
}
function truncate(text: string, max: number): string {
const collapsed = text.replace(/\s+/g, ' ').trim();
if (collapsed.length <= max) return collapsed;
return collapsed.slice(0, max) + '…';
}
export function register(server: FastMCP) {
server.addTool({
name: 'triageInbox',
description:
"Composite tool: fetches the user's most recent unread Gmail messages with full content and heuristic categorization in a single call. Returns headers, body excerpts, labels, plus per-message signals (newsletter, meeting reference, contains question, action requested) AND aggregate stats (total unread, top senders, breakdown by category). Designed for AI inbox triage workflows — use the returned data to decide which messages need a reply, can be archived, or warrant a draft response. Pairs naturally with createDraft, modifyMessageLabels, and trashMessage.",
parameters: z.strictObject({
maxResults: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(20)
.describe('How many unread messages to triage in one pass (1-50). Defaults to 20.'),
additionalQuery: z
.string()
.optional()
.describe(
'Optional Gmail query appended to "is:unread", e.g. "newer_than:2d" or "-from:notifications@".'
),
bodyExcerptLength: z
.number()
.int()
.min(0)
.max(2000)
.optional()
.default(400)
.describe('Max characters of body text to include per message (0 to skip bodies).'),
}),
execute: async (args, { log }) => {
const gmail = await getGmailClient();
const query = ['is:unread', args.additionalQuery].filter(Boolean).join(' ');
log.info(`Triaging inbox (max=${args.maxResults}, q="${query}")`);
try {
const listResponse = await gmail.users.messages.list({
userId: 'me',
maxResults: args.maxResults,
q: query,
});
const totalUnread = listResponse.data.resultSizeEstimate ?? 0;
const messageRefs = listResponse.data.messages ?? [];
if (messageRefs.length === 0) {
return JSON.stringify(
{
summary: {
totalUnread,
fetched: 0,
topSenders: [],
newsletterCount: 0,
meetingReferenceCount: 0,
questionCount: 0,
actionRequestedCount: 0,
},
messages: [],
},
null,
2
);
}
// allSettled so a single failed message fetch doesn't kill the whole
// triage — partial results are still actionable for the agent.
const settled = await Promise.allSettled(
messageRefs.map((ref) =>
gmail.users.messages.get({
userId: 'me',
id: ref.id!,
format: 'full',
})
)
);
const failedFetches = settled.filter((r) => r.status === 'rejected').length;
const detailed: gmail_v1.Schema$Message[] = [];
for (const r of settled) {
if (r.status === 'fulfilled') detailed.push(r.value.data);
}
const messages = detailed.map((msg) => {
const headers = msg.payload?.headers;
const from = findHeaderValue(headers, 'From');
const subject = findHeaderValue(headers, 'Subject') ?? '(no subject)';
// Standard newsletter headers per RFC 2369 (List-Unsubscribe) and RFC 2919 (List-Id).
const hasUnsubscribe =
findHeaderValue(headers, 'List-Unsubscribe') !== null ||
findHeaderValue(headers, 'List-Id') !== null;
const text = args.bodyExcerptLength > 0 ? extractTextBody(msg.payload) : '';
const bodyExcerpt =
args.bodyExcerptLength > 0 ? truncate(text, args.bodyExcerptLength) : '';
const searchSurface = `${subject} ${text}`;
return {
id: msg.id,
threadId: msg.threadId,
from,
domain: extractDomain(from),
to: findHeaderValue(headers, 'To'),
subject,
date: findHeaderValue(headers, 'Date'),
snippet: msg.snippet ?? '',
bodyExcerpt,
labels: msg.labelIds ?? [],
isNewsletter: hasUnsubscribe,
containsMeetingReference: MEETING_KEYWORD_PATTERN.test(searchSurface),
containsQuestion: QUESTION_PATTERN.test(searchSurface),
actionRequested: ACTION_PATTERN.test(searchSurface),
};
});
const senderCounts = new Map<string, number>();
for (const m of messages) {
if (!m.from) continue;
senderCounts.set(m.from, (senderCounts.get(m.from) ?? 0) + 1);
}
const topSenders = [...senderCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([from, count]) => ({ from, count }));
const summary = {
totalUnread,
fetched: messages.length,
failedFetches,
topSenders,
newsletterCount: messages.filter((m) => m.isNewsletter).length,
meetingReferenceCount: messages.filter((m) => m.containsMeetingReference).length,
questionCount: messages.filter((m) => m.containsQuestion).length,
actionRequestedCount: messages.filter((m) => m.actionRequested).length,
};
return JSON.stringify({ summary, messages }, null, 2);
} catch (error: any) {
log.error(`Error triaging inbox: ${error.message || error}`);
if (error.code === 401)
throw new UserError(
'Gmail authorization failed. Re-authorize to grant the gmail.modify scope.'
);
if (error.code === 403)
throw new UserError('Permission denied. Confirm the gmail.modify scope was granted.');
throw new UserError(`Failed to triage inbox: ${error.message || 'Unknown error'}`);
}
},
});
}
|