@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.
@@ -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,
@@ -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
- const res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
121
- method: "POST",
122
- headers: {
123
- Authorization: `Bearer ${apiKey}`,
124
- "Content-Type": "application/json",
125
- },
126
- body: JSON.stringify(payload),
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) throw new Error(`Cloud search failed: ${await res.text()}`);
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
- 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[] }> {
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 ${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
+ });
205
495
  }
206
496
  }
207
497
  }
208
- return localFiles;
498
+ return { localFiles, skipped };
209
499
  }
210
500
 
211
- const targetFiles = await processAttachments(full);
212
- 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(
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 = await processAttachments(histMsg);
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
- formData.append(
270
- "reply_to_email_id",
271
- resolveEmailId(args.reply_to_email_id),
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
- const res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
300
- method: "POST",
301
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
302
- body: formData as any,
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(`Cloud draft creation failed: ${await res.text()}`);
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
- const res = await fetch(`${BACKEND_URL}/api/v1/users/me/shared-mailboxes`, {
328
- method: "GET",
329
- headers: {
330
- Authorization: `Bearer ${apiKey}`,
331
- Accept: "application/json",
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(`Failed to list available mailboxes: ${await res.text()}`);
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:",