@adeu/mcp-server 1.18.4 → 1.19.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.
@@ -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&hellip;</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
- if (!result || !result.structuredContent) return;
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.type === "previews") {
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.4",
3
+ "version": "1.19.0",
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.4",
34
+ "@adeu/core": "^1.19.0",
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.\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. 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.string().optional(),
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()
@@ -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&hellip;</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
- if (!result || !result.structuredContent) return;
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.type === "previews") {
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
  }
@@ -1,5 +1,19 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { search_and_fetch_emails, list_available_mailboxes } from "./email.js";
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
+ });