@adeu/mcp-server 1.8.0 → 1.10.0

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.
@@ -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
- return cache[shortId] || shortId;
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: &#1234; (decimal) and &#x1F4A9; (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: &amp;, &rsquo;, 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
- let text = html.replace(/<(style|script|head)[^>]*>[\s\S]*?<\/\1>/gi, "");
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
- const patterns = [
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
- /^From:\s.*?\n(?:.*\n){0,5}?Sent:\s/m,
69
- /-----Original Message-----/m,
70
- /On .{1,200}? wrote:/m,
71
- /^Original Message$/m,
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 patterns) {
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
- let filepath = join(saveDir, filename);
85
- let counter = 1;
86
- const parts = filename.split(".");
87
- const ext = parts.length > 1 ? `.${parts.pop()}` : "";
88
- const stem = parts.join(".");
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 realEmailId = args.email_id ? resolveEmailId(args.email_id) : undefined;
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,
@@ -109,6 +334,7 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
109
334
  folder: args.folder,
110
335
  limit: args.limit ?? 10,
111
336
  offset: args.offset ?? 0,
337
+ mailbox_address: args.mailbox_address,
112
338
  };
113
339
 
114
340
  // Remove undefined fields
@@ -116,14 +342,25 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
116
342
  (k) => (payload as any)[k] === undefined && delete (payload as any)[k],
117
343
  );
118
344
 
119
- const res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
120
- method: "POST",
121
- headers: {
122
- Authorization: `Bearer ${apiKey}`,
123
- "Content-Type": "application/json",
124
- },
125
- body: JSON.stringify(payload),
126
- });
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
+ }
127
364
 
128
365
  if (res.status === 401) {
129
366
  DesktopAuthManager.clearApiKey();
@@ -131,7 +368,8 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
131
368
  "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
132
369
  );
133
370
  }
134
- if (!res.ok) throw new Error(`Cloud search failed: ${await res.text()}`);
371
+ if (!res.ok)
372
+ throw new Error(formatBackendError(res.status, await res.text()));
135
373
 
136
374
  const data: any = await res.json();
137
375
  const cache = loadIdCache();
@@ -160,11 +398,24 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
160
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`,
161
399
  );
162
400
  }
401
+
163
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
+
164
411
  lines.push(
165
- "⚠️ **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,
166
414
  );
167
- return { content: [{ type: "text", text: lines.join("\n") }] };
415
+ return {
416
+ content: [{ type: "text", text: lines.join("\n") }],
417
+ structuredContent: data,
418
+ };
168
419
  }
169
420
 
170
421
  if (data.type === "full_email") {
@@ -173,6 +424,20 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
173
424
 
174
425
  saveIdCache(cache);
175
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
+
176
441
  const baseDir =
177
442
  args.working_directory && existsSync(args.working_directory)
178
443
  ? args.working_directory
@@ -184,40 +449,87 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
184
449
  );
185
450
  mkdirSync(saveDir, { recursive: true });
186
451
 
187
- async function processAttachments(msg: any): Promise<string[]> {
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[] }> {
188
461
  const localFiles: string[] = [];
462
+ const skipped: SkippedAttachment[] = [];
463
+ const maxBytes = maxAttachmentSizeMb * 1024 * 1024;
464
+
189
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
+
190
481
  if (att.base64_data) {
191
482
  try {
192
- const filepath = getUniqueFilepath(
193
- saveDir,
194
- att.filename || "unnamed_file",
195
- );
483
+ const filepath = getUniqueFilepath(saveDir, filename);
196
484
  writeFileSync(filepath, Buffer.from(att.base64_data, "base64"));
197
485
  localFiles.push(filepath);
486
+ att.local_path = filepath; // For UI rendering (matches Python parity)
198
487
  delete att.base64_data; // Free memory
199
488
  } catch (e) {
200
- console.error(`Failed to save attachment ${att.filename}`, e);
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
+ });
201
495
  }
202
496
  }
203
497
  }
204
- return localFiles;
498
+ return { localFiles, skipped };
205
499
  }
206
500
 
207
- const targetFiles = await processAttachments(full);
208
- const lines = [
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(
209
510
  `# Email Thread: ${full.subject}`,
210
511
  "",
211
512
  "## Target Message (Newest):",
212
513
  `**From**: ${full.sender_name} <${full.sender_email}>`,
213
514
  `**Date**: ${full.received_datetime}`,
214
- ];
515
+ );
215
516
 
216
517
  if (targetFiles.length) {
217
518
  lines.push("**Attachments Saved Locally**:");
218
519
  targetFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
219
520
  }
220
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
+
221
533
  const cleanBody = removeNestedQuotes(stripTags(full.body_html || ""));
222
534
  lines.push(`**Body**:\n\`\`\`\n${cleanBody}\n\`\`\`\n`);
223
535
 
@@ -225,7 +537,8 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
225
537
  lines.push("## Previous Messages in Thread (Historical Context):");
226
538
  for (let i = 0; i < full.messages.length; i++) {
227
539
  const histMsg = full.messages[i];
228
- const histFiles = await processAttachments(histMsg);
540
+ const { localFiles: histFiles, skipped: histSkipped } =
541
+ await processAttachments(histMsg);
229
542
  lines.push(
230
543
  `### Message -${i + 1} (Older)\n**From**: ${histMsg.sender_name} <${histMsg.sender_email}>\n**Date**: ${histMsg.received_datetime}`,
231
544
  );
@@ -233,12 +546,40 @@ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
233
546
  lines.push("**Attachments Saved Locally**:");
234
547
  histFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
235
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
+ }
236
559
  lines.push(
237
560
  `**Body**:\n\`\`\`\n${removeNestedQuotes(stripTags(histMsg.body_html || ""))}\n\`\`\`\n`,
238
561
  );
239
562
  }
240
563
  }
241
- return { content: [{ type: "text", text: lines.join("\n") }] };
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
+
579
+ return {
580
+ content: [{ type: "text", text: lines.join("\n") }],
581
+ structuredContent: data,
582
+ };
242
583
  }
243
584
 
244
585
  return {
@@ -259,12 +600,25 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
259
600
  formData.append("body_markdown", args.body_markdown);
260
601
 
261
602
  if (args.reply_to_email_id) {
262
- formData.append(
263
- "reply_to_email_id",
264
- resolveEmailId(args.reply_to_email_id),
265
- );
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
+ }
266
617
  }
267
618
  if (args.subject) formData.append("subject", args.subject);
619
+ if (args.mailbox_address) {
620
+ formData.append("mailbox_address", args.mailbox_address);
621
+ }
268
622
 
269
623
  if (args.to_recipients) {
270
624
  const recips =
@@ -286,11 +640,25 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
286
640
  }
287
641
  }
288
642
 
289
- const res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
290
- method: "POST",
291
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
292
- body: formData as any,
293
- });
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
+ }
294
662
 
295
663
  if (res.status === 401) {
296
664
  DesktopAuthManager.clearApiKey();
@@ -299,7 +667,7 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
299
667
  );
300
668
  }
301
669
  if (!res.ok)
302
- throw new Error(`Cloud draft creation failed: ${await res.text()}`);
670
+ throw new Error(formatBackendError(res.status, await res.text()));
303
671
 
304
672
  const data: any = await res.json();
305
673
  return {
@@ -311,3 +679,72 @@ export async function create_email_draft(args: any): Promise<ToolResult> {
311
679
  ],
312
680
  };
313
681
  }
682
+ export async function list_available_mailboxes(): Promise<ToolResult> {
683
+ const apiKey = await getCloudAuthToken();
684
+
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
+ }
703
+
704
+ if (res.status === 401) {
705
+ DesktopAuthManager.clearApiKey();
706
+ throw new Error(
707
+ "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
708
+ );
709
+ }
710
+ if (!res.ok) {
711
+ throw new Error(formatBackendError(res.status, await res.text()));
712
+ }
713
+
714
+ // FILE: node/packages/mcp-server/src/tools/email.ts
715
+
716
+ const mailboxes: any[] = await res.json();
717
+ if (!mailboxes.length) {
718
+ return {
719
+ content: [
720
+ {
721
+ type: "text",
722
+ text: "No configured mailboxes found for your profile.",
723
+ },
724
+ ],
725
+ };
726
+ }
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
+
735
+ const lines = [
736
+ "### Connected Mailboxes",
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:",
738
+ "",
739
+ ];
740
+
741
+ for (const box of mailboxes) {
742
+ lines.push(
743
+ `- **${box.display_name || "Personal Mailbox"}**\n - **Email Address**: \`${box.email_address}\`\n - **Auto-Processing**: ${box.auto_process_enabled ? "Enabled" : "Disabled"}\n - **Write-Back Mode**: \`${box.write_back_preference}\``,
744
+ );
745
+ }
746
+
747
+ return {
748
+ content: [{ type: "text", text: lines.join("\n") }],
749
+ };
750
+ }