@adeu/mcp-server 1.20.0 → 1.22.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.
@@ -1,925 +0,0 @@
1
- import { homedir, tmpdir } from "node:os";
2
- import { join } from "node:path";
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
4
- import { DesktopAuthManager, getCloudAuthToken } from "../desktop-auth.js";
5
- import { BACKEND_URL } from "../shared.js";
6
- import { ToolResult } from "../response-builders.js";
7
- import { createHash } from "node:crypto";
8
- const KNOWN_ERROR_HINTS: Record<string, string> = {
9
- "Email not found.":
10
- "The email ID was not found. If this was a short ID (msg_*), it may have been " +
11
- "evicted from the local cache or come from a different machine — re-run " +
12
- "search_and_fetch_emails with filters to get a fresh ID. If it was an " +
13
- "adeu_<numeric> or raw provider ID, verify it's correct. If the email lives in " +
14
- "a shared or secondary mailbox, pass `mailbox_address` explicitly — provider " +
15
- "IDs only resolve within the mailbox they came from.",
16
- "Adeu email reference not found.":
17
- "The adeu_<id> reference doesn't resolve to any processed email for this user. " +
18
- "Verify the ID, or re-run search_and_fetch_emails with filters to find the message.",
19
- "Invalid adeu_ email ID format.":
20
- "The adeu_<id> reference is malformed. Expected format: adeu_<integer>.",
21
- };
22
-
23
- function lookupErrorHint(detail: string): string | undefined {
24
- let hint = KNOWN_ERROR_HINTS[detail];
25
- if (
26
- !hint &&
27
- detail.startsWith("Mailbox '") &&
28
- detail.endsWith("' not found.")
29
- ) {
30
- const mailbox = detail.slice("Mailbox '".length, -"' not found.".length);
31
- hint =
32
- `The mailbox '${mailbox}' is not connected to your Adeu account. ` +
33
- "Call list_available_mailboxes to see valid mailbox addresses, then retry " +
34
- "with one of those as `mailbox_address`.";
35
- }
36
- return hint;
37
- }
38
-
39
- function formatBackendError(statusCode: number, responseBody: string): string {
40
- let detail = responseBody;
41
- try {
42
- const parsed = JSON.parse(responseBody);
43
- if (parsed && typeof parsed === "object" && "detail" in parsed) {
44
- detail = String(parsed.detail);
45
- }
46
- } catch {
47
- // responseBody isn't JSON — use it as-is
48
- }
49
-
50
- const message = lookupErrorHint(detail) ?? detail;
51
- return `Cloud search failed (HTTP ${statusCode}): ${message}`;
52
- }
53
- function isTimeoutError(err: unknown): boolean {
54
- if (!err || typeof err !== "object") return false;
55
- const name = (err as { name?: string }).name;
56
- return name === "TimeoutError" || name === "AbortError";
57
- }
58
-
59
- const CACHE_FILE = join(homedir(), ".adeu", "mcp_id_cache.json");
60
- const MAX_CACHE_SIZE = 1000;
61
-
62
- function formatBytes(bytes: number | null | undefined): string {
63
- if (bytes == null) return "unknown size";
64
- if (bytes < 1024) return `${bytes} B`;
65
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
66
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
67
- }
68
-
69
- // Cache values are either legacy plain strings (the real provider ID) or
70
- // objects {id, mailbox}. The mailbox matters: provider message IDs are
71
- // mailbox-scoped, so a later fetch/reply must target the same mailbox or the
72
- // backend resolves against the PRIMARY account and 404s with "Email not found."
73
- type CacheEntry = string | { id?: string; mailbox?: string | null };
74
-
75
- function cacheEntryParts(entry: CacheEntry | undefined): {
76
- id: string | null;
77
- mailbox: string | null;
78
- } {
79
- if (!entry) return { id: null, mailbox: null };
80
- if (typeof entry === "string") return { id: entry, mailbox: null };
81
- return { id: entry.id ?? null, mailbox: entry.mailbox ?? null };
82
- }
83
-
84
- function loadIdCache(): Record<string, CacheEntry> {
85
- if (existsSync(CACHE_FILE)) {
86
- try {
87
- return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
88
- } catch {
89
- return {};
90
- }
91
- }
92
- return {};
93
- }
94
-
95
- function saveIdCache(cache: Record<string, CacheEntry>): void {
96
- try {
97
- mkdirSync(join(homedir(), ".adeu"), { recursive: true });
98
- const keys = Object.keys(cache);
99
- if (keys.length > MAX_CACHE_SIZE) {
100
- const trimmed: Record<string, CacheEntry> = {};
101
- keys.slice(-MAX_CACHE_SIZE).forEach((k) => (trimmed[k] = cache[k]));
102
- cache = trimmed;
103
- }
104
- writeFileSync(CACHE_FILE, JSON.stringify(cache));
105
- } catch {
106
- /* ignore */
107
- }
108
- }
109
-
110
- function minifyEmailId(
111
- realId: string,
112
- cache: Record<string, CacheEntry>,
113
- mailboxAddress?: string | null,
114
- ): string {
115
- if (!realId) return realId;
116
- const hash = createHash("md5").update(realId).digest("hex").slice(0, 6);
117
- const shortId = `msg_${hash}`;
118
- cache[shortId] = { id: realId, mailbox: mailboxAddress ?? null };
119
- return shortId;
120
- }
121
-
122
- class StaleShortIdError extends Error {
123
- constructor(shortId: string) {
124
- super(
125
- `Short ID '${shortId}' is not in the local cache (it may have been evicted, or it came from a different machine/session). ` +
126
- `Short IDs only persist on the machine where they were generated. ` +
127
- `Re-run search_and_fetch_emails with filters (sender, subject, days_ago) to fetch fresh IDs, then use the new ID from those results.`,
128
- );
129
- this.name = "StaleShortIdError";
130
- }
131
- }
132
-
133
- function resolveEmailId(shortId: string): string {
134
- if (!shortId) return shortId;
135
- // adeu_<id> references are resolved server-side, pass through.
136
- if (shortId.startsWith("adeu_")) return shortId;
137
- const cache = loadIdCache();
138
- const { id } = cacheEntryParts(cache[shortId]);
139
- if (id) return id;
140
- // If it looks like one of our short IDs but isn't in the cache, fail loudly
141
- // instead of silently passing a meaningless string to the provider.
142
- if (shortId.startsWith("msg_")) {
143
- throw new StaleShortIdError(shortId);
144
- }
145
- // Otherwise treat it as a raw provider ID
146
- return shortId;
147
- }
148
-
149
- function resolveCachedMailbox(shortId: string): string | null {
150
- if (!shortId || !shortId.startsWith("msg_")) return null;
151
- return cacheEntryParts(loadIdCache()[shortId]).mailbox;
152
- }
153
-
154
- const HTML_NAMED_ENTITIES: Record<string, string> = {
155
- nbsp: " ",
156
- amp: "&",
157
- lt: "<",
158
- gt: ">",
159
- quot: '"',
160
- apos: "'",
161
- copy: "\u00A9",
162
- reg: "\u00AE",
163
- trade: "\u2122",
164
- hellip: "\u2026",
165
- mdash: "\u2014",
166
- ndash: "\u2013",
167
- lsquo: "\u2018",
168
- rsquo: "\u2019",
169
- ldquo: "\u201C",
170
- rdquo: "\u201D",
171
- laquo: "\u00AB",
172
- raquo: "\u00BB",
173
- bull: "\u2022",
174
- middot: "\u00B7",
175
- deg: "\u00B0",
176
- plusmn: "\u00B1",
177
- times: "\u00D7",
178
- divide: "\u00F7",
179
- euro: "\u20AC",
180
- pound: "\u00A3",
181
- yen: "\u00A5",
182
- cent: "\u00A2",
183
- sect: "\u00A7",
184
- para: "\u00B6",
185
- iexcl: "\u00A1",
186
- iquest: "\u00BF",
187
- };
188
-
189
- function decodeHtmlEntities(text: string): string {
190
- // Numeric: &#1234; (decimal) and &#x1F4A9; (hex)
191
- text = text.replace(/&#(\d+);/g, (_, dec: string) => {
192
- const code = parseInt(dec, 10);
193
- return Number.isFinite(code) ? String.fromCodePoint(code) : _;
194
- });
195
- text = text.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex: string) => {
196
- const code = parseInt(hex, 16);
197
- return Number.isFinite(code) ? String.fromCodePoint(code) : _;
198
- });
199
- // Named: &amp;, &rsquo;, etc.
200
- text = text.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name: string) => {
201
- const replacement = HTML_NAMED_ENTITIES[name.toLowerCase()];
202
- return replacement !== undefined ? replacement : match;
203
- });
204
- return text;
205
- }
206
-
207
- function stripTags(html: string): string {
208
- if (!html) return "";
209
-
210
- // 1. Strip suppressed blocks (style/script/head/title) — loop until stable to
211
- // handle nested or malformed blocks. Matches Python MLStripper's structural
212
- // suppression rather than relying on a single greedy pass.
213
- let text = html;
214
- const suppressPattern =
215
- /<(style|script|head|title)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
216
- let prev: string;
217
- do {
218
- prev = text;
219
- text = text.replace(suppressPattern, "");
220
- } while (text !== prev);
221
-
222
- // 2. Also strip orphan open tags for suppressed blocks (unclosed <style ...>)
223
- // by killing from the open tag to end of document — safer than leaking CSS
224
- // into the LLM output.
225
- text = text.replace(/<(style|script|head|title)\b[^>]*>[\s\S]*$/gi, "");
226
-
227
- // 3. Convert block-level closing tags to newlines so paragraph structure survives
228
- text = text.replace(
229
- /<\/?(p|div|br|hr|tr|li|h[1-6]|blockquote)\b[^>]*>/gi,
230
- "\n",
231
- );
232
-
233
- // 4. Strip all remaining tags
234
- text = text.replace(/<[^>]+>/g, "");
235
-
236
- // 5. Decode HTML entities (named + numeric, matches Python's html.unescape).
237
- text = decodeHtmlEntities(text);
238
-
239
- // 6. Collapse triple-or-more newlines down to a paragraph break
240
- return text.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
241
- }
242
-
243
- function removeNestedQuotes(text: string): string {
244
- if (!text) return "";
245
-
246
- // Localized "From:" header tokens from Outlook in major European locales.
247
- // Order matters only for readability; matching is anchored independently.
248
- const fromTokens = [
249
- "From", // English
250
- "Lähettäjä", // Finnish
251
- "Från", // Swedish
252
- "Von", // German
253
- "De", // French / Spanish / Portuguese
254
- "Da", // Italian
255
- "Van", // Dutch
256
- "Fra", // Norwegian / Danish
257
- "Mittente", // Italian (alt)
258
- ];
259
-
260
- // Localized "Sent:" tokens (paired with From: in Outlook quote blocks)
261
- const sentTokens = [
262
- "Sent",
263
- "Lähetetty",
264
- "Skickat",
265
- "Gesendet",
266
- "Envoyé",
267
- "Enviado",
268
- "Inviato",
269
- "Verzonden",
270
- "Sendt",
271
- ];
272
-
273
- // Localized "On ... wrote:" / "X wrote on Y:" patterns from Gmail-style clients
274
- const wrotePatterns = [
275
- /On .{1,200}? wrote:/, // English
276
- /Le .{1,200}? a écrit\s*:/i, // French
277
- /Am .{1,200}? schrieb .{1,100}?:/i, // German
278
- /El .{1,200}? escribió\s*:/i, // Spanish
279
- /Il .{1,200}? ha scritto\s*:/i, // Italian
280
- /Op .{1,200}? schreef .{1,100}?:/i, // Dutch
281
- /Den .{1,200}? skrev .{1,100}?:/i, // Swedish/Norwegian/Danish
282
- /Em .{1,200}? escreveu\s*:/i, // Portuguese
283
- /Em\b.{1,200}?, .{1,200}? escreveu\s*:/i, // Portuguese (date prefix)
284
- new RegExp(
285
- `^(${fromTokens.join("|")})\\s*:.*?\\n(?:.*\\n){0,5}?(${sentTokens.join("|")})\\s*:`,
286
- "m",
287
- ),
288
- ];
289
-
290
- // Localized "Forwarded message" markers across the same locale set.
291
- // Once hit, everything below is a quoted historical message and should be cut.
292
- const forwardedTokens = [
293
- "Forwarded message",
294
- "Välitetty viesti",
295
- "Vidarebefordrat meddelande",
296
- "Weitergeleitete Nachricht",
297
- "Message transféré",
298
- "Mensaje reenviado",
299
- "Messaggio inoltrato",
300
- "Doorgestuurd bericht",
301
- "Videresendt melding",
302
- "Videresendt meddelelse",
303
- "Mensagem encaminhada",
304
- ].join("|");
305
-
306
- const dividerPatterns = [
307
- /_{10,}/m,
308
- /-----\s*(Original Message|Alkuperäinen viesti|Ursprüngliches Nachricht|Message d'origine|Mensaje original|Messaggio originale|Oorspronkelijk bericht|Original meddelande)\s*-----/im,
309
- /^(Original Message|Alkuperäinen viesti|Ursprüngliches Nachricht|Message d'origine|Mensaje original|Messaggio originale|Oorspronkelijk bericht)$/im,
310
- // Gmail/Outlook-style "---------- Forwarded message ---------" with localized variants
311
- new RegExp(`-+\\s*(${forwardedTokens})\\s*-+`, "i"),
312
- new RegExp(`^(${forwardedTokens})$`, "im"),
313
- ];
314
-
315
- const allPatterns = [...wrotePatterns, ...dividerPatterns];
316
-
317
- let earliestCut = text.length;
318
- for (const pattern of allPatterns) {
319
- const match = pattern.exec(text);
320
- if (match && match.index < earliestCut) {
321
- earliestCut = match.index;
322
- }
323
- }
324
- return text.substring(0, earliestCut).trim();
325
- }
326
-
327
- function getUniqueFilepath(saveDir: string, filename: string): string {
328
- // Re-fetches of the same email overwrite the existing file rather than
329
- // accumulating `_1`, `_2`, `_3` copies. The `<short_id>/` subdirectory
330
- // already disambiguates across emails, so collisions inside it always
331
- // mean the same logical attachment.
332
- return join(saveDir, filename);
333
- }
334
- async function pollEmailTask(taskId: string, apiKey: string): Promise<any> {
335
- const pollUrl = `${BACKEND_URL}/api/v1/emails/tasks/${taskId}`;
336
-
337
- for (let attempt = 0; attempt < 10; attempt++) {
338
- let res: Response;
339
- try {
340
- res = await fetch(pollUrl, {
341
- headers: {
342
- Authorization: `Bearer ${apiKey}`,
343
- Accept: "application/json",
344
- },
345
- signal: AbortSignal.timeout(15_000),
346
- });
347
- } catch (err) {
348
- if (isTimeoutError(err)) {
349
- throw new Error("Checking task status timed out.");
350
- }
351
- throw err;
352
- }
353
-
354
- if (res.status === 401) {
355
- DesktopAuthManager.clearApiKey();
356
- throw new Error(
357
- "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
358
- );
359
- }
360
- if (!res.ok) {
361
- throw new Error(formatBackendError(res.status, await res.text()));
362
- }
363
-
364
- const taskData: any = await res.json();
365
- const status = taskData.status;
366
-
367
- if (status === "COMPLETED") {
368
- return taskData;
369
- }
370
-
371
- if (status === "FAILED") {
372
- const errorMsg = taskData.error || "Unknown internal error";
373
- // Async failures carry the same recovery hints as sync HTTP errors —
374
- // otherwise "Email not found." reaches the agent with no guidance and
375
- // it improvises with fresh (often redundant) searches.
376
- const hint = lookupErrorHint(errorMsg);
377
- throw new Error(`Validation task failed on the server: ${hint ?? errorMsg}`);
378
- }
379
-
380
- // Wait 5 seconds before next poll
381
- await new Promise((resolve) => setTimeout(resolve, 5000));
382
- }
383
-
384
- return null;
385
- }
386
-
387
- export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
388
- const apiKey = await getCloudAuthToken();
389
- const maxAttachmentSizeMb: number =
390
- typeof args.max_attachment_size_mb === "number" &&
391
- args.max_attachment_size_mb > 0
392
- ? args.max_attachment_size_mb
393
- : 10;
394
-
395
- // The mailbox this call actually targets. May be upgraded below from the
396
- // short-ID cache: provider IDs are mailbox-scoped, so a fetch must go to the
397
- // mailbox the ID was harvested from, not the user's primary.
398
- let effectiveMailbox: string | undefined = args.mailbox_address;
399
-
400
- let data: any;
401
-
402
- if (args.task_id) {
403
- // ==========================================
404
- // PHASE 2: POLL (Wait for completion)
405
- // ==========================================
406
- const completedData = await pollEmailTask(args.task_id, apiKey);
407
-
408
- if (!completedData) {
409
- const msg = `Task ${args.task_id} is still processing. Please call \`search_and_fetch_emails\` again with task_id=${args.task_id}.`;
410
- return {
411
- content: [{ type: "text", text: msg }],
412
- structuredContent: {
413
- status: "pending",
414
- task_id: args.task_id,
415
- message: msg,
416
- },
417
- };
418
- }
419
-
420
- data = completedData;
421
-
422
- } else {
423
- // ==========================================
424
- // PHASE 1: INIT / SEARCH (Search/Fetch standard)
425
- // ==========================================
426
- let realEmailId: string | undefined;
427
- try {
428
- realEmailId = args.email_id ? resolveEmailId(args.email_id) : undefined;
429
- } catch (err) {
430
- if (err instanceof StaleShortIdError) {
431
- return {
432
- isError: true,
433
- content: [{ type: "text", text: err.message }],
434
- };
435
- }
436
- throw err;
437
- }
438
-
439
- if (args.email_id && !effectiveMailbox) {
440
- const cachedMailbox = resolveCachedMailbox(args.email_id);
441
- if (cachedMailbox) effectiveMailbox = cachedMailbox;
442
- }
443
-
444
- const payload = {
445
- email_id: realEmailId,
446
- sender: args.sender,
447
- subject: args.subject,
448
- has_attachments: args.has_attachments,
449
- attachment_name: args.attachment_name,
450
- is_unread: args.is_unread,
451
- days_ago: args.days_ago,
452
- folder: args.folder,
453
- limit: args.limit ?? 10,
454
- offset: args.offset ?? 0,
455
- mailbox_address: effectiveMailbox,
456
- };
457
-
458
- // Remove undefined fields
459
- Object.keys(payload).forEach(
460
- (k) => (payload as any)[k] === undefined && delete (payload as any)[k],
461
- );
462
-
463
- let res: Response;
464
- try {
465
- res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
466
- method: "POST",
467
- headers: {
468
- Authorization: `Bearer ${apiKey}`,
469
- "Content-Type": "application/json",
470
- },
471
- body: JSON.stringify(payload),
472
- signal: AbortSignal.timeout(45_000),
473
- });
474
- } catch (err) {
475
- if (isTimeoutError(err)) {
476
- throw new Error(
477
- "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.",
478
- );
479
- }
480
- throw err;
481
- }
482
-
483
- if (res.status === 401) {
484
- DesktopAuthManager.clearApiKey();
485
- throw new Error(
486
- "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
487
- );
488
- }
489
- if (!res.ok)
490
- throw new Error(formatBackendError(res.status, await res.text()));
491
-
492
- data = await res.json();
493
-
494
- if (res.status === 202 || (data && (data.status === "pending" || data.task_id) && data.type === undefined)) {
495
- const newTaskId = data.task_id;
496
- const completedData = await pollEmailTask(String(newTaskId), apiKey);
497
-
498
- if (!completedData) {
499
- const msg = `Task ${newTaskId} is still processing. Please call \`search_and_fetch_emails\` again immediately with task_id=${newTaskId} to monitor the progress.`;
500
- return {
501
- content: [{ type: "text", text: msg }],
502
- structuredContent: {
503
- status: "pending",
504
- task_id: String(newTaskId),
505
- message: msg,
506
- },
507
- };
508
- }
509
- data = completedData;
510
- }
511
- }
512
-
513
- const cache = loadIdCache();
514
-
515
- if (data.type === "previews") {
516
- const previews = data.previews || [];
517
- if (!previews.length)
518
- return {
519
- content: [
520
- {
521
- type: "text",
522
- text: "No emails found matching your search criteria.",
523
- },
524
- ],
525
- // Keep the UI channel populated (Python parity) — without it the
526
- // widget's tool-result handler bails and the skeleton spins forever.
527
- structuredContent: data,
528
- };
529
-
530
- const lines = [
531
- `Found ${previews.length} email(s). Here are the previews:`,
532
- "",
533
- ];
534
- for (const p of previews) {
535
- const shortId = minifyEmailId(p.id, cache, effectiveMailbox);
536
- const attFlag = p.has_attachments ? "📎 (Has Attachments)" : "";
537
- const unreadFlag = p.is_read === false ? "🟢 [UNREAD]" : "";
538
- lines.push(
539
- `- **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`,
540
- );
541
- }
542
-
543
- saveIdCache(cache);
544
-
545
- const limit: number = typeof args.limit === "number" ? args.limit : 10;
546
- const offset: number = typeof args.offset === "number" ? args.offset : 0;
547
- const pageHint =
548
- previews.length >= limit
549
- ? `\n*(If you need to see more results, call this tool again with offset=${offset + limit})*`
550
- : "";
551
-
552
- lines.push(
553
- "⚠️ **ACTION REQUIRED**: To read the full body of an email and download its attachments, call this tool again and provide the exact `email_id`." +
554
- pageHint,
555
- );
556
- return {
557
- content: [{ type: "text", text: lines.join("\n") }],
558
- structuredContent: data,
559
- };
560
- }
561
-
562
- if (data.type === "full_email") {
563
- const full = data.full_email || {};
564
- const shortTargetId = minifyEmailId(
565
- full.id || "unknown_id",
566
- cache,
567
- effectiveMailbox,
568
- );
569
-
570
- saveIdCache(cache);
571
-
572
- // Detect auto-escalation: the caller asked for previews (no email_id) but
573
- // the backend found exactly one match and returned a full email instead.
574
- // Flag it so the agent doesn't get blindsided by a wall of body text when
575
- // it asked for a list.
576
- const autoEscalated =
577
- !args.email_id &&
578
- (args.sender !== undefined ||
579
- args.subject !== undefined ||
580
- args.has_attachments !== undefined ||
581
- args.attachment_name !== undefined ||
582
- args.is_unread !== undefined ||
583
- args.days_ago !== undefined ||
584
- args.folder !== undefined);
585
-
586
- // Honor the requested working_directory by creating it (recursively) when
587
- // missing. Silently falling back to the system temp dir made agents in
588
- // sandboxed hosts believe the download failed (the reported paths pointed
589
- // at an inaccessible /tmp), triggering redundant re-search loops.
590
- let baseDir = tmpdir();
591
- let usedWorkingDirectory = false;
592
- let dirFallbackNote: string | null = null;
593
- if (args.working_directory) {
594
- try {
595
- mkdirSync(args.working_directory, { recursive: true });
596
- baseDir = args.working_directory;
597
- usedWorkingDirectory = true;
598
- } catch (e) {
599
- dirFallbackNote =
600
- `⚠️ **Attachment location notice**: the requested \`working_directory\` ` +
601
- `(\`${args.working_directory}\`) did not exist and could not be created ` +
602
- `(${(e as Error).message}). Any attachments were saved to the system temp ` +
603
- `directory instead — use the exact paths listed below; do NOT re-run the ` +
604
- `search expecting a different location.`;
605
- }
606
- }
607
- const saveDir = join(
608
- baseDir,
609
- usedWorkingDirectory ? "adeu_attachments" : "adeu_downloads",
610
- shortTargetId,
611
- );
612
- mkdirSync(saveDir, { recursive: true });
613
-
614
- interface SkippedAttachment {
615
- filename: string;
616
- size_bytes: number | null;
617
- reason: string;
618
- }
619
-
620
- async function processAttachments(
621
- msg: any,
622
- ): Promise<{ localFiles: string[]; skipped: SkippedAttachment[] }> {
623
- const localFiles: string[] = [];
624
- const skipped: SkippedAttachment[] = [];
625
- const maxBytes = maxAttachmentSizeMb * 1024 * 1024;
626
-
627
- for (const att of msg.attachments || []) {
628
- const filename = att.filename || "unnamed_file";
629
- const size: number | null =
630
- typeof att.size_bytes === "number" ? att.size_bytes : null;
631
-
632
- // Size cap: skip download but record it so the agent knows the file exists
633
- if (size != null && size > maxBytes) {
634
- skipped.push({
635
- filename,
636
- size_bytes: size,
637
- reason: `exceeds ${maxAttachmentSizeMb} MB cap`,
638
- });
639
- delete att.base64_data; // Drop payload from structured response too
640
- continue;
641
- }
642
-
643
- if (att.base64_data) {
644
- try {
645
- const filepath = getUniqueFilepath(saveDir, filename);
646
- writeFileSync(filepath, Buffer.from(att.base64_data, "base64"));
647
- localFiles.push(filepath);
648
- att.local_path = filepath; // For UI rendering (matches Python parity)
649
- delete att.base64_data; // Free memory
650
- } catch (e) {
651
- console.error(`Failed to save attachment ${filename}`, e);
652
- skipped.push({
653
- filename,
654
- size_bytes: size,
655
- reason: `download failed: ${(e as Error).message}`,
656
- });
657
- }
658
- }
659
- }
660
- return { localFiles, skipped };
661
- }
662
-
663
- const { localFiles: targetFiles, skipped: targetSkipped } =
664
- await processAttachments(full);
665
- const lines: string[] = [];
666
- if (autoEscalated) {
667
- lines.push(
668
- "_(Search returned exactly one result; auto-fetched full email below.)_\n",
669
- );
670
- }
671
- if (dirFallbackNote) {
672
- lines.push(dirFallbackNote + "\n");
673
- }
674
- lines.push(
675
- `# Email Thread: ${full.subject}`,
676
- "",
677
- "## Target Message (Newest):",
678
- `**From**: ${full.sender_name} <${full.sender_email}>`,
679
- `**Date**: ${full.received_datetime}`,
680
- );
681
-
682
- if (targetFiles.length) {
683
- lines.push("**Attachments Saved Locally**:");
684
- targetFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
685
- }
686
-
687
- if (targetSkipped.length) {
688
- lines.push(
689
- `**Attachments Skipped (not downloaded)** — pass \`max_attachment_size_mb\` to raise the ${maxAttachmentSizeMb} MB cap:`,
690
- );
691
- targetSkipped.forEach((s) =>
692
- lines.push(
693
- `- ⚠️ \`${s.filename}\` (${formatBytes(s.size_bytes)}, ${s.reason})`,
694
- ),
695
- );
696
- }
697
-
698
- const cleanBody = removeNestedQuotes(stripTags(full.body_html || ""));
699
- lines.push(`**Body**:\n\`\`\`\n${cleanBody}\n\`\`\`\n`);
700
-
701
- if (full.is_thread && full.messages?.length) {
702
- lines.push("## Previous Messages in Thread (Historical Context):");
703
- for (let i = 0; i < full.messages.length; i++) {
704
- const histMsg = full.messages[i];
705
- const { localFiles: histFiles, skipped: histSkipped } =
706
- await processAttachments(histMsg);
707
- lines.push(
708
- `### Message -${i + 1} (Older)\n**From**: ${histMsg.sender_name} <${histMsg.sender_email}>\n**Date**: ${histMsg.received_datetime}`,
709
- );
710
- if (histFiles.length) {
711
- lines.push("**Attachments Saved Locally**:");
712
- histFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
713
- }
714
- if (histSkipped.length) {
715
- lines.push(
716
- `**Attachments Skipped (not downloaded)** — pass \`max_attachment_size_mb\` — raise the cap:`,
717
- );
718
- histSkipped.forEach((s) =>
719
- lines.push(
720
- `- ⚠️ \`${s.filename}\` (${formatBytes(s.size_bytes)}, ${s.reason})`,
721
- ),
722
- );
723
- }
724
- lines.push(
725
- `**Body**:\n\`\`\`\n${removeNestedQuotes(stripTags(histMsg.body_html || ""))}\n\`\`\`\n`,
726
- );
727
- }
728
- }
729
-
730
- // --- Finding #9 downstream tool suggestions parity ---
731
- const hasAttachments =
732
- targetFiles.length > 0 ||
733
- (full.messages &&
734
- full.messages.some(
735
- (m: any) => m.attachments && m.attachments.length > 0,
736
- ));
737
-
738
- if (hasAttachments) {
739
- lines.push(
740
- "\n*You can now use tools like `read_docx`, `diff_docx_files`, or `finalize_document` on the local file paths listed under each message. " +
741
- "These paths are on the user's machine — pass them directly to those tools; your own sandbox/shell may not see them, and that does NOT mean the download failed.*",
742
- );
743
- }
744
-
745
- return {
746
- content: [{ type: "text", text: lines.join("\n") }],
747
- structuredContent: data,
748
- };
749
- }
750
-
751
- return {
752
- isError: true,
753
- content: [{ type: "text", text: "Unknown response format from backend." }],
754
- structuredContent: data,
755
- };
756
- }
757
-
758
- export async function create_email_draft(args: any): Promise<ToolResult> {
759
- const apiKey = await getCloudAuthToken();
760
- if (!args.reply_to_email_id && (!args.subject || !args.to_recipients)) {
761
- throw new Error(
762
- "You must provide either 'reply_to_email_id' OR both 'subject' and 'to_recipients'.",
763
- );
764
- }
765
-
766
- const formData = new FormData();
767
- formData.append("body_markdown", args.body_markdown);
768
-
769
- if (args.reply_to_email_id) {
770
- try {
771
- formData.append(
772
- "reply_to_email_id",
773
- resolveEmailId(args.reply_to_email_id),
774
- );
775
- } catch (err) {
776
- if (err instanceof StaleShortIdError) {
777
- return {
778
- isError: true,
779
- content: [{ type: "text", text: err.message }],
780
- };
781
- }
782
- throw err;
783
- }
784
- }
785
- if (args.subject) formData.append("subject", args.subject);
786
-
787
- // Replies inherit the mailbox the original email's short ID was harvested
788
- // from unless the caller overrides — same mailbox-scoping rule as fetches.
789
- let draftMailbox: string | undefined = args.mailbox_address;
790
- if (args.reply_to_email_id && !draftMailbox) {
791
- const cachedMailbox = resolveCachedMailbox(args.reply_to_email_id);
792
- if (cachedMailbox) draftMailbox = cachedMailbox;
793
- }
794
- if (draftMailbox) {
795
- formData.append("mailbox_address", draftMailbox);
796
- }
797
-
798
- if (args.to_recipients) {
799
- const recips =
800
- typeof args.to_recipients === "string"
801
- ? JSON.parse(args.to_recipients)
802
- : args.to_recipients;
803
- formData.append("to_recipients", JSON.stringify(recips));
804
- }
805
-
806
- if (args.attachment_paths) {
807
- const paths =
808
- typeof args.attachment_paths === "string"
809
- ? JSON.parse(args.attachment_paths)
810
- : args.attachment_paths;
811
- for (const p of paths) {
812
- const buf = readFileSync(p);
813
- const filename = p.split(/[/\\]/).pop();
814
- formData.append("files", new Blob([buf]), filename);
815
- }
816
- }
817
-
818
- let res: Response;
819
- try {
820
- res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
821
- method: "POST",
822
- headers: {
823
- Authorization: `Bearer ${apiKey}`,
824
- Accept: "application/json",
825
- },
826
- body: formData as any,
827
- signal: AbortSignal.timeout(90_000),
828
- });
829
- } catch (err) {
830
- if (isTimeoutError(err)) {
831
- throw new Error(
832
- "Draft creation timed out after 90s. If the draft includes large attachments, try splitting them across multiple drafts or omitting the largest files.",
833
- );
834
- }
835
- throw err;
836
- }
837
-
838
- if (res.status === 401) {
839
- DesktopAuthManager.clearApiKey();
840
- throw new Error(
841
- "Authentication expired. Please call `login_to_adeu_cloud`.",
842
- );
843
- }
844
- if (!res.ok)
845
- throw new Error(formatBackendError(res.status, await res.text()));
846
-
847
- const data: any = await res.json();
848
- return {
849
- content: [
850
- {
851
- type: "text",
852
- text: `Successfully created email draft! Draft ID: ${data.id}`,
853
- },
854
- ],
855
- };
856
- }
857
- export async function list_available_mailboxes(): Promise<ToolResult> {
858
- const apiKey = await getCloudAuthToken();
859
-
860
- let res: Response;
861
- try {
862
- res = await fetch(`${BACKEND_URL}/api/v1/users/me/shared-mailboxes`, {
863
- method: "GET",
864
- headers: {
865
- Authorization: `Bearer ${apiKey}`,
866
- Accept: "application/json",
867
- },
868
- signal: AbortSignal.timeout(15_000),
869
- });
870
- } catch (err) {
871
- if (isTimeoutError(err)) {
872
- throw new Error(
873
- "Listing mailboxes timed out after 15s. The Adeu backend may be temporarily unavailable; retry shortly.",
874
- );
875
- }
876
- throw err;
877
- }
878
-
879
- if (res.status === 401) {
880
- DesktopAuthManager.clearApiKey();
881
- throw new Error(
882
- "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
883
- );
884
- }
885
- if (!res.ok) {
886
- throw new Error(formatBackendError(res.status, await res.text()));
887
- }
888
-
889
- // FILE: node/packages/mcp-server/src/tools/email.ts
890
-
891
- const mailboxes: any[] = await res.json();
892
- if (!mailboxes.length) {
893
- return {
894
- content: [
895
- {
896
- type: "text",
897
- text: "No configured mailboxes found for your profile.",
898
- },
899
- ],
900
- };
901
- }
902
-
903
- // Sort alphabetically by email for deterministic ordering across clients.
904
- mailboxes.sort((a, b) =>
905
- (a.email_address ?? "")
906
- .toLowerCase()
907
- .localeCompare((b.email_address ?? "").toLowerCase()),
908
- );
909
-
910
- const lines = [
911
- "### Connected Mailboxes",
912
- "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:",
913
- "",
914
- ];
915
-
916
- for (const box of mailboxes) {
917
- lines.push(
918
- `- **${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}\``,
919
- );
920
- }
921
-
922
- return {
923
- content: [{ type: "text", text: lines.join("\n") }],
924
- };
925
- }