@adeu/mcp-server 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +424 -82
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/formatter.test.ts +64 -0
- package/src/index.ts +103 -19
- package/src/tools/auth.ts +9 -1
- package/src/tools/email.test.ts +258 -0
- package/src/tools/email.ts +438 -60
package/src/tools/email.ts
CHANGED
|
@@ -6,10 +6,62 @@ import { DesktopAuthManager, getCloudAuthToken } from "../desktop-auth.js";
|
|
|
6
6
|
import { BACKEND_URL } from "../shared.js";
|
|
7
7
|
import { ToolResult } from "../response-builders.js";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
+
const KNOWN_ERROR_HINTS: Record<string, string> = {
|
|
10
|
+
"Email not found.":
|
|
11
|
+
"The email ID was not found. If this was a short ID (msg_*), it may have been " +
|
|
12
|
+
"evicted from the local cache or come from a different machine — re-run " +
|
|
13
|
+
"search_and_fetch_emails with filters to get a fresh ID. If it was an " +
|
|
14
|
+
"adeu_<numeric> or raw provider ID, verify it's correct.",
|
|
15
|
+
"Adeu email reference not found.":
|
|
16
|
+
"The adeu_<id> reference doesn't resolve to any processed email for this user. " +
|
|
17
|
+
"Verify the ID, or re-run search_and_fetch_emails with filters to find the message.",
|
|
18
|
+
"Invalid adeu_ email ID format.":
|
|
19
|
+
"The adeu_<id> reference is malformed. Expected format: adeu_<integer>.",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function formatBackendError(statusCode: number, responseBody: string): string {
|
|
23
|
+
let detail = responseBody;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(responseBody);
|
|
26
|
+
if (parsed && typeof parsed === "object" && "detail" in parsed) {
|
|
27
|
+
detail = String(parsed.detail);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// responseBody isn't JSON — use it as-is
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let hint = KNOWN_ERROR_HINTS[detail];
|
|
34
|
+
if (
|
|
35
|
+
!hint &&
|
|
36
|
+
detail.startsWith("Mailbox '") &&
|
|
37
|
+
detail.endsWith("' not found.")
|
|
38
|
+
) {
|
|
39
|
+
const mailbox = detail.slice("Mailbox '".length, -"' not found.".length);
|
|
40
|
+
hint =
|
|
41
|
+
`The mailbox '${mailbox}' is not connected to your Adeu account. ` +
|
|
42
|
+
"Call list_available_mailboxes to see valid mailbox addresses, then retry " +
|
|
43
|
+
"with one of those as `mailbox_address`.";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const message = hint ?? detail;
|
|
47
|
+
return `Cloud search failed (HTTP ${statusCode}): ${message}`;
|
|
48
|
+
}
|
|
49
|
+
function isTimeoutError(err: unknown): boolean {
|
|
50
|
+
if (!err || typeof err !== "object") return false;
|
|
51
|
+
const name = (err as { name?: string }).name;
|
|
52
|
+
return name === "TimeoutError" || name === "AbortError";
|
|
53
|
+
}
|
|
9
54
|
|
|
10
55
|
const CACHE_FILE = join(homedir(), ".adeu", "mcp_id_cache.json");
|
|
11
56
|
const MAX_CACHE_SIZE = 1000;
|
|
12
57
|
|
|
58
|
+
function formatBytes(bytes: number | null | undefined): string {
|
|
59
|
+
if (bytes == null) return "unknown size";
|
|
60
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
61
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
62
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
63
|
+
}
|
|
64
|
+
|
|
13
65
|
function loadIdCache(): Record<string, string> {
|
|
14
66
|
if (existsSync(CACHE_FILE)) {
|
|
15
67
|
try {
|
|
@@ -44,34 +96,198 @@ function minifyEmailId(realId: string, cache: Record<string, string>): string {
|
|
|
44
96
|
return shortId;
|
|
45
97
|
}
|
|
46
98
|
|
|
99
|
+
class StaleShortIdError extends Error {
|
|
100
|
+
constructor(shortId: string) {
|
|
101
|
+
super(
|
|
102
|
+
`Short ID '${shortId}' is not in the local cache (it may have been evicted, or it came from a different machine/session). ` +
|
|
103
|
+
`Short IDs only persist on the machine where they were generated. ` +
|
|
104
|
+
`Re-run search_and_fetch_emails with filters (sender, subject, days_ago) to fetch fresh IDs, then use the new ID from those results.`,
|
|
105
|
+
);
|
|
106
|
+
this.name = "StaleShortIdError";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
47
110
|
function resolveEmailId(shortId: string): string {
|
|
48
111
|
if (!shortId) return shortId;
|
|
112
|
+
// adeu_<id> references are resolved server-side, pass through.
|
|
113
|
+
if (shortId.startsWith("adeu_")) return shortId;
|
|
49
114
|
const cache = loadIdCache();
|
|
50
|
-
|
|
115
|
+
const resolved = cache[shortId];
|
|
116
|
+
if (resolved) return resolved;
|
|
117
|
+
// If it looks like one of our short IDs but isn't in the cache, fail loudly
|
|
118
|
+
// instead of silently passing a meaningless string to the provider.
|
|
119
|
+
if (shortId.startsWith("msg_")) {
|
|
120
|
+
throw new StaleShortIdError(shortId);
|
|
121
|
+
}
|
|
122
|
+
// Otherwise treat it as a raw provider ID
|
|
123
|
+
return shortId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const HTML_NAMED_ENTITIES: Record<string, string> = {
|
|
127
|
+
nbsp: " ",
|
|
128
|
+
amp: "&",
|
|
129
|
+
lt: "<",
|
|
130
|
+
gt: ">",
|
|
131
|
+
quot: '"',
|
|
132
|
+
apos: "'",
|
|
133
|
+
copy: "\u00A9",
|
|
134
|
+
reg: "\u00AE",
|
|
135
|
+
trade: "\u2122",
|
|
136
|
+
hellip: "\u2026",
|
|
137
|
+
mdash: "\u2014",
|
|
138
|
+
ndash: "\u2013",
|
|
139
|
+
lsquo: "\u2018",
|
|
140
|
+
rsquo: "\u2019",
|
|
141
|
+
ldquo: "\u201C",
|
|
142
|
+
rdquo: "\u201D",
|
|
143
|
+
laquo: "\u00AB",
|
|
144
|
+
raquo: "\u00BB",
|
|
145
|
+
bull: "\u2022",
|
|
146
|
+
middot: "\u00B7",
|
|
147
|
+
deg: "\u00B0",
|
|
148
|
+
plusmn: "\u00B1",
|
|
149
|
+
times: "\u00D7",
|
|
150
|
+
divide: "\u00F7",
|
|
151
|
+
euro: "\u20AC",
|
|
152
|
+
pound: "\u00A3",
|
|
153
|
+
yen: "\u00A5",
|
|
154
|
+
cent: "\u00A2",
|
|
155
|
+
sect: "\u00A7",
|
|
156
|
+
para: "\u00B6",
|
|
157
|
+
iexcl: "\u00A1",
|
|
158
|
+
iquest: "\u00BF",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function decodeHtmlEntities(text: string): string {
|
|
162
|
+
// Numeric: Ӓ (decimal) and 💩 (hex)
|
|
163
|
+
text = text.replace(/&#(\d+);/g, (_, dec: string) => {
|
|
164
|
+
const code = parseInt(dec, 10);
|
|
165
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : _;
|
|
166
|
+
});
|
|
167
|
+
text = text.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex: string) => {
|
|
168
|
+
const code = parseInt(hex, 16);
|
|
169
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : _;
|
|
170
|
+
});
|
|
171
|
+
// Named: &, ’, etc.
|
|
172
|
+
text = text.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name: string) => {
|
|
173
|
+
const replacement = HTML_NAMED_ENTITIES[name.toLowerCase()];
|
|
174
|
+
return replacement !== undefined ? replacement : match;
|
|
175
|
+
});
|
|
176
|
+
return text;
|
|
51
177
|
}
|
|
52
178
|
|
|
53
179
|
function stripTags(html: string): string {
|
|
54
180
|
if (!html) return "";
|
|
55
|
-
|
|
181
|
+
|
|
182
|
+
// 1. Strip suppressed blocks (style/script/head/title) — loop until stable to
|
|
183
|
+
// handle nested or malformed blocks. Matches Python MLStripper's structural
|
|
184
|
+
// suppression rather than relying on a single greedy pass.
|
|
185
|
+
let text = html;
|
|
186
|
+
const suppressPattern =
|
|
187
|
+
/<(style|script|head|title)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
188
|
+
let prev: string;
|
|
189
|
+
do {
|
|
190
|
+
prev = text;
|
|
191
|
+
text = text.replace(suppressPattern, "");
|
|
192
|
+
} while (text !== prev);
|
|
193
|
+
|
|
194
|
+
// 2. Also strip orphan open tags for suppressed blocks (unclosed <style ...>)
|
|
195
|
+
// by killing from the open tag to end of document — safer than leaking CSS
|
|
196
|
+
// into the LLM output.
|
|
197
|
+
text = text.replace(/<(style|script|head|title)\b[^>]*>[\s\S]*$/gi, "");
|
|
198
|
+
|
|
199
|
+
// 3. Convert block-level closing tags to newlines so paragraph structure survives
|
|
56
200
|
text = text.replace(
|
|
57
201
|
/<\/?(p|div|br|hr|tr|li|h[1-6]|blockquote)\b[^>]*>/gi,
|
|
58
202
|
"\n",
|
|
59
203
|
);
|
|
204
|
+
|
|
205
|
+
// 4. Strip all remaining tags
|
|
60
206
|
text = text.replace(/<[^>]+>/g, "");
|
|
207
|
+
|
|
208
|
+
// 5. Decode HTML entities (named + numeric, matches Python's html.unescape).
|
|
209
|
+
text = decodeHtmlEntities(text);
|
|
210
|
+
|
|
211
|
+
// 6. Collapse triple-or-more newlines down to a paragraph break
|
|
61
212
|
return text.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
|
|
62
213
|
}
|
|
63
214
|
|
|
64
215
|
function removeNestedQuotes(text: string): string {
|
|
65
216
|
if (!text) return "";
|
|
66
|
-
|
|
217
|
+
|
|
218
|
+
// Localized "From:" header tokens from Outlook in major European locales.
|
|
219
|
+
// Order matters only for readability; matching is anchored independently.
|
|
220
|
+
const fromTokens = [
|
|
221
|
+
"From", // English
|
|
222
|
+
"Lähettäjä", // Finnish
|
|
223
|
+
"Från", // Swedish
|
|
224
|
+
"Von", // German
|
|
225
|
+
"De", // French / Spanish / Portuguese
|
|
226
|
+
"Da", // Italian
|
|
227
|
+
"Van", // Dutch
|
|
228
|
+
"Fra", // Norwegian / Danish
|
|
229
|
+
"Mittente", // Italian (alt)
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
// Localized "Sent:" tokens (paired with From: in Outlook quote blocks)
|
|
233
|
+
const sentTokens = [
|
|
234
|
+
"Sent",
|
|
235
|
+
"Lähetetty",
|
|
236
|
+
"Skickat",
|
|
237
|
+
"Gesendet",
|
|
238
|
+
"Envoyé",
|
|
239
|
+
"Enviado",
|
|
240
|
+
"Inviato",
|
|
241
|
+
"Verzonden",
|
|
242
|
+
"Sendt",
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
// Localized "On ... wrote:" / "X wrote on Y:" patterns from Gmail-style clients
|
|
246
|
+
const wrotePatterns = [
|
|
247
|
+
/On .{1,200}? wrote:/, // English
|
|
248
|
+
/Le .{1,200}? a écrit\s*:/i, // French
|
|
249
|
+
/Am .{1,200}? schrieb .{1,100}?:/i, // German
|
|
250
|
+
/El .{1,200}? escribió\s*:/i, // Spanish
|
|
251
|
+
/Il .{1,200}? ha scritto\s*:/i, // Italian
|
|
252
|
+
/Op .{1,200}? schreef .{1,100}?:/i, // Dutch
|
|
253
|
+
/Den .{1,200}? skrev .{1,100}?:/i, // Swedish/Norwegian/Danish
|
|
254
|
+
/Em .{1,200}? escreveu\s*:/i, // Portuguese
|
|
255
|
+
/Em\b.{1,200}?, .{1,200}? escreveu\s*:/i, // Portuguese (date prefix)
|
|
256
|
+
new RegExp(
|
|
257
|
+
`^(${fromTokens.join("|")})\\s*:.*?\\n(?:.*\\n){0,5}?(${sentTokens.join("|")})\\s*:`,
|
|
258
|
+
"m",
|
|
259
|
+
),
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
// Localized "Forwarded message" markers across the same locale set.
|
|
263
|
+
// Once hit, everything below is a quoted historical message and should be cut.
|
|
264
|
+
const forwardedTokens = [
|
|
265
|
+
"Forwarded message",
|
|
266
|
+
"Välitetty viesti",
|
|
267
|
+
"Vidarebefordrat meddelande",
|
|
268
|
+
"Weitergeleitete Nachricht",
|
|
269
|
+
"Message transféré",
|
|
270
|
+
"Mensaje reenviado",
|
|
271
|
+
"Messaggio inoltrato",
|
|
272
|
+
"Doorgestuurd bericht",
|
|
273
|
+
"Videresendt melding",
|
|
274
|
+
"Videresendt meddelelse",
|
|
275
|
+
"Mensagem encaminhada",
|
|
276
|
+
].join("|");
|
|
277
|
+
|
|
278
|
+
const dividerPatterns = [
|
|
67
279
|
/_{10,}/m,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
/
|
|
71
|
-
|
|
280
|
+
/-----\s*(Original Message|Alkuperäinen viesti|Ursprüngliches Nachricht|Message d'origine|Mensaje original|Messaggio originale|Oorspronkelijk bericht|Original meddelande)\s*-----/im,
|
|
281
|
+
/^(Original Message|Alkuperäinen viesti|Ursprüngliches Nachricht|Message d'origine|Mensaje original|Messaggio originale|Oorspronkelijk bericht)$/im,
|
|
282
|
+
// Gmail/Outlook-style "---------- Forwarded message ---------" with localized variants
|
|
283
|
+
new RegExp(`-+\\s*(${forwardedTokens})\\s*-+`, "i"),
|
|
284
|
+
new RegExp(`^(${forwardedTokens})$`, "im"),
|
|
72
285
|
];
|
|
286
|
+
|
|
287
|
+
const allPatterns = [...wrotePatterns, ...dividerPatterns];
|
|
288
|
+
|
|
73
289
|
let earliestCut = text.length;
|
|
74
|
-
for (const pattern of
|
|
290
|
+
for (const pattern of allPatterns) {
|
|
75
291
|
const match = pattern.exec(text);
|
|
76
292
|
if (match && match.index < earliestCut) {
|
|
77
293
|
earliestCut = match.index;
|
|
@@ -81,22 +297,31 @@ function removeNestedQuotes(text: string): string {
|
|
|
81
297
|
}
|
|
82
298
|
|
|
83
299
|
function getUniqueFilepath(saveDir: string, filename: string): string {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
while (existsSync(filepath)) {
|
|
91
|
-
filepath = join(saveDir, `${stem}_${counter}${ext}`);
|
|
92
|
-
counter++;
|
|
93
|
-
}
|
|
94
|
-
return filepath;
|
|
300
|
+
// Re-fetches of the same email overwrite the existing file rather than
|
|
301
|
+
// accumulating `_1`, `_2`, `_3` copies. The `<short_id>/` subdirectory
|
|
302
|
+
// already disambiguates across emails, so collisions inside it always
|
|
303
|
+
// mean the same logical attachment.
|
|
304
|
+
return join(saveDir, filename);
|
|
95
305
|
}
|
|
96
|
-
|
|
97
306
|
export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
98
307
|
const apiKey = await getCloudAuthToken();
|
|
99
|
-
const
|
|
308
|
+
const maxAttachmentSizeMb: number =
|
|
309
|
+
typeof args.max_attachment_size_mb === "number" &&
|
|
310
|
+
args.max_attachment_size_mb > 0
|
|
311
|
+
? args.max_attachment_size_mb
|
|
312
|
+
: 10;
|
|
313
|
+
let realEmailId: string | undefined;
|
|
314
|
+
try {
|
|
315
|
+
realEmailId = args.email_id ? resolveEmailId(args.email_id) : undefined;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
if (err instanceof StaleShortIdError) {
|
|
318
|
+
return {
|
|
319
|
+
isError: true,
|
|
320
|
+
content: [{ type: "text", text: err.message }],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
100
325
|
|
|
101
326
|
const payload = {
|
|
102
327
|
email_id: realEmailId,
|
|
@@ -117,14 +342,25 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
|
117
342
|
(k) => (payload as any)[k] === undefined && delete (payload as any)[k],
|
|
118
343
|
);
|
|
119
344
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
345
|
+
let res: Response;
|
|
346
|
+
try {
|
|
347
|
+
res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
headers: {
|
|
350
|
+
Authorization: `Bearer ${apiKey}`,
|
|
351
|
+
"Content-Type": "application/json",
|
|
352
|
+
},
|
|
353
|
+
body: JSON.stringify(payload),
|
|
354
|
+
signal: AbortSignal.timeout(45_000),
|
|
355
|
+
});
|
|
356
|
+
} catch (err) {
|
|
357
|
+
if (isTimeoutError(err)) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
"Email search timed out after 45s. The mail provider (Outlook/Gmail) may be slow. Try narrowing the search with more filters (sender, subject, days_ago), or retry shortly.",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
128
364
|
|
|
129
365
|
if (res.status === 401) {
|
|
130
366
|
DesktopAuthManager.clearApiKey();
|
|
@@ -132,7 +368,8 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
|
132
368
|
"Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
|
|
133
369
|
);
|
|
134
370
|
}
|
|
135
|
-
if (!res.ok)
|
|
371
|
+
if (!res.ok)
|
|
372
|
+
throw new Error(formatBackendError(res.status, await res.text()));
|
|
136
373
|
|
|
137
374
|
const data: any = await res.json();
|
|
138
375
|
const cache = loadIdCache();
|
|
@@ -161,9 +398,19 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
|
161
398
|
`- **ID**: \`${shortId}\`\n **Subject**: ${p.subject} ${attFlag} ${unreadFlag}\n **From**: ${p.sender_name} <${p.sender_email}>\n **Date**: ${p.received_datetime}\n **Preview**: ${p.preview_text}\n`,
|
|
162
399
|
);
|
|
163
400
|
}
|
|
401
|
+
|
|
164
402
|
saveIdCache(cache);
|
|
403
|
+
|
|
404
|
+
const limit: number = typeof args.limit === "number" ? args.limit : 10;
|
|
405
|
+
const offset: number = typeof args.offset === "number" ? args.offset : 0;
|
|
406
|
+
const pageHint =
|
|
407
|
+
previews.length >= limit
|
|
408
|
+
? `\n*(If you need to see more results, call this tool again with offset=${offset + limit})*`
|
|
409
|
+
: "";
|
|
410
|
+
|
|
165
411
|
lines.push(
|
|
166
|
-
"⚠️ **ACTION REQUIRED**: To read the full body of an email and download its attachments, call this tool again and provide the exact `email_id`."
|
|
412
|
+
"⚠️ **ACTION REQUIRED**: To read the full body of an email and download its attachments, call this tool again and provide the exact `email_id`." +
|
|
413
|
+
pageHint,
|
|
167
414
|
);
|
|
168
415
|
return {
|
|
169
416
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
@@ -177,6 +424,20 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
|
177
424
|
|
|
178
425
|
saveIdCache(cache);
|
|
179
426
|
|
|
427
|
+
// Detect auto-escalation: the caller asked for previews (no email_id) but
|
|
428
|
+
// the backend found exactly one match and returned a full email instead.
|
|
429
|
+
// Flag it so the agent doesn't get blindsided by a wall of body text when
|
|
430
|
+
// it asked for a list.
|
|
431
|
+
const autoEscalated =
|
|
432
|
+
!args.email_id &&
|
|
433
|
+
(args.sender !== undefined ||
|
|
434
|
+
args.subject !== undefined ||
|
|
435
|
+
args.has_attachments !== undefined ||
|
|
436
|
+
args.attachment_name !== undefined ||
|
|
437
|
+
args.is_unread !== undefined ||
|
|
438
|
+
args.days_ago !== undefined ||
|
|
439
|
+
args.folder !== undefined);
|
|
440
|
+
|
|
180
441
|
const baseDir =
|
|
181
442
|
args.working_directory && existsSync(args.working_directory)
|
|
182
443
|
? args.working_directory
|
|
@@ -188,40 +449,87 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
|
188
449
|
);
|
|
189
450
|
mkdirSync(saveDir, { recursive: true });
|
|
190
451
|
|
|
191
|
-
|
|
452
|
+
interface SkippedAttachment {
|
|
453
|
+
filename: string;
|
|
454
|
+
size_bytes: number | null;
|
|
455
|
+
reason: string;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function processAttachments(
|
|
459
|
+
msg: any,
|
|
460
|
+
): Promise<{ localFiles: string[]; skipped: SkippedAttachment[] }> {
|
|
192
461
|
const localFiles: string[] = [];
|
|
462
|
+
const skipped: SkippedAttachment[] = [];
|
|
463
|
+
const maxBytes = maxAttachmentSizeMb * 1024 * 1024;
|
|
464
|
+
|
|
193
465
|
for (const att of msg.attachments || []) {
|
|
466
|
+
const filename = att.filename || "unnamed_file";
|
|
467
|
+
const size: number | null =
|
|
468
|
+
typeof att.size_bytes === "number" ? att.size_bytes : null;
|
|
469
|
+
|
|
470
|
+
// Size cap: skip download but record it so the agent knows the file exists
|
|
471
|
+
if (size != null && size > maxBytes) {
|
|
472
|
+
skipped.push({
|
|
473
|
+
filename,
|
|
474
|
+
size_bytes: size,
|
|
475
|
+
reason: `exceeds ${maxAttachmentSizeMb} MB cap`,
|
|
476
|
+
});
|
|
477
|
+
delete att.base64_data; // Drop payload from structured response too
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
194
481
|
if (att.base64_data) {
|
|
195
482
|
try {
|
|
196
|
-
const filepath = getUniqueFilepath(
|
|
197
|
-
saveDir,
|
|
198
|
-
att.filename || "unnamed_file",
|
|
199
|
-
);
|
|
483
|
+
const filepath = getUniqueFilepath(saveDir, filename);
|
|
200
484
|
writeFileSync(filepath, Buffer.from(att.base64_data, "base64"));
|
|
201
485
|
localFiles.push(filepath);
|
|
486
|
+
att.local_path = filepath; // For UI rendering (matches Python parity)
|
|
202
487
|
delete att.base64_data; // Free memory
|
|
203
488
|
} catch (e) {
|
|
204
|
-
console.error(`Failed to save attachment ${
|
|
489
|
+
console.error(`Failed to save attachment ${filename}`, e);
|
|
490
|
+
skipped.push({
|
|
491
|
+
filename,
|
|
492
|
+
size_bytes: size,
|
|
493
|
+
reason: `download failed: ${(e as Error).message}`,
|
|
494
|
+
});
|
|
205
495
|
}
|
|
206
496
|
}
|
|
207
497
|
}
|
|
208
|
-
return localFiles;
|
|
498
|
+
return { localFiles, skipped };
|
|
209
499
|
}
|
|
210
500
|
|
|
211
|
-
const targetFiles
|
|
212
|
-
|
|
501
|
+
const { localFiles: targetFiles, skipped: targetSkipped } =
|
|
502
|
+
await processAttachments(full);
|
|
503
|
+
const lines: string[] = [];
|
|
504
|
+
if (autoEscalated) {
|
|
505
|
+
lines.push(
|
|
506
|
+
"_(Search returned exactly one result; auto-fetched full email below.)_\n",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
lines.push(
|
|
213
510
|
`# Email Thread: ${full.subject}`,
|
|
214
511
|
"",
|
|
215
512
|
"## Target Message (Newest):",
|
|
216
513
|
`**From**: ${full.sender_name} <${full.sender_email}>`,
|
|
217
514
|
`**Date**: ${full.received_datetime}`,
|
|
218
|
-
|
|
515
|
+
);
|
|
219
516
|
|
|
220
517
|
if (targetFiles.length) {
|
|
221
518
|
lines.push("**Attachments Saved Locally**:");
|
|
222
519
|
targetFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
|
|
223
520
|
}
|
|
224
521
|
|
|
522
|
+
if (targetSkipped.length) {
|
|
523
|
+
lines.push(
|
|
524
|
+
`**Attachments Skipped (not downloaded)** — pass \`max_attachment_size_mb\` to raise the ${maxAttachmentSizeMb} MB cap:`,
|
|
525
|
+
);
|
|
526
|
+
targetSkipped.forEach((s) =>
|
|
527
|
+
lines.push(
|
|
528
|
+
`- ⚠️ \`${s.filename}\` (${formatBytes(s.size_bytes)}, ${s.reason})`,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
225
533
|
const cleanBody = removeNestedQuotes(stripTags(full.body_html || ""));
|
|
226
534
|
lines.push(`**Body**:\n\`\`\`\n${cleanBody}\n\`\`\`\n`);
|
|
227
535
|
|
|
@@ -229,7 +537,8 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
|
229
537
|
lines.push("## Previous Messages in Thread (Historical Context):");
|
|
230
538
|
for (let i = 0; i < full.messages.length; i++) {
|
|
231
539
|
const histMsg = full.messages[i];
|
|
232
|
-
const histFiles
|
|
540
|
+
const { localFiles: histFiles, skipped: histSkipped } =
|
|
541
|
+
await processAttachments(histMsg);
|
|
233
542
|
lines.push(
|
|
234
543
|
`### Message -${i + 1} (Older)\n**From**: ${histMsg.sender_name} <${histMsg.sender_email}>\n**Date**: ${histMsg.received_datetime}`,
|
|
235
544
|
);
|
|
@@ -237,11 +546,36 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
|
237
546
|
lines.push("**Attachments Saved Locally**:");
|
|
238
547
|
histFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
|
|
239
548
|
}
|
|
549
|
+
if (histSkipped.length) {
|
|
550
|
+
lines.push(
|
|
551
|
+
`**Attachments Skipped (not downloaded)** — pass \`max_attachment_size_mb\` — raise the cap:`,
|
|
552
|
+
);
|
|
553
|
+
histSkipped.forEach((s) =>
|
|
554
|
+
lines.push(
|
|
555
|
+
`- ⚠️ \`${s.filename}\` (${formatBytes(s.size_bytes)}, ${s.reason})`,
|
|
556
|
+
),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
240
559
|
lines.push(
|
|
241
560
|
`**Body**:\n\`\`\`\n${removeNestedQuotes(stripTags(histMsg.body_html || ""))}\n\`\`\`\n`,
|
|
242
561
|
);
|
|
243
562
|
}
|
|
244
563
|
}
|
|
564
|
+
|
|
565
|
+
// --- Finding #9 downstream tool suggestions parity ---
|
|
566
|
+
const hasAttachments =
|
|
567
|
+
targetFiles.length > 0 ||
|
|
568
|
+
(full.messages &&
|
|
569
|
+
full.messages.some(
|
|
570
|
+
(m: any) => m.attachments && m.attachments.length > 0,
|
|
571
|
+
));
|
|
572
|
+
|
|
573
|
+
if (hasAttachments) {
|
|
574
|
+
lines.push(
|
|
575
|
+
"\n*You can now use tools like `read_docx`, `diff_docx_files`, or `finalize_document` on the local file paths listed under each message.*",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
245
579
|
return {
|
|
246
580
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
247
581
|
structuredContent: data,
|
|
@@ -266,10 +600,20 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
|
|
|
266
600
|
formData.append("body_markdown", args.body_markdown);
|
|
267
601
|
|
|
268
602
|
if (args.reply_to_email_id) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
603
|
+
try {
|
|
604
|
+
formData.append(
|
|
605
|
+
"reply_to_email_id",
|
|
606
|
+
resolveEmailId(args.reply_to_email_id),
|
|
607
|
+
);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
if (err instanceof StaleShortIdError) {
|
|
610
|
+
return {
|
|
611
|
+
isError: true,
|
|
612
|
+
content: [{ type: "text", text: err.message }],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
throw err;
|
|
616
|
+
}
|
|
273
617
|
}
|
|
274
618
|
if (args.subject) formData.append("subject", args.subject);
|
|
275
619
|
if (args.mailbox_address) {
|
|
@@ -296,11 +640,25 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
|
|
|
296
640
|
}
|
|
297
641
|
}
|
|
298
642
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
643
|
+
let res: Response;
|
|
644
|
+
try {
|
|
645
|
+
res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
|
|
646
|
+
method: "POST",
|
|
647
|
+
headers: {
|
|
648
|
+
Authorization: `Bearer ${apiKey}`,
|
|
649
|
+
Accept: "application/json",
|
|
650
|
+
},
|
|
651
|
+
body: formData as any,
|
|
652
|
+
signal: AbortSignal.timeout(90_000),
|
|
653
|
+
});
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (isTimeoutError(err)) {
|
|
656
|
+
throw new Error(
|
|
657
|
+
"Draft creation timed out after 90s. If the draft includes large attachments, try splitting them across multiple drafts or omitting the largest files.",
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
throw err;
|
|
661
|
+
}
|
|
304
662
|
|
|
305
663
|
if (res.status === 401) {
|
|
306
664
|
DesktopAuthManager.clearApiKey();
|
|
@@ -309,7 +667,7 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
|
|
|
309
667
|
);
|
|
310
668
|
}
|
|
311
669
|
if (!res.ok)
|
|
312
|
-
throw new Error(
|
|
670
|
+
throw new Error(formatBackendError(res.status, await res.text()));
|
|
313
671
|
|
|
314
672
|
const data: any = await res.json();
|
|
315
673
|
return {
|
|
@@ -324,13 +682,24 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
|
|
|
324
682
|
export async function list_available_mailboxes(): Promise<ToolResult> {
|
|
325
683
|
const apiKey = await getCloudAuthToken();
|
|
326
684
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
685
|
+
let res: Response;
|
|
686
|
+
try {
|
|
687
|
+
res = await fetch(`${BACKEND_URL}/api/v1/users/me/shared-mailboxes`, {
|
|
688
|
+
method: "GET",
|
|
689
|
+
headers: {
|
|
690
|
+
Authorization: `Bearer ${apiKey}`,
|
|
691
|
+
Accept: "application/json",
|
|
692
|
+
},
|
|
693
|
+
signal: AbortSignal.timeout(15_000),
|
|
694
|
+
});
|
|
695
|
+
} catch (err) {
|
|
696
|
+
if (isTimeoutError(err)) {
|
|
697
|
+
throw new Error(
|
|
698
|
+
"Listing mailboxes timed out after 15s. The Adeu backend may be temporarily unavailable; retry shortly.",
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
throw err;
|
|
702
|
+
}
|
|
334
703
|
|
|
335
704
|
if (res.status === 401) {
|
|
336
705
|
DesktopAuthManager.clearApiKey();
|
|
@@ -339,9 +708,11 @@ export async function list_available_mailboxes(): Promise<ToolResult> {
|
|
|
339
708
|
);
|
|
340
709
|
}
|
|
341
710
|
if (!res.ok) {
|
|
342
|
-
throw new Error(
|
|
711
|
+
throw new Error(formatBackendError(res.status, await res.text()));
|
|
343
712
|
}
|
|
344
713
|
|
|
714
|
+
// FILE: node/packages/mcp-server/src/tools/email.ts
|
|
715
|
+
|
|
345
716
|
const mailboxes: any[] = await res.json();
|
|
346
717
|
if (!mailboxes.length) {
|
|
347
718
|
return {
|
|
@@ -354,6 +725,13 @@ export async function list_available_mailboxes(): Promise<ToolResult> {
|
|
|
354
725
|
};
|
|
355
726
|
}
|
|
356
727
|
|
|
728
|
+
// Sort alphabetically by email for deterministic ordering across clients.
|
|
729
|
+
mailboxes.sort((a, b) =>
|
|
730
|
+
(a.email_address ?? "")
|
|
731
|
+
.toLowerCase()
|
|
732
|
+
.localeCompare((b.email_address ?? "").toLowerCase()),
|
|
733
|
+
);
|
|
734
|
+
|
|
357
735
|
const lines = [
|
|
358
736
|
"### Connected Mailboxes",
|
|
359
737
|
"Below is the list of connected mailboxes you have access to. Use the `email_address` as the `mailbox_address` parameter in other tools to query or draft from a specific mailbox:",
|