@adeu/mcp-server 1.20.0 → 1.21.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/index.js +49 -1062
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +46 -236
- package/src/response-builders.ts +5 -0
- package/src/shared.ts +0 -5
- package/dist/templates/email_ui.html +0 -770
- package/src/desktop-auth.ts +0 -127
- package/src/mcp.cloud.test.ts +0 -152
- package/src/templates/email_ui.html +0 -770
- package/src/tools/auth.ts +0 -57
- package/src/tools/email.test.ts +0 -697
- package/src/tools/email.ts +0 -925
package/src/tools/email.test.ts
DELETED
|
@@ -1,697 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
mkdtempSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
rmSync,
|
|
6
|
-
writeFileSync,
|
|
7
|
-
existsSync,
|
|
8
|
-
readFileSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { dirname, join } from "node:path";
|
|
11
|
-
import { homedir, tmpdir } from "node:os";
|
|
12
|
-
import {
|
|
13
|
-
search_and_fetch_emails,
|
|
14
|
-
list_available_mailboxes,
|
|
15
|
-
create_email_draft,
|
|
16
|
-
} from "./email.js";
|
|
17
|
-
|
|
18
|
-
// Mock the Auth module so tests bypass active browser logins
|
|
19
|
-
vi.mock("../desktop-auth.js", () => {
|
|
20
|
-
return {
|
|
21
|
-
getCloudAuthToken: vi.fn().mockResolvedValue("mock_token_abc"),
|
|
22
|
-
DesktopAuthManager: {
|
|
23
|
-
getApiKey: vi.fn().mockReturnValue("mock_token_abc"),
|
|
24
|
-
clearApiKey: vi.fn(),
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("Node Email Tools Finding #2 and Finding #6 tests", () => {
|
|
30
|
-
const originalFetch = global.fetch;
|
|
31
|
-
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
vi.clearAllMocks();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
afterEach(() => {
|
|
37
|
-
global.fetch = originalFetch;
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("Finding #6: Correctly handles stale msg_ short IDs inside tool boundary", async () => {
|
|
41
|
-
const result = await search_and_fetch_emails({
|
|
42
|
-
email_id: "msg_stale99",
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
expect(result.isError).toBe(true);
|
|
46
|
-
const text = result.content[0].text;
|
|
47
|
-
expect(text).toContain("is not in the local cache");
|
|
48
|
-
expect(text).toContain("evicted, or it came from a different machine");
|
|
49
|
-
expect(text).toContain("Re-run search_and_fetch_emails with filters");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("Finding #6: Maps backend Mailbox Not Found 404 error to Node boundary ToolError", async () => {
|
|
53
|
-
// Stub fetch to simulate a 404 response with Mailbox error body
|
|
54
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
55
|
-
ok: false,
|
|
56
|
-
status: 404,
|
|
57
|
-
text: async () =>
|
|
58
|
-
JSON.stringify({
|
|
59
|
-
detail: "Mailbox 'bogus@nowhere.invalid' not found.",
|
|
60
|
-
}),
|
|
61
|
-
} as Response);
|
|
62
|
-
|
|
63
|
-
await expect(
|
|
64
|
-
search_and_fetch_emails({ mailbox_address: "bogus@nowhere.invalid" }),
|
|
65
|
-
).rejects.toThrowError(
|
|
66
|
-
"Cloud search failed (HTTP 404): The mailbox 'bogus@nowhere.invalid' is not connected to your Adeu account. Call list_available_mailboxes to see valid mailbox addresses, then retry with one of those as `mailbox_address`.",
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("Finding #6: Maps backend Email Not Found 404 error to Node boundary ToolError", async () => {
|
|
71
|
-
// Stub fetch to simulate 404 for an invalid adeu_ ID (which bypasses local cache checks)
|
|
72
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
73
|
-
ok: false,
|
|
74
|
-
status: 404,
|
|
75
|
-
text: async () => JSON.stringify({ detail: "Email not found." }),
|
|
76
|
-
} as Response);
|
|
77
|
-
|
|
78
|
-
await expect(
|
|
79
|
-
search_and_fetch_emails({ email_id: "adeu_9999" }),
|
|
80
|
-
).rejects.toThrowError(
|
|
81
|
-
"Cloud search failed (HTTP 404): The email ID was not found. If this was a short ID (msg_*), it may have been evicted from the local cache or come from a different machine — re-run search_and_fetch_emails with filters to get a fresh ID. If it was an adeu_<numeric> or raw provider ID, verify it's correct.",
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("Finding #2: Asserts correct Markdown parity and Personal Mailbox fallback on list_available_mailboxes", async () => {
|
|
86
|
-
// Stub fetch to return one null-display mailbox and one alphabetical secondary mailbox
|
|
87
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
88
|
-
ok: true,
|
|
89
|
-
status: 200,
|
|
90
|
-
json: async () => [
|
|
91
|
-
{
|
|
92
|
-
email_address: "secondary@adeu.ai",
|
|
93
|
-
display_name: "Secondary Mailbox",
|
|
94
|
-
auto_process_enabled: false,
|
|
95
|
-
write_back_preference: "INTERNAL",
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
email_address: "primary@adeu.ai",
|
|
99
|
-
display_name: null, // Tests 'Personal Mailbox' fallback
|
|
100
|
-
auto_process_enabled: true,
|
|
101
|
-
write_back_preference: "DRAFT",
|
|
102
|
-
},
|
|
103
|
-
],
|
|
104
|
-
} as Response);
|
|
105
|
-
|
|
106
|
-
const result = await list_available_mailboxes();
|
|
107
|
-
expect(result.isError).toBeFalsy();
|
|
108
|
-
|
|
109
|
-
const output = result.content[0].text;
|
|
110
|
-
|
|
111
|
-
// Verify preamble parity
|
|
112
|
-
expect(output).toContain("### Connected Mailboxes");
|
|
113
|
-
expect(output).toContain(
|
|
114
|
-
"Below is the list of connected mailboxes you have access to.",
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
// Verify Fallback formatting
|
|
118
|
-
expect(output).toContain("**Personal Mailbox**");
|
|
119
|
-
|
|
120
|
-
// Verify deterministic alphabetical sorting by email address (primary before secondary)
|
|
121
|
-
const idxPrimary = output.indexOf("primary@adeu.ai");
|
|
122
|
-
const idxSecondary = output.indexOf("secondary@adeu.ai");
|
|
123
|
-
expect(idxPrimary).not.toBe(-1);
|
|
124
|
-
expect(idxSecondary).not.toBe(-1);
|
|
125
|
-
expect(idxPrimary).toBeLessThan(idxSecondary);
|
|
126
|
-
|
|
127
|
-
// Verify labels formatting matches Python exactly
|
|
128
|
-
expect(output).toContain("- **Email Address**:");
|
|
129
|
-
expect(output).toContain("- **Auto-Processing**:");
|
|
130
|
-
expect(output).toContain("- **Write-Back Mode**:");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("Finding #6: Gracefully maps generic, unmapped server errors on search_and_fetch_emails", async () => {
|
|
134
|
-
// Simulate generic HTTP 500 error
|
|
135
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
136
|
-
ok: false,
|
|
137
|
-
status: 500,
|
|
138
|
-
text: async () =>
|
|
139
|
-
JSON.stringify({ detail: "Database connection failed." }),
|
|
140
|
-
} as Response);
|
|
141
|
-
|
|
142
|
-
await expect(search_and_fetch_emails({ limit: 10 })).rejects.toThrowError(
|
|
143
|
-
"Cloud search failed (HTTP 500): Database connection failed.",
|
|
144
|
-
);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("Finding #6: Converts abort/timeout errors to actionable ToolErrors on search_and_fetch_emails", async () => {
|
|
148
|
-
// Simulate a native fetch Timeout/AbortError
|
|
149
|
-
const timeoutError = new Error("The operation was aborted.");
|
|
150
|
-
timeoutError.name = "AbortError";
|
|
151
|
-
global.fetch = vi.fn().mockRejectedValue(timeoutError);
|
|
152
|
-
|
|
153
|
-
await expect(search_and_fetch_emails({ limit: 10 })).rejects.toThrowError(
|
|
154
|
-
"Email search timed out after 45s. The mail provider (Outlook/Gmail) may be slow.",
|
|
155
|
-
);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("Findings #3, #9, and #11: Asserts preview pagination, auto-escalation note, and downstream tool suggests", async () => {
|
|
159
|
-
// --- Scenario 1: Previews listing with limit met (Finding #3 pagination hint) ---
|
|
160
|
-
const mockPreviewsPayload = {
|
|
161
|
-
type: "previews",
|
|
162
|
-
previews: [
|
|
163
|
-
{
|
|
164
|
-
id: "id1",
|
|
165
|
-
subject: "Subject 1",
|
|
166
|
-
sender_name: "Sender 1",
|
|
167
|
-
sender_email: "s1@adeu.ai",
|
|
168
|
-
received_datetime: "2026-01-01T12:00:00Z",
|
|
169
|
-
preview_text: "Text 1",
|
|
170
|
-
has_attachments: false,
|
|
171
|
-
is_read: true,
|
|
172
|
-
},
|
|
173
|
-
{
|
|
174
|
-
id: "id2",
|
|
175
|
-
subject: "Subject 2",
|
|
176
|
-
sender_name: "Sender 2",
|
|
177
|
-
sender_email: "s2@adeu.ai",
|
|
178
|
-
received_datetime: "2026-01-01T12:00:00Z",
|
|
179
|
-
preview_text: "Text 2",
|
|
180
|
-
has_attachments: false,
|
|
181
|
-
is_read: true,
|
|
182
|
-
},
|
|
183
|
-
],
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
187
|
-
ok: true,
|
|
188
|
-
status: 200,
|
|
189
|
-
json: async () => mockPreviewsPayload,
|
|
190
|
-
} as Response);
|
|
191
|
-
|
|
192
|
-
const resPreviews = await search_and_fetch_emails({
|
|
193
|
-
subject: "Invoice",
|
|
194
|
-
limit: 2,
|
|
195
|
-
offset: 0,
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
const previewsText = resPreviews.content[0].text;
|
|
199
|
-
expect(previewsText).toContain(
|
|
200
|
-
"*(If you need to see more results, call this tool again with offset=2)*",
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
// --- Scenario 2: Single result auto-escalation (Finding #11 banner notice) ---
|
|
204
|
-
const mockFullEmailPayload = {
|
|
205
|
-
type: "full_email",
|
|
206
|
-
full_email: {
|
|
207
|
-
id: "adeu_12345",
|
|
208
|
-
subject: "Contract Review Required",
|
|
209
|
-
sender_name: "Legal",
|
|
210
|
-
sender_email: "legal@adeu.ai",
|
|
211
|
-
received_datetime: "2026-01-01T12:00:00Z",
|
|
212
|
-
body_html: "<p>Please look at this document.</p>",
|
|
213
|
-
is_thread: false,
|
|
214
|
-
attachments: [],
|
|
215
|
-
},
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
219
|
-
ok: true,
|
|
220
|
-
status: 200,
|
|
221
|
-
json: async () => mockFullEmailPayload,
|
|
222
|
-
} as Response);
|
|
223
|
-
|
|
224
|
-
const resEscalation = await search_and_fetch_emails({
|
|
225
|
-
subject: "Contract Review Required",
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
const escalationText = resEscalation.content[0].text;
|
|
229
|
-
expect(
|
|
230
|
-
escalationText.startsWith(
|
|
231
|
-
"_(Search returned exactly one result; auto-fetched full email below.)_",
|
|
232
|
-
),
|
|
233
|
-
).toBe(true);
|
|
234
|
-
|
|
235
|
-
// --- Scenario 3: Suggestions for attachments (Finding #9 downstream hint) ---
|
|
236
|
-
const mockAttachmentsPayload = {
|
|
237
|
-
type: "full_email",
|
|
238
|
-
full_email: {
|
|
239
|
-
id: "adeu_12345",
|
|
240
|
-
subject: "Contract Attachment",
|
|
241
|
-
sender_name: "Legal",
|
|
242
|
-
sender_email: "legal@adeu.ai",
|
|
243
|
-
received_datetime: "2026-01-01T12:00:00Z",
|
|
244
|
-
body_html: "<p>Please see attachment.</p>",
|
|
245
|
-
is_thread: false,
|
|
246
|
-
attachments: [
|
|
247
|
-
{
|
|
248
|
-
filename: "draft_contract.docx",
|
|
249
|
-
size_bytes: 1024,
|
|
250
|
-
base64_data: Buffer.from("dummy docx contents").toString("base64"),
|
|
251
|
-
},
|
|
252
|
-
],
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
257
|
-
ok: true,
|
|
258
|
-
status: 200,
|
|
259
|
-
json: async () => mockAttachmentsPayload,
|
|
260
|
-
} as Response);
|
|
261
|
-
|
|
262
|
-
const resAttachments = await search_and_fetch_emails({
|
|
263
|
-
email_id: "adeu_12345",
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
const attachmentsText = resAttachments.content[0].text;
|
|
267
|
-
expect(attachmentsText).toContain(
|
|
268
|
-
"You can now use tools like `read_docx`, `diff_docx_files`, or `finalize_document`",
|
|
269
|
-
);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
describe("Stateful Polling symmetry with Validation", () => {
|
|
273
|
-
const sleepMock = vi.spyOn(global, "setTimeout");
|
|
274
|
-
|
|
275
|
-
beforeEach(() => {
|
|
276
|
-
vi.useFakeTimers();
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
afterEach(() => {
|
|
280
|
-
vi.useRealTimers();
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("should handle async task initiation upon searching and resolve when the task completes successfully", async () => {
|
|
284
|
-
let callCount = 0;
|
|
285
|
-
global.fetch = vi.fn().mockImplementation(async () => {
|
|
286
|
-
callCount++;
|
|
287
|
-
if (callCount === 1) {
|
|
288
|
-
return {
|
|
289
|
-
ok: true,
|
|
290
|
-
status: 202,
|
|
291
|
-
json: async () => ({
|
|
292
|
-
status: "pending",
|
|
293
|
-
task_id: "email_task_typescript_123",
|
|
294
|
-
message: "Queued",
|
|
295
|
-
}),
|
|
296
|
-
} as Response;
|
|
297
|
-
}
|
|
298
|
-
return {
|
|
299
|
-
ok: true,
|
|
300
|
-
status: 200,
|
|
301
|
-
json: async () => ({
|
|
302
|
-
status: "COMPLETED",
|
|
303
|
-
type: "previews",
|
|
304
|
-
previews: [],
|
|
305
|
-
}),
|
|
306
|
-
} as Response;
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
const promise = search_and_fetch_emails({ subject: "heavy search" });
|
|
310
|
-
promise.catch(() => {});
|
|
311
|
-
await vi.runAllTimersAsync();
|
|
312
|
-
|
|
313
|
-
const result = await promise;
|
|
314
|
-
|
|
315
|
-
expect(callCount).toBe(2);
|
|
316
|
-
expect(result.content[0].text).toContain("No emails found matching your search criteria.");
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("should handle async task initiation upon searching and return pending status on polling timeout (50s)", async () => {
|
|
320
|
-
let callCount = 0;
|
|
321
|
-
global.fetch = vi.fn().mockImplementation(async () => {
|
|
322
|
-
callCount++;
|
|
323
|
-
if (callCount === 1) {
|
|
324
|
-
return {
|
|
325
|
-
ok: true,
|
|
326
|
-
status: 202,
|
|
327
|
-
json: async () => ({
|
|
328
|
-
status: "pending",
|
|
329
|
-
task_id: "email_task_typescript_123",
|
|
330
|
-
message: "Queued",
|
|
331
|
-
}),
|
|
332
|
-
} as Response;
|
|
333
|
-
}
|
|
334
|
-
return {
|
|
335
|
-
ok: true,
|
|
336
|
-
status: 200,
|
|
337
|
-
json: async () => ({ status: "PENDING" }),
|
|
338
|
-
} as Response;
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
const promise = search_and_fetch_emails({ subject: "heavy search" });
|
|
342
|
-
promise.catch(() => {});
|
|
343
|
-
|
|
344
|
-
for (let i = 0; i < 10; i++) {
|
|
345
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
346
|
-
}
|
|
347
|
-
await vi.runAllTimersAsync();
|
|
348
|
-
|
|
349
|
-
const result = await promise;
|
|
350
|
-
|
|
351
|
-
expect(callCount).toBe(11);
|
|
352
|
-
expect(result.content[0].text).toContain("is still processing");
|
|
353
|
-
expect(result.content[0].text).toContain("task_id=email_task_typescript_123");
|
|
354
|
-
expect(result.structuredContent?.status).toBe("pending");
|
|
355
|
-
expect(result.structuredContent?.task_id).toBe("email_task_typescript_123");
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it("should poll and resolve when a task completes successfully", async () => {
|
|
359
|
-
let callCount = 0;
|
|
360
|
-
global.fetch = vi.fn().mockImplementation(async () => {
|
|
361
|
-
callCount++;
|
|
362
|
-
if (callCount === 1) {
|
|
363
|
-
return {
|
|
364
|
-
ok: true,
|
|
365
|
-
status: 200,
|
|
366
|
-
json: async () => ({ status: "PENDING" }),
|
|
367
|
-
} as Response;
|
|
368
|
-
}
|
|
369
|
-
return {
|
|
370
|
-
ok: true,
|
|
371
|
-
status: 200,
|
|
372
|
-
json: async () => ({
|
|
373
|
-
status: "COMPLETED",
|
|
374
|
-
type: "previews",
|
|
375
|
-
previews: [],
|
|
376
|
-
}),
|
|
377
|
-
} as Response;
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
|
|
381
|
-
promise.catch(() => {}); // Suppress Vitest's unhandled rejection warnings during timer advance
|
|
382
|
-
await vi.runAllTimersAsync();
|
|
383
|
-
|
|
384
|
-
const result = await promise;
|
|
385
|
-
|
|
386
|
-
expect(callCount).toBe(2);
|
|
387
|
-
expect(result.content[0].text).toContain("No emails found matching your search criteria.");
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
it("should throw standard ToolError on task failure", async () => {
|
|
391
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
392
|
-
ok: true,
|
|
393
|
-
status: 200,
|
|
394
|
-
json: async () => ({
|
|
395
|
-
status: "FAILED",
|
|
396
|
-
error: "API authorization revoked.",
|
|
397
|
-
}),
|
|
398
|
-
} as Response);
|
|
399
|
-
|
|
400
|
-
const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
|
|
401
|
-
const caughtPromise = promise.catch((err) => err);
|
|
402
|
-
await vi.runAllTimersAsync();
|
|
403
|
-
|
|
404
|
-
await expect(promise).rejects.toThrowError(
|
|
405
|
-
"Validation task failed on the server: API authorization revoked."
|
|
406
|
-
);
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
it("should gracefully return a pending status on polling timeout (50s)", async () => {
|
|
410
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
411
|
-
ok: true,
|
|
412
|
-
status: 200,
|
|
413
|
-
json: async () => ({ status: "PENDING" }),
|
|
414
|
-
} as Response);
|
|
415
|
-
|
|
416
|
-
const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
|
|
417
|
-
|
|
418
|
-
// Advance all 10 polling intervals
|
|
419
|
-
for (let i = 0; i < 10; i++) {
|
|
420
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
421
|
-
}
|
|
422
|
-
await vi.runAllTimersAsync();
|
|
423
|
-
|
|
424
|
-
const result = await promise;
|
|
425
|
-
expect(result.content[0].text).toContain("is still processing");
|
|
426
|
-
expect(result.content[0].text).toContain("task_id=email_task_typescript_123");
|
|
427
|
-
expect(result.structuredContent?.status).toBe("pending");
|
|
428
|
-
expect(result.structuredContent?.task_id).toBe("email_task_typescript_123");
|
|
429
|
-
});
|
|
430
|
-
});
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
describe("Working directory resolution (silent /tmp fallback fix)", () => {
|
|
434
|
-
const originalFetch = global.fetch;
|
|
435
|
-
|
|
436
|
-
afterEach(() => {
|
|
437
|
-
global.fetch = originalFetch;
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
function mockFullEmailFetch(emailId: string) {
|
|
441
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
442
|
-
ok: true,
|
|
443
|
-
status: 200,
|
|
444
|
-
json: async () => ({
|
|
445
|
-
type: "full_email",
|
|
446
|
-
full_email: {
|
|
447
|
-
id: emailId,
|
|
448
|
-
subject: "Attachment Delivery",
|
|
449
|
-
sender_name: "Legal",
|
|
450
|
-
sender_email: "legal@adeu.ai",
|
|
451
|
-
received_datetime: "2026-01-01T12:00:00Z",
|
|
452
|
-
body_html: "<p>See attachment.</p>",
|
|
453
|
-
is_thread: false,
|
|
454
|
-
attachments: [
|
|
455
|
-
{
|
|
456
|
-
filename: "questionnaire.docx",
|
|
457
|
-
size_bytes: 128,
|
|
458
|
-
base64_data: Buffer.from("questionnaire contents").toString(
|
|
459
|
-
"base64",
|
|
460
|
-
),
|
|
461
|
-
},
|
|
462
|
-
],
|
|
463
|
-
},
|
|
464
|
-
}),
|
|
465
|
-
} as Response);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
it("creates a missing working_directory recursively and saves attachments inside it", async () => {
|
|
469
|
-
const root = mkdtempSync(join(tmpdir(), "adeu-wd-test-"));
|
|
470
|
-
const requestedDir = join(root, "questionnaires", "nested");
|
|
471
|
-
try {
|
|
472
|
-
mockFullEmailFetch("adeu_777");
|
|
473
|
-
|
|
474
|
-
const result = await search_and_fetch_emails({
|
|
475
|
-
email_id: "adeu_777",
|
|
476
|
-
working_directory: requestedDir,
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
const text = result.content[0].text;
|
|
480
|
-
expect(existsSync(requestedDir)).toBe(true);
|
|
481
|
-
expect(text).not.toContain("Attachment location notice");
|
|
482
|
-
|
|
483
|
-
const savedPathMatch = text.match(/📎 `([^`]+)`/);
|
|
484
|
-
expect(savedPathMatch).not.toBeNull();
|
|
485
|
-
const savedPath = savedPathMatch![1];
|
|
486
|
-
expect(savedPath.startsWith(join(requestedDir, "adeu_attachments"))).toBe(
|
|
487
|
-
true,
|
|
488
|
-
);
|
|
489
|
-
expect(readFileSync(savedPath, "utf-8")).toBe("questionnaire contents");
|
|
490
|
-
} finally {
|
|
491
|
-
rmSync(root, { recursive: true, force: true });
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it("falls back to the temp dir WITH an explicit notice when working_directory cannot be created", async () => {
|
|
496
|
-
const root = mkdtempSync(join(tmpdir(), "adeu-wd-test-"));
|
|
497
|
-
const blockerFile = join(root, "blocker.txt");
|
|
498
|
-
writeFileSync(blockerFile, "not a directory");
|
|
499
|
-
try {
|
|
500
|
-
mockFullEmailFetch("adeu_778");
|
|
501
|
-
|
|
502
|
-
const result = await search_and_fetch_emails({
|
|
503
|
-
email_id: "adeu_778",
|
|
504
|
-
working_directory: join(blockerFile, "sub"),
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
const text = result.content[0].text;
|
|
508
|
-
expect(text).toContain("Attachment location notice");
|
|
509
|
-
expect(text).toContain("do NOT re-run the search");
|
|
510
|
-
|
|
511
|
-
const savedPathMatch = text.match(/📎 `([^`]+)`/);
|
|
512
|
-
expect(savedPathMatch).not.toBeNull();
|
|
513
|
-
const savedPath = savedPathMatch![1];
|
|
514
|
-
expect(savedPath.startsWith(join(tmpdir(), "adeu_downloads"))).toBe(true);
|
|
515
|
-
expect(existsSync(savedPath)).toBe(true);
|
|
516
|
-
|
|
517
|
-
rmSync(dirname(savedPath), { recursive: true, force: true });
|
|
518
|
-
} finally {
|
|
519
|
-
rmSync(root, { recursive: true, force: true });
|
|
520
|
-
}
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
it("returns structuredContent for empty preview results so the UI can dismiss its skeleton", async () => {
|
|
524
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
525
|
-
ok: true,
|
|
526
|
-
status: 200,
|
|
527
|
-
json: async () => ({ type: "previews", previews: [] }),
|
|
528
|
-
} as Response);
|
|
529
|
-
|
|
530
|
-
const result = await search_and_fetch_emails({ subject: "nothing matches" });
|
|
531
|
-
expect(result.content[0].text).toContain(
|
|
532
|
-
"No emails found matching your search criteria.",
|
|
533
|
-
);
|
|
534
|
-
expect(result.structuredContent).toEqual({ type: "previews", previews: [] });
|
|
535
|
-
});
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
describe("Mailbox-aware short ID cache", () => {
|
|
539
|
-
const originalFetch = global.fetch;
|
|
540
|
-
|
|
541
|
-
afterEach(() => {
|
|
542
|
-
global.fetch = originalFetch;
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
it("re-applies the search's mailbox_address when fetching by short ID without one", async () => {
|
|
546
|
-
const fetchMock = vi.fn();
|
|
547
|
-
global.fetch = fetchMock;
|
|
548
|
-
|
|
549
|
-
fetchMock.mockResolvedValueOnce({
|
|
550
|
-
ok: true,
|
|
551
|
-
status: 200,
|
|
552
|
-
json: async () => ({
|
|
553
|
-
type: "previews",
|
|
554
|
-
previews: [
|
|
555
|
-
{
|
|
556
|
-
id: "AAMkAD_shared_item_1",
|
|
557
|
-
subject: "Questionnaire",
|
|
558
|
-
sender_name: "Abo Shoten",
|
|
559
|
-
sender_email: "ops@aboshoten.example",
|
|
560
|
-
received_datetime: "2026-07-07T09:00:00Z",
|
|
561
|
-
preview_text: "Please fill in",
|
|
562
|
-
has_attachments: true,
|
|
563
|
-
is_read: false,
|
|
564
|
-
},
|
|
565
|
-
{
|
|
566
|
-
id: "AAMkAD_shared_item_2",
|
|
567
|
-
subject: "Other mail",
|
|
568
|
-
sender_name: "Abo Shoten",
|
|
569
|
-
sender_email: "ops@aboshoten.example",
|
|
570
|
-
received_datetime: "2026-07-07T09:01:00Z",
|
|
571
|
-
preview_text: "Something else",
|
|
572
|
-
has_attachments: false,
|
|
573
|
-
is_read: true,
|
|
574
|
-
},
|
|
575
|
-
],
|
|
576
|
-
}),
|
|
577
|
-
} as Response);
|
|
578
|
-
|
|
579
|
-
const searchRes = await search_and_fetch_emails({
|
|
580
|
-
subject: "Questionnaire",
|
|
581
|
-
mailbox_address: "risto.kariranta@ahti.io",
|
|
582
|
-
});
|
|
583
|
-
const shortIdMatch = searchRes.content[0].text.match(/msg_[0-9a-f]{6}/);
|
|
584
|
-
expect(shortIdMatch).not.toBeNull();
|
|
585
|
-
const shortId = shortIdMatch![0];
|
|
586
|
-
|
|
587
|
-
fetchMock.mockResolvedValueOnce({
|
|
588
|
-
ok: true,
|
|
589
|
-
status: 200,
|
|
590
|
-
json: async () => ({
|
|
591
|
-
type: "full_email",
|
|
592
|
-
full_email: {
|
|
593
|
-
id: "AAMkAD_shared_item_1",
|
|
594
|
-
subject: "Questionnaire",
|
|
595
|
-
sender_name: "Abo Shoten",
|
|
596
|
-
sender_email: "ops@aboshoten.example",
|
|
597
|
-
received_datetime: "2026-07-07T09:00:00Z",
|
|
598
|
-
body_html: "<p>Please fill in.</p>",
|
|
599
|
-
is_thread: false,
|
|
600
|
-
attachments: [],
|
|
601
|
-
},
|
|
602
|
-
}),
|
|
603
|
-
} as Response);
|
|
604
|
-
|
|
605
|
-
await search_and_fetch_emails({ email_id: shortId });
|
|
606
|
-
|
|
607
|
-
const fetchBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
608
|
-
expect(fetchBody.email_id).toBe("AAMkAD_shared_item_1");
|
|
609
|
-
expect(fetchBody.mailbox_address).toBe("risto.kariranta@ahti.io");
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
it("still resolves legacy plain-string cache entries without injecting a mailbox", async () => {
|
|
613
|
-
// Seed a legacy-format entry directly into the cache file (merge, don't clobber).
|
|
614
|
-
const cachePath = join(homedir(), ".adeu", "mcp_id_cache.json");
|
|
615
|
-
mkdirSync(join(homedir(), ".adeu"), { recursive: true });
|
|
616
|
-
let existing: Record<string, unknown> = {};
|
|
617
|
-
try {
|
|
618
|
-
existing = JSON.parse(readFileSync(cachePath, "utf-8"));
|
|
619
|
-
} catch {
|
|
620
|
-
/* no cache yet */
|
|
621
|
-
}
|
|
622
|
-
existing["msg_leg01"] = "raw_provider_id_123";
|
|
623
|
-
writeFileSync(cachePath, JSON.stringify(existing));
|
|
624
|
-
|
|
625
|
-
const fetchMock = vi.fn().mockResolvedValue({
|
|
626
|
-
ok: true,
|
|
627
|
-
status: 200,
|
|
628
|
-
json: async () => ({
|
|
629
|
-
type: "full_email",
|
|
630
|
-
full_email: {
|
|
631
|
-
id: "raw_provider_id_123",
|
|
632
|
-
subject: "Legacy",
|
|
633
|
-
sender_name: "Old Cache",
|
|
634
|
-
sender_email: "old@cache.example",
|
|
635
|
-
received_datetime: "2026-07-07T09:00:00Z",
|
|
636
|
-
body_html: "<p>hi</p>",
|
|
637
|
-
is_thread: false,
|
|
638
|
-
attachments: [],
|
|
639
|
-
},
|
|
640
|
-
}),
|
|
641
|
-
} as Response);
|
|
642
|
-
global.fetch = fetchMock;
|
|
643
|
-
|
|
644
|
-
await search_and_fetch_emails({ email_id: "msg_leg01" });
|
|
645
|
-
|
|
646
|
-
const fetchBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
647
|
-
expect(fetchBody.email_id).toBe("raw_provider_id_123");
|
|
648
|
-
expect(fetchBody.mailbox_address).toBeUndefined();
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
it("maps known errors to recovery hints when an async task fails", async () => {
|
|
652
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
653
|
-
ok: true,
|
|
654
|
-
status: 200,
|
|
655
|
-
json: async () => ({ status: "FAILED", error: "Email not found." }),
|
|
656
|
-
} as Response);
|
|
657
|
-
|
|
658
|
-
await expect(
|
|
659
|
-
search_and_fetch_emails({ task_id: "email_task_777" }),
|
|
660
|
-
).rejects.toThrowError(
|
|
661
|
-
/re-run search_and_fetch_emails with filters[\s\S]*mailbox_address/,
|
|
662
|
-
);
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
it("re-applies the cached mailbox when replying to a short ID without one", async () => {
|
|
666
|
-
// Seed a new-format entry directly into the cache file (merge, don't clobber).
|
|
667
|
-
const cachePath = join(homedir(), ".adeu", "mcp_id_cache.json");
|
|
668
|
-
mkdirSync(join(homedir(), ".adeu"), { recursive: true });
|
|
669
|
-
let existing: Record<string, unknown> = {};
|
|
670
|
-
try {
|
|
671
|
-
existing = JSON.parse(readFileSync(cachePath, "utf-8"));
|
|
672
|
-
} catch {
|
|
673
|
-
/* no cache yet */
|
|
674
|
-
}
|
|
675
|
-
existing["msg_rep01"] = {
|
|
676
|
-
id: "AAMkAD_reply_item_1",
|
|
677
|
-
mailbox: "sales@ahti.io",
|
|
678
|
-
};
|
|
679
|
-
writeFileSync(cachePath, JSON.stringify(existing));
|
|
680
|
-
|
|
681
|
-
const fetchMock = vi.fn().mockResolvedValue({
|
|
682
|
-
ok: true,
|
|
683
|
-
status: 200,
|
|
684
|
-
json: async () => ({ id: "draft_1" }),
|
|
685
|
-
} as Response);
|
|
686
|
-
global.fetch = fetchMock;
|
|
687
|
-
|
|
688
|
-
await create_email_draft({
|
|
689
|
-
reply_to_email_id: "msg_rep01",
|
|
690
|
-
body_markdown: "Thanks, will do!",
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
const formData = fetchMock.mock.calls[0][1].body as FormData;
|
|
694
|
-
expect(formData.get("reply_to_email_id")).toBe("AAMkAD_reply_item_1");
|
|
695
|
-
expect(formData.get("mailbox_address")).toBe("sales@ahti.io");
|
|
696
|
-
});
|
|
697
|
-
});
|