@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.
- package/dist/assets/adeu.svg +3 -0
- package/dist/assets/logo.png +0 -0
- package/dist/assets/marked.min.js +69 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +904 -447
- package/dist/index.js.map +1 -1
- package/dist/templates/email_ui.html +667 -0
- package/dist/templates/markdown_ui.html +745 -0
- package/package.json +4 -2
- package/src/assets/adeu.svg +3 -0
- package/src/assets/logo.png +0 -0
- package/src/assets/marked.min.js +69 -0
- package/src/formatter.test.ts +64 -0
- package/src/index.ts +577 -407
- package/src/mcp.cloud.test.ts +13 -0
- package/src/response-builders.ts +111 -50
- package/src/templates/email_ui.html +667 -0
- package/src/templates/markdown_ui.html +745 -0
- package/src/tools/auth.ts +1 -0
- package/src/tools/email.test.ts +258 -0
- package/src/tools/email.ts +491 -54
- package/tsup.config.ts +35 -11
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,
|
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
208
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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(
|
|
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
|
+
}
|