@adeu/mcp-server 1.6.8 → 1.7.1

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.
@@ -0,0 +1,162 @@
1
+ // FILE: node/packages/mcp-server/src/mcp.bugs.test.ts
2
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
3
+ import { spawn, ChildProcess } from "node:child_process";
4
+ import { resolve, join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
7
+ import { DocumentObject, RedlineEngine } from "@adeu/core";
8
+
9
+ describe("Resolved Bugs MCP Server Verification", () => {
10
+ let serverProc: ChildProcess;
11
+ let cleanDocPath: string;
12
+ let dirtyDocPath: string;
13
+
14
+ beforeAll(async () => {
15
+ // 1. Grab the shared golden fixture from the monorepo root
16
+ const fixturePath = resolve(
17
+ __dirname,
18
+ "../../../../shared/fixtures/golden.docx",
19
+ );
20
+
21
+ cleanDocPath = join(tmpdir(), `adeu_clean_${Date.now()}.docx`);
22
+ dirtyDocPath = join(tmpdir(), `adeu_dirty_${Date.now()}.docx`);
23
+
24
+ // Save a clean copy
25
+ const fixtureBuf = readFileSync(fixturePath);
26
+ writeFileSync(cleanDocPath, fixtureBuf);
27
+
28
+ // Load it via the public API, dirty it, and save a dirty copy
29
+ const doc = await DocumentObject.load(fixtureBuf);
30
+ const engine = new RedlineEngine(doc, "Reviewer");
31
+
32
+ // "document" is original base text, so we won't trigger the cross-author nested redline constraint
33
+ engine.process_batch([
34
+ {
35
+ type: "modify",
36
+ target_text: "document",
37
+ new_text: "dirty modified document",
38
+ },
39
+ ]);
40
+ writeFileSync(dirtyDocPath, await doc.save());
41
+
42
+ // 2. Boot the compiled MCP server
43
+ const serverPath = resolve(__dirname, "../dist/index.js");
44
+ if (!existsSync(serverPath)) {
45
+ throw new Error(
46
+ "MCP server not built. Run 'npm run build' before tests.",
47
+ );
48
+ }
49
+
50
+ serverProc = spawn("node", [serverPath]);
51
+ });
52
+
53
+ afterAll(() => {
54
+ if (serverProc && !serverProc.killed) serverProc.kill();
55
+ if (existsSync(cleanDocPath)) unlinkSync(cleanDocPath);
56
+ if (existsSync(dirtyDocPath)) unlinkSync(dirtyDocPath);
57
+ });
58
+
59
+ // Helper to interact with the stdio JSON-RPC server
60
+ function sendRpc(method: string, params: any, id: number = 1): Promise<any> {
61
+ return new Promise((resolve, reject) => {
62
+ const timeout = setTimeout(() => reject(new Error("RPC Timeout")), 5000);
63
+
64
+ const listener = (data: Buffer) => {
65
+ const lines = data.toString().trim().split("\n");
66
+ for (const line of lines) {
67
+ if (!line.startsWith("{")) continue;
68
+ try {
69
+ const res = JSON.parse(line);
70
+ if (res.id === id) {
71
+ clearTimeout(timeout);
72
+ serverProc.stdout?.removeListener("data", listener);
73
+ resolve(res);
74
+ }
75
+ } catch (e) {
76
+ // Ignore incomplete chunks
77
+ }
78
+ }
79
+ };
80
+
81
+ serverProc.stdout?.on("data", listener);
82
+ serverProc.stdin?.write(
83
+ JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
84
+ );
85
+ });
86
+ }
87
+
88
+ it("BUG-5: Rejects empty changes array early without writing files", async () => {
89
+ const outPath = join(tmpdir(), `adeu_out_${Date.now()}.docx`);
90
+ if (existsSync(outPath)) unlinkSync(outPath);
91
+
92
+ const res = await sendRpc(
93
+ "tools/call",
94
+ {
95
+ name: "process_document_batch",
96
+ arguments: {
97
+ original_docx_path: cleanDocPath,
98
+ author_name: "Agent",
99
+ changes: [],
100
+ output_path: outPath,
101
+ },
102
+ },
103
+ 101,
104
+ );
105
+
106
+ expect(res.result.content[0].text).toBe("Error: No changes provided.");
107
+ expect(existsSync(outPath)).toBe(false); // Proves no-op
108
+ });
109
+
110
+ it("BUG-9: diff_docx_files tool respects compare_clean parameter", async () => {
111
+ // 1. compare_clean = true (Default) -> Should output clean text comparison
112
+ const resClean = await sendRpc(
113
+ "tools/call",
114
+ {
115
+ name: "diff_docx_files",
116
+ arguments: {
117
+ original_path: cleanDocPath,
118
+ modified_path: dirtyDocPath,
119
+ compare_clean: true,
120
+ },
121
+ },
122
+ 102,
123
+ );
124
+
125
+ const cleanText = resClean.result.content[0].text;
126
+ expect(cleanText).toContain("dirty modified");
127
+ expect(cleanText).not.toContain("{++"); // No CriticMarkup
128
+
129
+ // 2. compare_clean = false -> Should output raw CriticMarkup comparison
130
+ const resRaw = await sendRpc(
131
+ "tools/call",
132
+ {
133
+ name: "diff_docx_files",
134
+ arguments: {
135
+ original_path: cleanDocPath,
136
+ modified_path: dirtyDocPath,
137
+ compare_clean: false,
138
+ },
139
+ },
140
+ 103,
141
+ );
142
+
143
+ const rawText = resRaw.result.content[0].text;
144
+ expect(rawText).toContain("{++dirty modified ++}");
145
+ });
146
+ it("BUG-10: Traps ENOENT and returns clean File Not Found errors", async () => {
147
+ const res = await sendRpc(
148
+ "tools/call",
149
+ {
150
+ name: "read_docx",
151
+ arguments: { file_path: join(tmpdir(), "DEF_DOES_NOT_EXIST.docx") },
152
+ },
153
+ 104,
154
+ );
155
+
156
+ expect(res.result.isError).toBe(true);
157
+ expect(res.result.content[0].text).toContain(
158
+ "Error executing tool read_docx: File not found:",
159
+ );
160
+ expect(res.result.content[0].text).not.toContain("ENOENT"); // Raw node error must not leak
161
+ });
162
+ });
@@ -0,0 +1,138 @@
1
+ // FILE: node/packages/mcp-server/src/mcp.cloud.test.ts
2
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
3
+ import { spawn, ChildProcess } from "node:child_process";
4
+ import { resolve, join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ rmSync,
12
+ } from "node:fs";
13
+
14
+ describe("Cloud Auth & Email Tools MCP Verification", () => {
15
+ let serverProc: ChildProcess;
16
+ let testHomeDir: string;
17
+ let adeuConfigDir: string;
18
+ let credPath: string;
19
+
20
+ beforeAll(async () => {
21
+ // 1. Create a sandboxed home directory so we don't nuke the dev's actual login
22
+ testHomeDir = join(tmpdir(), `adeu_test_home_${Date.now()}`);
23
+ adeuConfigDir = join(testHomeDir, ".adeu");
24
+ credPath = join(adeuConfigDir, "credentials.json");
25
+ mkdirSync(testHomeDir, { recursive: true });
26
+
27
+ // 2. Boot the compiled MCP server with the sandboxed HOME environment
28
+ const serverPath = resolve(__dirname, "../dist/index.js");
29
+ if (!existsSync(serverPath)) {
30
+ throw new Error(
31
+ "MCP server not built. Run 'npm run build' before tests.",
32
+ );
33
+ }
34
+
35
+ serverProc = spawn("node", [serverPath], {
36
+ env: {
37
+ ...process.env,
38
+ HOME: testHomeDir, // Mock homedir() for macOS/Linux
39
+ USERPROFILE: testHomeDir, // Mock homedir() for Windows
40
+ },
41
+ });
42
+ });
43
+
44
+ afterAll(() => {
45
+ if (serverProc && !serverProc.killed) serverProc.kill();
46
+ if (existsSync(testHomeDir)) {
47
+ rmSync(testHomeDir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ // Helper to interact with the stdio JSON-RPC server
52
+ function sendRpc(method: string, params: any, id: number = 1): Promise<any> {
53
+ return new Promise((resolve, reject) => {
54
+ const timeout = setTimeout(() => reject(new Error("RPC Timeout")), 5000);
55
+
56
+ const listener = (data: Buffer) => {
57
+ const lines = data.toString().trim().split("\n");
58
+ for (const line of lines) {
59
+ if (!line.startsWith("{")) continue;
60
+ try {
61
+ const res = JSON.parse(line);
62
+ if (res.id === id) {
63
+ clearTimeout(timeout);
64
+ serverProc.stdout?.removeListener("data", listener);
65
+ resolve(res);
66
+ }
67
+ } catch (e) {
68
+ // Ignore incomplete chunks
69
+ }
70
+ }
71
+ };
72
+
73
+ serverProc.stdout?.on("data", listener);
74
+ serverProc.stdin?.write(
75
+ JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
76
+ );
77
+ });
78
+ }
79
+
80
+ it("CLOUD-1: Enforces authentication trap for search_and_fetch_emails", async () => {
81
+ // Attempt to search emails without a credentials.json file in the sandbox
82
+ const res = await sendRpc(
83
+ "tools/call",
84
+ {
85
+ name: "search_and_fetch_emails",
86
+ arguments: { subject: "Invoice" },
87
+ },
88
+ 201,
89
+ );
90
+
91
+ expect(res.result.isError).toBe(true);
92
+ expect(res.result.content[0].text).toContain("Authentication Required");
93
+ expect(res.result.content[0].text).toContain("login_to_adeu_cloud");
94
+ });
95
+
96
+ it("CLOUD-2: Validates create_email_draft missing required arguments", async () => {
97
+ // To bypass the auth trap for this test, we inject a dummy credential file
98
+ mkdirSync(adeuConfigDir, { recursive: true });
99
+ writeFileSync(credPath, JSON.stringify({ api_key: "dummy_test_key" }));
100
+
101
+ const res = await sendRpc(
102
+ "tools/call",
103
+ {
104
+ name: "create_email_draft",
105
+ arguments: {
106
+ body_markdown: "Hello World",
107
+ // Missing reply_to_email_id AND subject/to_recipients
108
+ },
109
+ },
110
+ 202,
111
+ );
112
+
113
+ expect(res.result.isError).toBe(true);
114
+ expect(res.result.content[0].text).toContain(
115
+ "You must provide either 'reply_to_email_id' OR both 'subject' and 'to_recipients'",
116
+ );
117
+ });
118
+
119
+ it("CLOUD-3: logout_of_adeu_cloud successfully deletes the credentials file", async () => {
120
+ // Ensure the dummy file from the previous test exists
121
+ expect(existsSync(credPath)).toBe(true);
122
+
123
+ const res = await sendRpc(
124
+ "tools/call",
125
+ {
126
+ name: "logout_of_adeu_cloud",
127
+ arguments: {},
128
+ },
129
+ 203,
130
+ );
131
+
132
+ expect(res.result.isError).toBeFalsy();
133
+ expect(res.result.content[0].text).toContain("Successfully logged out");
134
+
135
+ // Verify the file was physically deleted from the sandboxed .adeu directory
136
+ expect(existsSync(credPath)).toBe(false);
137
+ });
138
+ });
package/src/shared.ts ADDED
@@ -0,0 +1,7 @@
1
+ // FILE: node/packages/mcp-server/src/shared.ts
2
+ export const FRONTEND_URL =
3
+ process.env.ADEU_FRONTEND_URL || "https://app.adeu.ai";
4
+ export const BACKEND_URL =
5
+ process.env.ADEU_BACKEND_URL || "https://app.adeu.ai";
6
+ export const MARKDOWN_UI_URI = "ui://adeu/markdown-ui";
7
+ export const EMAIL_UI_URI = "ui://adeu/email-ui";
@@ -0,0 +1,49 @@
1
+ // FILE: node/packages/mcp-server/src/tools/auth.ts
2
+ import { DesktopAuthManager } from "../desktop-auth.js";
3
+ import { BACKEND_URL } from "../shared.js";
4
+ import { ToolResult } from "../response-builders.js";
5
+
6
+ export async function login_to_adeu_cloud(): Promise<ToolResult> {
7
+ try {
8
+ const apiKey = await DesktopAuthManager.ensureAuthenticated();
9
+
10
+ const res = await fetch(`${BACKEND_URL}/api/v1/auth/me`, {
11
+ headers: {
12
+ Authorization: `Bearer ${apiKey}`,
13
+ Accept: "application/json",
14
+ },
15
+ });
16
+
17
+ if (res.status === 401) {
18
+ DesktopAuthManager.clearApiKey();
19
+ throw new Error(
20
+ "Your previous session expired. The stale key has been cleared. Please call `login_to_adeu_cloud` ONE MORE TIME to log in fresh.",
21
+ );
22
+ }
23
+ if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
24
+
25
+ const data: any = await res.json();
26
+ return {
27
+ content: [
28
+ {
29
+ type: "text",
30
+ text: `Login successful! Connected to Adeu Cloud as: ${data.email || "Unknown Email"}.`,
31
+ },
32
+ ],
33
+ };
34
+ } catch (err: any) {
35
+ return { isError: true, content: [{ type: "text", text: err.message }] };
36
+ }
37
+ }
38
+
39
+ export async function logout_of_adeu_cloud(): Promise<ToolResult> {
40
+ DesktopAuthManager.clearApiKey();
41
+ return {
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: "Successfully logged out. The local API key has been removed.",
46
+ },
47
+ ],
48
+ };
49
+ }
@@ -0,0 +1,313 @@
1
+ // FILE: node/packages/mcp-server/src/tools/email.ts
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
5
+ import { DesktopAuthManager, getCloudAuthToken } from "../desktop-auth.js";
6
+ import { BACKEND_URL } from "../shared.js";
7
+ import { ToolResult } from "../response-builders.js";
8
+ import { createHash } from "node:crypto";
9
+
10
+ const CACHE_FILE = join(homedir(), ".adeu", "mcp_id_cache.json");
11
+ const MAX_CACHE_SIZE = 1000;
12
+
13
+ function loadIdCache(): Record<string, string> {
14
+ if (existsSync(CACHE_FILE)) {
15
+ try {
16
+ return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+ return {};
22
+ }
23
+
24
+ function saveIdCache(cache: Record<string, string>): void {
25
+ try {
26
+ mkdirSync(join(homedir(), ".adeu"), { recursive: true });
27
+ const keys = Object.keys(cache);
28
+ if (keys.length > MAX_CACHE_SIZE) {
29
+ const trimmed: Record<string, string> = {};
30
+ keys.slice(-MAX_CACHE_SIZE).forEach((k) => (trimmed[k] = cache[k]));
31
+ cache = trimmed;
32
+ }
33
+ writeFileSync(CACHE_FILE, JSON.stringify(cache));
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+
39
+ function minifyEmailId(realId: string, cache: Record<string, string>): string {
40
+ if (!realId) return realId;
41
+ const hash = createHash("md5").update(realId).digest("hex").slice(0, 6);
42
+ const shortId = `msg_${hash}`;
43
+ cache[shortId] = realId;
44
+ return shortId;
45
+ }
46
+
47
+ function resolveEmailId(shortId: string): string {
48
+ if (!shortId) return shortId;
49
+ const cache = loadIdCache();
50
+ return cache[shortId] || shortId;
51
+ }
52
+
53
+ function stripTags(html: string): string {
54
+ if (!html) return "";
55
+ let text = html.replace(/<(style|script|head)[^>]*>[\s\S]*?<\/\1>/gi, "");
56
+ text = text.replace(
57
+ /<\/?(p|div|br|hr|tr|li|h[1-6]|blockquote)\b[^>]*>/gi,
58
+ "\n",
59
+ );
60
+ text = text.replace(/<[^>]+>/g, "");
61
+ return text.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
62
+ }
63
+
64
+ function removeNestedQuotes(text: string): string {
65
+ if (!text) return "";
66
+ const patterns = [
67
+ /_{10,}/m,
68
+ /^From:\s.*?\n(?:.*\n){0,5}?Sent:\s/m,
69
+ /-----Original Message-----/m,
70
+ /On .{1,200}? wrote:/m,
71
+ /^Original Message$/m,
72
+ ];
73
+ let earliestCut = text.length;
74
+ for (const pattern of patterns) {
75
+ const match = pattern.exec(text);
76
+ if (match && match.index < earliestCut) {
77
+ earliestCut = match.index;
78
+ }
79
+ }
80
+ return text.substring(0, earliestCut).trim();
81
+ }
82
+
83
+ function getUniqueFilepath(saveDir: string, filename: string): string {
84
+ let filepath = join(saveDir, filename);
85
+ let counter = 1;
86
+ const parts = filename.split(".");
87
+ const ext = parts.length > 1 ? `.${parts.pop()}` : "";
88
+ const stem = parts.join(".");
89
+
90
+ while (existsSync(filepath)) {
91
+ filepath = join(saveDir, `${stem}_${counter}${ext}`);
92
+ counter++;
93
+ }
94
+ return filepath;
95
+ }
96
+
97
+ export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
98
+ const apiKey = await getCloudAuthToken();
99
+ const realEmailId = args.email_id ? resolveEmailId(args.email_id) : undefined;
100
+
101
+ const payload = {
102
+ email_id: realEmailId,
103
+ sender: args.sender,
104
+ subject: args.subject,
105
+ has_attachments: args.has_attachments,
106
+ attachment_name: args.attachment_name,
107
+ is_unread: args.is_unread,
108
+ days_ago: args.days_ago,
109
+ folder: args.folder,
110
+ limit: args.limit ?? 10,
111
+ offset: args.offset ?? 0,
112
+ };
113
+
114
+ // Remove undefined fields
115
+ Object.keys(payload).forEach(
116
+ (k) => (payload as any)[k] === undefined && delete (payload as any)[k],
117
+ );
118
+
119
+ const res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
120
+ method: "POST",
121
+ headers: {
122
+ Authorization: `Bearer ${apiKey}`,
123
+ "Content-Type": "application/json",
124
+ },
125
+ body: JSON.stringify(payload),
126
+ });
127
+
128
+ if (res.status === 401) {
129
+ DesktopAuthManager.clearApiKey();
130
+ throw new Error(
131
+ "Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
132
+ );
133
+ }
134
+ if (!res.ok) throw new Error(`Cloud search failed: ${await res.text()}`);
135
+
136
+ const data: any = await res.json();
137
+ const cache = loadIdCache();
138
+
139
+ if (data.type === "previews") {
140
+ const previews = data.previews || [];
141
+ if (!previews.length)
142
+ return {
143
+ content: [
144
+ {
145
+ type: "text",
146
+ text: "No emails found matching your search criteria.",
147
+ },
148
+ ],
149
+ };
150
+
151
+ const lines = [
152
+ `Found ${previews.length} email(s). Here are the previews:`,
153
+ "",
154
+ ];
155
+ for (const p of previews) {
156
+ const shortId = minifyEmailId(p.id, cache);
157
+ const attFlag = p.has_attachments ? "📎 (Has Attachments)" : "";
158
+ const unreadFlag = p.is_read === false ? "🟢 [UNREAD]" : "";
159
+ lines.push(
160
+ `- **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
+ );
162
+ }
163
+ saveIdCache(cache);
164
+ 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`.",
166
+ );
167
+ return { content: [{ type: "text", text: lines.join("\n") }] };
168
+ }
169
+
170
+ if (data.type === "full_email") {
171
+ const full = data.full_email || {};
172
+ const shortTargetId = minifyEmailId(full.id || "unknown_id", cache);
173
+
174
+ saveIdCache(cache);
175
+
176
+ const baseDir =
177
+ args.working_directory && existsSync(args.working_directory)
178
+ ? args.working_directory
179
+ : tmpdir();
180
+ const saveDir = join(
181
+ baseDir,
182
+ args.working_directory ? "adeu_attachments" : "adeu_downloads",
183
+ shortTargetId,
184
+ );
185
+ mkdirSync(saveDir, { recursive: true });
186
+
187
+ async function processAttachments(msg: any): Promise<string[]> {
188
+ const localFiles: string[] = [];
189
+ for (const att of msg.attachments || []) {
190
+ if (att.base64_data) {
191
+ try {
192
+ const filepath = getUniqueFilepath(
193
+ saveDir,
194
+ att.filename || "unnamed_file",
195
+ );
196
+ writeFileSync(filepath, Buffer.from(att.base64_data, "base64"));
197
+ localFiles.push(filepath);
198
+ delete att.base64_data; // Free memory
199
+ } catch (e) {
200
+ console.error(`Failed to save attachment ${att.filename}`, e);
201
+ }
202
+ }
203
+ }
204
+ return localFiles;
205
+ }
206
+
207
+ const targetFiles = await processAttachments(full);
208
+ const lines = [
209
+ `# Email Thread: ${full.subject}`,
210
+ "",
211
+ "## Target Message (Newest):",
212
+ `**From**: ${full.sender_name} <${full.sender_email}>`,
213
+ `**Date**: ${full.received_datetime}`,
214
+ ];
215
+
216
+ if (targetFiles.length) {
217
+ lines.push("**Attachments Saved Locally**:");
218
+ targetFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
219
+ }
220
+
221
+ const cleanBody = removeNestedQuotes(stripTags(full.body_html || ""));
222
+ lines.push(`**Body**:\n\`\`\`\n${cleanBody}\n\`\`\`\n`);
223
+
224
+ if (full.is_thread && full.messages?.length) {
225
+ lines.push("## Previous Messages in Thread (Historical Context):");
226
+ for (let i = 0; i < full.messages.length; i++) {
227
+ const histMsg = full.messages[i];
228
+ const histFiles = await processAttachments(histMsg);
229
+ lines.push(
230
+ `### Message -${i + 1} (Older)\n**From**: ${histMsg.sender_name} <${histMsg.sender_email}>\n**Date**: ${histMsg.received_datetime}`,
231
+ );
232
+ if (histFiles.length) {
233
+ lines.push("**Attachments Saved Locally**:");
234
+ histFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
235
+ }
236
+ lines.push(
237
+ `**Body**:\n\`\`\`\n${removeNestedQuotes(stripTags(histMsg.body_html || ""))}\n\`\`\`\n`,
238
+ );
239
+ }
240
+ }
241
+ return { content: [{ type: "text", text: lines.join("\n") }] };
242
+ }
243
+
244
+ return {
245
+ isError: true,
246
+ content: [{ type: "text", text: "Unknown response format from backend." }],
247
+ };
248
+ }
249
+
250
+ export async function create_email_draft(args: any): Promise<ToolResult> {
251
+ const apiKey = await getCloudAuthToken();
252
+ if (!args.reply_to_email_id && (!args.subject || !args.to_recipients)) {
253
+ throw new Error(
254
+ "You must provide either 'reply_to_email_id' OR both 'subject' and 'to_recipients'.",
255
+ );
256
+ }
257
+
258
+ const formData = new FormData();
259
+ formData.append("body_markdown", args.body_markdown);
260
+
261
+ if (args.reply_to_email_id) {
262
+ formData.append(
263
+ "reply_to_email_id",
264
+ resolveEmailId(args.reply_to_email_id),
265
+ );
266
+ }
267
+ if (args.subject) formData.append("subject", args.subject);
268
+
269
+ if (args.to_recipients) {
270
+ const recips =
271
+ typeof args.to_recipients === "string"
272
+ ? JSON.parse(args.to_recipients)
273
+ : args.to_recipients;
274
+ formData.append("to_recipients", JSON.stringify(recips));
275
+ }
276
+
277
+ if (args.attachment_paths) {
278
+ const paths =
279
+ typeof args.attachment_paths === "string"
280
+ ? JSON.parse(args.attachment_paths)
281
+ : args.attachment_paths;
282
+ for (const p of paths) {
283
+ const buf = readFileSync(p);
284
+ const filename = p.split(/[/\\]/).pop();
285
+ formData.append("files", new Blob([buf]), filename);
286
+ }
287
+ }
288
+
289
+ const res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
290
+ method: "POST",
291
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
292
+ body: formData as any,
293
+ });
294
+
295
+ if (res.status === 401) {
296
+ DesktopAuthManager.clearApiKey();
297
+ throw new Error(
298
+ "Authentication expired. Please call `login_to_adeu_cloud`.",
299
+ );
300
+ }
301
+ if (!res.ok)
302
+ throw new Error(`Cloud draft creation failed: ${await res.text()}`);
303
+
304
+ const data: any = await res.json();
305
+ return {
306
+ content: [
307
+ {
308
+ type: "text",
309
+ text: `Successfully created email draft! Draft ID: ${data.id}`,
310
+ },
311
+ ],
312
+ };
313
+ }