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'}`);
      }
    },
  });
}