@adeu/mcp-server 1.18.2 → 1.18.5
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/README.md +16 -1
- package/dist/index.js +75 -28
- package/dist/index.js.map +1 -1
- package/dist/templates/email_ui.html +106 -3
- package/package.json +2 -2
- package/src/index.ts +11 -3
- package/src/parity_live.test.ts +8 -3
- package/src/templates/email_ui.html +106 -3
- package/src/tools/email.test.ts +281 -1
- package/src/tools/email.ts +112 -32
|
@@ -348,6 +348,51 @@
|
|
|
348
348
|
background-position: -200% 0;
|
|
349
349
|
}
|
|
350
350
|
}
|
|
351
|
+
|
|
352
|
+
/* --- ASYNC / PENDING STATE --- */
|
|
353
|
+
.pending-state {
|
|
354
|
+
display: flex;
|
|
355
|
+
align-items: center;
|
|
356
|
+
gap: 16px;
|
|
357
|
+
padding: 24px;
|
|
358
|
+
border: 1px solid var(--border-card);
|
|
359
|
+
border-radius: 8px;
|
|
360
|
+
background: var(--bg-card);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.pending-spinner {
|
|
364
|
+
width: 28px;
|
|
365
|
+
height: 28px;
|
|
366
|
+
flex-shrink: 0;
|
|
367
|
+
border-radius: 50%;
|
|
368
|
+
border: 3px solid var(--border-card);
|
|
369
|
+
border-top-color: var(--brand-sand);
|
|
370
|
+
animation: pending-spin 0.9s linear infinite;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@keyframes pending-spin {
|
|
374
|
+
to {
|
|
375
|
+
transform: rotate(360deg);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.pending-title {
|
|
380
|
+
font-weight: 600;
|
|
381
|
+
color: var(--text-heading);
|
|
382
|
+
margin-bottom: 4px;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.pending-message {
|
|
386
|
+
font-size: 0.9rem;
|
|
387
|
+
opacity: 0.85;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.pending-task {
|
|
391
|
+
margin-top: 6px;
|
|
392
|
+
font-size: 0.8rem;
|
|
393
|
+
opacity: 0.7;
|
|
394
|
+
font-family: ui-monospace, Consolas, monospace;
|
|
395
|
+
}
|
|
351
396
|
</style>
|
|
352
397
|
</head>
|
|
353
398
|
|
|
@@ -559,6 +604,45 @@
|
|
|
559
604
|
wrapper.innerHTML = threadHtml;
|
|
560
605
|
return wrapper;
|
|
561
606
|
}
|
|
607
|
+
function renderPending(data) {
|
|
608
|
+
const message = data.message || 'The mailbox search is still running on the server. The assistant will check progress again shortly.';
|
|
609
|
+
const taskLine = data.task_id
|
|
610
|
+
? `<div class="pending-task">Task: ${escapeHtml(data.task_id)}</div>`
|
|
611
|
+
: '';
|
|
612
|
+
return `
|
|
613
|
+
<div class="pending-state">
|
|
614
|
+
<div class="pending-spinner"></div>
|
|
615
|
+
<div class="pending-info">
|
|
616
|
+
<div class="pending-title">Still processing…</div>
|
|
617
|
+
<div class="pending-message">${escapeHtml(message)}</div>
|
|
618
|
+
${taskLine}
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Large mailboxes can keep the backend polling for a minute or more before
|
|
625
|
+
// the first tool result arrives. Tell the human the search is still running
|
|
626
|
+
// rather than leaving a bare skeleton with no sign of progress.
|
|
627
|
+
let slowLoadStage2 = null;
|
|
628
|
+
const slowLoadNotice = setTimeout(() => {
|
|
629
|
+
if (appDiv.querySelector('.skeleton')) {
|
|
630
|
+
const notice = document.createElement('div');
|
|
631
|
+
notice.className = 'empty-state';
|
|
632
|
+
notice.textContent = 'Still working — large mailbox searches can take a minute or two…';
|
|
633
|
+
appDiv.appendChild(notice);
|
|
634
|
+
triggerResize();
|
|
635
|
+
// If no result ever arrives (e.g. the tool call errored and the host
|
|
636
|
+
// never forwards a tool-result), stop implying progress.
|
|
637
|
+
slowLoadStage2 = setTimeout(() => {
|
|
638
|
+
if (appDiv.querySelector('.skeleton')) {
|
|
639
|
+
notice.textContent = 'This is taking longer than expected — the search may have failed. Check the chat for details.';
|
|
640
|
+
triggerResize();
|
|
641
|
+
}
|
|
642
|
+
}, 150000);
|
|
643
|
+
}
|
|
644
|
+
}, 20000);
|
|
645
|
+
|
|
562
646
|
window.addEventListener("message", (event) => {
|
|
563
647
|
try {
|
|
564
648
|
if (!event.data || event.data.jsonrpc !== "2.0") return;
|
|
@@ -570,13 +654,32 @@
|
|
|
570
654
|
}
|
|
571
655
|
|
|
572
656
|
if (msg.method === "ui/notifications/tool-result") {
|
|
657
|
+
clearTimeout(slowLoadNotice);
|
|
658
|
+
if (slowLoadStage2) clearTimeout(slowLoadStage2);
|
|
573
659
|
const result = msg.params;
|
|
574
|
-
|
|
660
|
+
// The MCP wire format is camelCase `structuredContent`, but tolerate
|
|
661
|
+
// snake_case in case a host forwards the Python server's payload unnormalized.
|
|
662
|
+
const data = result ? (result.structuredContent || result.structured_content) : null;
|
|
663
|
+
|
|
664
|
+
if (!data) {
|
|
665
|
+
// Error results and text-only results carry no structured payload.
|
|
666
|
+
// Never leave the skeleton spinning — surface whatever text we have.
|
|
667
|
+
const textParts = (result && Array.isArray(result.content))
|
|
668
|
+
? result.content.filter(c => c && c.type === "text" && c.text).map(c => c.text)
|
|
669
|
+
: [];
|
|
670
|
+
const fallbackText = textParts.join("\n").trim();
|
|
671
|
+
appDiv.innerHTML = `<div class="empty-state" style="white-space: pre-wrap;">${escapeHtml(fallbackText || 'The email tool returned no displayable data. Check the chat for details.')}</div>`;
|
|
672
|
+
triggerResize();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
575
675
|
|
|
576
|
-
const data = result.structuredContent;
|
|
577
676
|
appDiv.innerHTML = ''; // Clear skeleton
|
|
578
677
|
|
|
579
|
-
if (data.
|
|
678
|
+
if (data.status === "pending") {
|
|
679
|
+
appDiv.innerHTML = renderPending(data);
|
|
680
|
+
setTimeout(triggerResize, 50);
|
|
681
|
+
}
|
|
682
|
+
else if (data.type === "previews") {
|
|
580
683
|
appDiv.innerHTML = renderPreviews(data.previews);
|
|
581
684
|
setTimeout(triggerResize, 50);
|
|
582
685
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeu/mcp-server",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.5",
|
|
4
4
|
"description": "",
|
|
5
5
|
"mcpName": "ai.adeu/adeu",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"type": "module",
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@adeu/core": "^1.18.
|
|
34
|
+
"@adeu/core": "^1.18.5",
|
|
35
35
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
36
36
|
"@modelcontextprotocol/ext-apps": "^1.0.0",
|
|
37
37
|
"zod": "^3.23.8"
|
package/src/index.ts
CHANGED
|
@@ -847,11 +847,11 @@ if (!isDocxOnly) {
|
|
|
847
847
|
"2. Fetch mode (with `email_id`): returns the full email body, thread history, and downloads attachments under `max_attachment_size_mb` to the local disk.\n\n" +
|
|
848
848
|
"AUTO-ESCALATION: If a search returns exactly one preview, the backend automatically fetches the full email in the same call. Plan around the response shape — check the `type` field (`previews` vs `full_email`) before assuming.\n\n" +
|
|
849
849
|
"EMAIL ID FORMATS (`email_id` parameter accepts any of):\n" +
|
|
850
|
-
"- `msg_<6 chars>` — short ID returned by previews on THIS machine. NOT portable across machines or sessions; the local cache holds the most recent 1000. If you reference one that's been evicted, the tool returns a StaleShortIdError telling you to re-search
|
|
850
|
+
"- `msg_<6 chars>` — short ID returned by previews on THIS machine. NOT portable across machines or sessions; the local cache holds the most recent 1000. If you reference one that's been evicted, the tool returns a StaleShortIdError telling you to re-search. The mailbox used in the original search is remembered with the short ID and re-applied automatically when you fetch or reply without specifying `mailbox_address`.\n" +
|
|
851
851
|
"- `adeu_<numeric>` — server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n" +
|
|
852
852
|
"- Raw provider ID (Gmail/Outlook native ID) — works if you have it, but you usually won't.\n\n" +
|
|
853
853
|
"FOLDER DEFAULT: omitting `folder` searches the Inbox only (matching what the user sees in their mail client). Use `folder='sent'` for sent items, `folder='all'` to include Deleted Items, Drafts, and other folders.\n\n" +
|
|
854
|
-
"ATTACHMENTS: attachments larger than `max_attachment_size_mb` (default 10) are listed in the response but NOT downloaded — raise the cap if you need them. Always set `working_directory` when calling from a project so attachments land alongside the user's other files.",
|
|
854
|
+
"ATTACHMENTS: attachments larger than `max_attachment_size_mb` (default 10) are listed in the response but NOT downloaded — raise the cap if you need them. Always set `working_directory` when calling from a project so attachments land alongside the user's other files; the directory is created automatically if it does not exist. This directory path refers to the user's native operating system, not the LLM's sandbox environment.",
|
|
855
855
|
inputSchema: z.object({
|
|
856
856
|
reasoning: z
|
|
857
857
|
.string()
|
|
@@ -868,7 +868,15 @@ if (!isDocxOnly) {
|
|
|
868
868
|
limit: z.coerce.number().default(10),
|
|
869
869
|
offset: z.coerce.number().default(0),
|
|
870
870
|
email_id: z.string().optional(),
|
|
871
|
-
working_directory: z
|
|
871
|
+
working_directory: z
|
|
872
|
+
.string()
|
|
873
|
+
.optional()
|
|
874
|
+
.describe(
|
|
875
|
+
"Optional. The current working directory of the project or task. " +
|
|
876
|
+
"If provided, attachments will be saved here under an 'adeu_attachments' subfolder; " +
|
|
877
|
+
"the directory is created automatically if it does not exist. " +
|
|
878
|
+
"If omitted, attachments are saved to the system temp directory.",
|
|
879
|
+
),
|
|
872
880
|
mailbox_address: z
|
|
873
881
|
.string()
|
|
874
882
|
.optional()
|
package/src/parity_live.test.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { spawn, ChildProcess } from "node:child_process";
|
|
|
4
4
|
import { resolve, join } from "node:path";
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
|
|
7
|
+
// Each Python CLI invocation spawns a fresh interpreter costing ~3-5s on
|
|
8
|
+
// Windows even when warm, so tests that shell out to Python need more than
|
|
9
|
+
// vitest's 5s default timeout.
|
|
10
|
+
const PYTHON_PARITY_TIMEOUT_MS = 60_000;
|
|
11
|
+
|
|
7
12
|
describe("Parity Live Server Integration Verification", () => {
|
|
8
13
|
let serverProc: ChildProcess;
|
|
9
14
|
const fixturePath = resolve(
|
|
@@ -112,7 +117,7 @@ describe("Parity Live Server Integration Verification", () => {
|
|
|
112
117
|
expect(res.result.content[0].text).not.toContain("[Debug] build=");
|
|
113
118
|
});
|
|
114
119
|
|
|
115
|
-
it("GAP 2 (Live): process_document_batch modify straddling deleted text returns actionable deletion error", async () => {
|
|
120
|
+
it("GAP 2 (Live): process_document_batch modify straddling deleted text returns actionable deletion error", { timeout: PYTHON_PARITY_TIMEOUT_MS }, async () => {
|
|
116
121
|
const outPath = join(
|
|
117
122
|
resolve(__dirname, "../../../../tmp"),
|
|
118
123
|
`live_gap2_out_${Date.now()}.docx`,
|
|
@@ -197,7 +202,7 @@ describe("Parity Live Server Integration Verification", () => {
|
|
|
197
202
|
}
|
|
198
203
|
});
|
|
199
204
|
|
|
200
|
-
it("GAP 1 (Live): read_docx mode=outline heading count and content parity with Python", async () => {
|
|
205
|
+
it("GAP 1 (Live): read_docx mode=outline heading count and content parity with Python", { timeout: PYTHON_PARITY_TIMEOUT_MS }, async () => {
|
|
201
206
|
const gap1FixturePath = resolve(
|
|
202
207
|
__dirname,
|
|
203
208
|
"../tests/fixtures/gap1_deleted_row_repro.docx",
|
|
@@ -288,7 +293,7 @@ describe("Parity Live Server Integration Verification", () => {
|
|
|
288
293
|
expect(nodeHeadingsDirty).toEqual(pythonHeadingsDirty);
|
|
289
294
|
});
|
|
290
295
|
|
|
291
|
-
it("GAP 1 - Style Def (Live): style-definition outlineLvl on non-heading style classification parity", async () => {
|
|
296
|
+
it("GAP 1 - Style Def (Live): style-definition outlineLvl on non-heading style classification parity", { timeout: PYTHON_PARITY_TIMEOUT_MS }, async () => {
|
|
292
297
|
const gap1FixturePath = resolve(
|
|
293
298
|
__dirname,
|
|
294
299
|
"../tests/fixtures/gap1_minimal_repro.docx",
|
|
@@ -348,6 +348,51 @@
|
|
|
348
348
|
background-position: -200% 0;
|
|
349
349
|
}
|
|
350
350
|
}
|
|
351
|
+
|
|
352
|
+
/* --- ASYNC / PENDING STATE --- */
|
|
353
|
+
.pending-state {
|
|
354
|
+
display: flex;
|
|
355
|
+
align-items: center;
|
|
356
|
+
gap: 16px;
|
|
357
|
+
padding: 24px;
|
|
358
|
+
border: 1px solid var(--border-card);
|
|
359
|
+
border-radius: 8px;
|
|
360
|
+
background: var(--bg-card);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.pending-spinner {
|
|
364
|
+
width: 28px;
|
|
365
|
+
height: 28px;
|
|
366
|
+
flex-shrink: 0;
|
|
367
|
+
border-radius: 50%;
|
|
368
|
+
border: 3px solid var(--border-card);
|
|
369
|
+
border-top-color: var(--brand-sand);
|
|
370
|
+
animation: pending-spin 0.9s linear infinite;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@keyframes pending-spin {
|
|
374
|
+
to {
|
|
375
|
+
transform: rotate(360deg);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.pending-title {
|
|
380
|
+
font-weight: 600;
|
|
381
|
+
color: var(--text-heading);
|
|
382
|
+
margin-bottom: 4px;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.pending-message {
|
|
386
|
+
font-size: 0.9rem;
|
|
387
|
+
opacity: 0.85;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.pending-task {
|
|
391
|
+
margin-top: 6px;
|
|
392
|
+
font-size: 0.8rem;
|
|
393
|
+
opacity: 0.7;
|
|
394
|
+
font-family: ui-monospace, Consolas, monospace;
|
|
395
|
+
}
|
|
351
396
|
</style>
|
|
352
397
|
</head>
|
|
353
398
|
|
|
@@ -559,6 +604,45 @@
|
|
|
559
604
|
wrapper.innerHTML = threadHtml;
|
|
560
605
|
return wrapper;
|
|
561
606
|
}
|
|
607
|
+
function renderPending(data) {
|
|
608
|
+
const message = data.message || 'The mailbox search is still running on the server. The assistant will check progress again shortly.';
|
|
609
|
+
const taskLine = data.task_id
|
|
610
|
+
? `<div class="pending-task">Task: ${escapeHtml(data.task_id)}</div>`
|
|
611
|
+
: '';
|
|
612
|
+
return `
|
|
613
|
+
<div class="pending-state">
|
|
614
|
+
<div class="pending-spinner"></div>
|
|
615
|
+
<div class="pending-info">
|
|
616
|
+
<div class="pending-title">Still processing…</div>
|
|
617
|
+
<div class="pending-message">${escapeHtml(message)}</div>
|
|
618
|
+
${taskLine}
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Large mailboxes can keep the backend polling for a minute or more before
|
|
625
|
+
// the first tool result arrives. Tell the human the search is still running
|
|
626
|
+
// rather than leaving a bare skeleton with no sign of progress.
|
|
627
|
+
let slowLoadStage2 = null;
|
|
628
|
+
const slowLoadNotice = setTimeout(() => {
|
|
629
|
+
if (appDiv.querySelector('.skeleton')) {
|
|
630
|
+
const notice = document.createElement('div');
|
|
631
|
+
notice.className = 'empty-state';
|
|
632
|
+
notice.textContent = 'Still working — large mailbox searches can take a minute or two…';
|
|
633
|
+
appDiv.appendChild(notice);
|
|
634
|
+
triggerResize();
|
|
635
|
+
// If no result ever arrives (e.g. the tool call errored and the host
|
|
636
|
+
// never forwards a tool-result), stop implying progress.
|
|
637
|
+
slowLoadStage2 = setTimeout(() => {
|
|
638
|
+
if (appDiv.querySelector('.skeleton')) {
|
|
639
|
+
notice.textContent = 'This is taking longer than expected — the search may have failed. Check the chat for details.';
|
|
640
|
+
triggerResize();
|
|
641
|
+
}
|
|
642
|
+
}, 150000);
|
|
643
|
+
}
|
|
644
|
+
}, 20000);
|
|
645
|
+
|
|
562
646
|
window.addEventListener("message", (event) => {
|
|
563
647
|
try {
|
|
564
648
|
if (!event.data || event.data.jsonrpc !== "2.0") return;
|
|
@@ -570,13 +654,32 @@
|
|
|
570
654
|
}
|
|
571
655
|
|
|
572
656
|
if (msg.method === "ui/notifications/tool-result") {
|
|
657
|
+
clearTimeout(slowLoadNotice);
|
|
658
|
+
if (slowLoadStage2) clearTimeout(slowLoadStage2);
|
|
573
659
|
const result = msg.params;
|
|
574
|
-
|
|
660
|
+
// The MCP wire format is camelCase `structuredContent`, but tolerate
|
|
661
|
+
// snake_case in case a host forwards the Python server's payload unnormalized.
|
|
662
|
+
const data = result ? (result.structuredContent || result.structured_content) : null;
|
|
663
|
+
|
|
664
|
+
if (!data) {
|
|
665
|
+
// Error results and text-only results carry no structured payload.
|
|
666
|
+
// Never leave the skeleton spinning — surface whatever text we have.
|
|
667
|
+
const textParts = (result && Array.isArray(result.content))
|
|
668
|
+
? result.content.filter(c => c && c.type === "text" && c.text).map(c => c.text)
|
|
669
|
+
: [];
|
|
670
|
+
const fallbackText = textParts.join("\n").trim();
|
|
671
|
+
appDiv.innerHTML = `<div class="empty-state" style="white-space: pre-wrap;">${escapeHtml(fallbackText || 'The email tool returned no displayable data. Check the chat for details.')}</div>`;
|
|
672
|
+
triggerResize();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
575
675
|
|
|
576
|
-
const data = result.structuredContent;
|
|
577
676
|
appDiv.innerHTML = ''; // Clear skeleton
|
|
578
677
|
|
|
579
|
-
if (data.
|
|
678
|
+
if (data.status === "pending") {
|
|
679
|
+
appDiv.innerHTML = renderPending(data);
|
|
680
|
+
setTimeout(triggerResize, 50);
|
|
681
|
+
}
|
|
682
|
+
else if (data.type === "previews") {
|
|
580
683
|
appDiv.innerHTML = renderPreviews(data.previews);
|
|
581
684
|
setTimeout(triggerResize, 50);
|
|
582
685
|
}
|
package/src/tools/email.test.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
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";
|
|
3
17
|
|
|
4
18
|
// Mock the Auth module so tests bypass active browser logins
|
|
5
19
|
vi.mock("../desktop-auth.js", () => {
|
|
@@ -415,3 +429,269 @@ describe("Node Email Tools Finding #2 and Finding #6 tests", () => {
|
|
|
415
429
|
});
|
|
416
430
|
});
|
|
417
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
|
+
});
|