apple-mail-mcp 2.1.5 → 2.3.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/build/index.js CHANGED
@@ -77,6 +77,41 @@ const ATTACHMENTS_SCHEMA = z
77
77
  .optional()
78
78
  .describe("Files to attach: absolute paths (e.g. '/Users/me/report.pdf') and/or " +
79
79
  "inline {filename, contentBase64} objects for content not on disk.");
80
+ // =============================================================================
81
+ // Shared Output Schemas (MCP outputSchema)
82
+ //
83
+ // Every tool below declares an MCP `outputSchema` so clients can know and
84
+ // validate the output shape. The SDK requires `structuredContent` on every
85
+ // success result once an outputSchema is present and validates it against the
86
+ // schema, so these schemas are intentionally PERMISSIVE — fields are optional
87
+ // unless they are provably always present on every success path, no `.strict()`
88
+ // is used (extra keys pass), and rows that vary across the AppleScript and IMAP
89
+ // backends use loose element types. Error responses are exempt from validation.
90
+ // =============================================================================
91
+ /** A message row in a list/search result. Loose: the AppleScript path emits a
92
+ * messageSummary while the IMAP path emits its own record, so allow any keys. */
93
+ const MESSAGE_ROW_SCHEMA = z.object({}).passthrough();
94
+ /** Shape returned by list/search style tools (messages + count + optional
95
+ * partial-coverage diagnostics). Diagnostics only appear on the AppleScript
96
+ * path, so they are optional. */
97
+ const LIST_OUTPUT_SCHEMA = {
98
+ messages: z.array(MESSAGE_ROW_SCHEMA).optional(),
99
+ count: z.number().optional(),
100
+ partial: z.boolean().optional(),
101
+ skippedLargeMailboxes: z.array(z.string()).optional(),
102
+ notSearchedMailboxes: z.array(z.string()).optional(),
103
+ timedOutAccounts: z.array(z.string()).optional(),
104
+ };
105
+ /** Shape returned by the batch count tools. */
106
+ const BATCH_COUNT_OUTPUT_SCHEMA = {
107
+ ok: z.boolean().optional(),
108
+ success: z.number().optional(),
109
+ failed: z.number().optional(),
110
+ mailbox: z.string().optional(),
111
+ };
112
+ /** A health/doctor check item — loose to accept both health-check
113
+ * ({name, passed, message}) and doctor ({name, status, detail}) items. */
114
+ const CHECK_ITEM_SCHEMA = z.object({}).passthrough();
80
115
  // Read version from package.json to keep it in sync
81
116
  const require = createRequire(import.meta.url);
82
117
  const { version } = require("../package.json");
@@ -132,28 +167,32 @@ async function hybridBatchCounts(ids, appleFn, imapFn) {
132
167
  // Message Tools
133
168
  // =============================================================================
134
169
  // --- search-messages ---
135
- server.tool("search-messages", "Use when: finding messages by query/sender/subject/date/read/flag filters and you need their ids for follow-up operations.\nReturns: matching messages with id, date, subject, sender, and read state (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you want a plain mailbox listing without filters (use list-messages), already have an id and want the body (use get-message), or want a whole conversation (use get-thread).\nPrefer this first to obtain the message ids that get-message/mark-as-read/delete-message/move-message and the batch tools require.", {
136
- query: z.string().optional().describe("Text to search for in subject, sender, or content"),
137
- from: z
138
- .string()
139
- .optional()
140
- .describe("Filter by sender (substring match against the full sender string, i.e. display name + address — not an exact address match)"),
141
- subject: z.string().optional().describe("Filter by subject line (substring match)"),
142
- mailbox: z
143
- .string()
144
- .optional()
145
- .describe("Mailbox to search in (e.g., 'INBOX'). Omit to search all mailboxes."),
146
- account: z.string().optional().describe("Account to search in (omit to search all accounts)"),
147
- isRead: z.boolean().optional().describe("Filter by read status"),
148
- isFlagged: z.boolean().optional().describe("Filter by flagged status"),
149
- dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
150
- dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
151
- limit: z.number().optional().describe("Maximum number of results (default: 50)"),
170
+ server.registerTool("search-messages", {
171
+ description: "Use when: finding messages by query/sender/subject/date/read/flag filters and you need their ids for follow-up operations.\nReturns: matching messages with id, date, subject, sender, and read state (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you want a plain mailbox listing without filters (use list-messages), already have an id and want the body (use get-message), or want a whole conversation (use get-thread).\nPrefer this first to obtain the message ids that get-message/mark-as-read/delete-message/move-message and the batch tools require.",
172
+ inputSchema: {
173
+ query: z.string().optional().describe("Text to search for in subject, sender, or content"),
174
+ from: z
175
+ .string()
176
+ .optional()
177
+ .describe("Filter by sender (substring match against the full sender string, i.e. display name + address — not an exact address match)"),
178
+ subject: z.string().optional().describe("Filter by subject line (substring match)"),
179
+ mailbox: z
180
+ .string()
181
+ .optional()
182
+ .describe("Mailbox to search in (e.g., 'INBOX'). Omit to search all mailboxes."),
183
+ account: z.string().optional().describe("Account to search in (omit to search all accounts)"),
184
+ isRead: z.boolean().optional().describe("Filter by read status"),
185
+ isFlagged: z.boolean().optional().describe("Filter by flagged status"),
186
+ dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
187
+ dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
188
+ limit: z.number().optional().describe("Maximum number of results (default: 50)"),
189
+ },
190
+ outputSchema: LIST_OUTPUT_SCHEMA,
152
191
  }, withErrorHandling(async ({ query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged, }) => {
153
192
  // IMAP backend (issue #43): server-side search when this account is
154
193
  // explicitly configured for IMAP; otherwise fall through to AppleScript.
155
194
  if (isImapAccount(account)) {
156
- return successResponse(await imapSearchMessages({
195
+ const r = await imapSearchMessages({
157
196
  query,
158
197
  mailbox,
159
198
  account,
@@ -164,7 +203,12 @@ server.tool("search-messages", "Use when: finding messages by query/sender/subje
164
203
  subject,
165
204
  isRead,
166
205
  isFlagged,
167
- }));
206
+ });
207
+ return successResponse(r.text, {
208
+ messages: r.messages,
209
+ count: r.count,
210
+ partial: r.partial,
211
+ });
168
212
  }
169
213
  const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(query, mailbox, account, limit, dateFrom, dateTo, from, subject, isRead, isFlagged);
170
214
  const coverageBlock = partialCoverageBlock(diagnostics);
@@ -188,15 +232,37 @@ server.tool("search-messages", "Use when: finding messages by query/sender/subje
188
232
  return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}`, structured);
189
233
  }, "Error searching messages"));
190
234
  // --- get-message ---
191
- server.tool("get-message", "Use when: reading the full body of one message whose id you already have (numeric or imap:…); set preferHtml to get the HTML body instead of plain text.\nReturns: the message subject and body (plain text by default, HTML when preferHtml is true).\nDo not use when: you don't yet have an id (use search-messages or list-messages first), or you want the whole conversation (use get-thread).", {
192
- id: MESSAGE_ID_SCHEMA,
193
- preferHtml: z
194
- .boolean()
195
- .optional()
196
- .describe("Return the HTML body (extracted from the message source) instead of plain text"),
235
+ server.registerTool("get-message", {
236
+ description: "Use when: reading the full body of one message whose id you already have (numeric or imap:…); set preferHtml to get the HTML body instead of plain text.\nReturns: the message subject and body (plain text by default, HTML when preferHtml is true).\nDo not use when: you don't yet have an id (use search-messages or list-messages first), or you want the whole conversation (use get-thread).",
237
+ inputSchema: {
238
+ id: MESSAGE_ID_SCHEMA,
239
+ preferHtml: z
240
+ .boolean()
241
+ .optional()
242
+ .describe("Return the HTML body (extracted from the message source) instead of plain text"),
243
+ },
244
+ outputSchema: {
245
+ id: z.string().optional(),
246
+ subject: z.string().optional(),
247
+ body: z.string().optional(),
248
+ isHtml: z.boolean().optional(),
249
+ },
197
250
  }, withErrorHandling(({ id, preferHtml }) => routeMessage(id, {
198
251
  // IMAP id (imap:…) → fetch via IMAP (#43 Phase 3); else AppleScript.
199
252
  imap: () => imapGetMessage(id, preferHtml === true),
253
+ // IMAP path: parse subject/body out of the returned source so the
254
+ // structuredContent matches the AppleScript branch's shape.
255
+ structuredFromResult: (r) => {
256
+ if (!r.info)
257
+ return undefined;
258
+ const sep = r.info.indexOf("\n\n");
259
+ return {
260
+ id,
261
+ subject: subjectFromGetMessage(r.info),
262
+ body: sep >= 0 ? r.info.slice(sep + 2) : r.info,
263
+ isHtml: preferHtml === true,
264
+ };
265
+ },
200
266
  apple: () => {
201
267
  // Only fetch/parse the raw source when HTML is actually requested (#32).
202
268
  const content = mailManager.getMessageContent(id, preferHtml === true);
@@ -215,11 +281,20 @@ server.tool("get-message", "Use when: reading the full body of one message whose
215
281
  fail: `Message with ID "${id}" not found`,
216
282
  }), "Error retrieving message"));
217
283
  // --- get-thread ---
218
- server.tool("get-thread", "Use when: you have one message id and want the whole conversation it belongs to, oldest-first. With an imap: id it threads by References/Message-ID; otherwise it groups by normalized subject.\nReturns: the thread's normalized subject and its messages (id, date, subject, sender, read state).\nDo not use when: you only need the single message (use get-message) or are searching by arbitrary criteria (use search-messages).", {
219
- id: MESSAGE_ID_SCHEMA.describe("A message ID in the conversation (numeric or imap:…)"),
220
- account: z.string().optional().describe("Account to search (omit to search all)"),
221
- mailbox: z.string().optional().describe("Mailbox to search (omit to search all)"),
222
- limit: z.number().optional().describe("Max messages in the thread (default 50)"),
284
+ server.registerTool("get-thread", {
285
+ description: "Use when: you have one message id and want the whole conversation it belongs to, oldest-first. With an imap: id it threads by References/Message-ID; otherwise it groups by normalized subject.\nReturns: the thread's normalized subject and its messages (id, date, subject, sender, read state).\nDo not use when: you only need the single message (use get-message) or are searching by arbitrary criteria (use search-messages).",
286
+ inputSchema: {
287
+ id: MESSAGE_ID_SCHEMA.describe("A message ID in the conversation (numeric or imap:…)"),
288
+ account: z.string().optional().describe("Account to search (omit to search all)"),
289
+ mailbox: z.string().optional().describe("Mailbox to search (omit to search all)"),
290
+ limit: z.number().optional().describe("Max messages in the thread (default 50)"),
291
+ },
292
+ outputSchema: {
293
+ subject: z.string().optional(),
294
+ messages: z.array(MESSAGE_ROW_SCHEMA).optional(),
295
+ count: z.number().optional(),
296
+ partial: z.boolean().optional(),
297
+ },
223
298
  }, withErrorHandling(async ({ id, account, mailbox, limit = 50 }) => {
224
299
  // True threading via References/Message-ID when we have an imap: id (I5);
225
300
  // falls through to subject grouping if the server lacks HEADER search or
@@ -249,8 +324,13 @@ server.tool("get-thread", "Use when: you have one message id and want the whole
249
324
  const base = normalizeSubject(seedSubject);
250
325
  // IMAP backend: server-side subject search.
251
326
  if (isImapAccount(account)) {
252
- const text = await imapSearchMessages({ subject: base, mailbox, account, limit });
253
- return successResponse(`Thread "${base}":\n${text}`, { subject: base });
327
+ const r = await imapSearchMessages({ subject: base, mailbox, account, limit });
328
+ return successResponse(`Thread "${base}":\n${r.text}`, {
329
+ subject: base,
330
+ messages: r.messages,
331
+ count: r.count,
332
+ partial: r.partial,
333
+ });
254
334
  }
255
335
  const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(undefined, mailbox, account, limit, undefined, undefined, undefined, base);
256
336
  // Oldest-first is the natural reading order for a conversation.
@@ -273,21 +353,30 @@ server.tool("get-thread", "Use when: you have one message id and want the whole
273
353
  return successResponse(`Thread "${base}" — ${ordered.length} message(s), oldest first:\n${list}${coverageBlock}`, structured);
274
354
  }, "Error retrieving thread"));
275
355
  // --- list-messages ---
276
- server.tool("list-messages", "Use when: browsing a mailbox's recent messages (optionally filtered by sender or unread-only) with pagination via limit/offset, and you need their ids.\nReturns: messages with id, date, subject, and sender (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you have specific search criteria like subject/date/flags (use search-messages) or already have an id and want the body (use get-message).\nLike search-messages, use this to obtain the ids that read/mark/delete/move and batch tools require.", {
277
- mailbox: z
278
- .string()
279
- .optional()
280
- .describe("Mailbox to list messages from. Omit to list from all mailboxes."),
281
- account: z.string().optional().describe("Account to list messages from"),
282
- limit: z.number().optional().describe("Maximum number of messages (default: 50)"),
283
- offset: z.number().optional().describe("Number of messages to skip (for pagination)"),
284
- from: z.string().optional().describe("Filter by sender email address or name"),
285
- unreadOnly: z.boolean().optional().describe("Only show unread messages"),
356
+ server.registerTool("list-messages", {
357
+ description: "Use when: browsing a mailbox's recent messages (optionally filtered by sender or unread-only) with pagination via limit/offset, and you need their ids.\nReturns: messages with id, date, subject, and sender (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you have specific search criteria like subject/date/flags (use search-messages) or already have an id and want the body (use get-message).\nLike search-messages, use this to obtain the ids that read/mark/delete/move and batch tools require.",
358
+ inputSchema: {
359
+ mailbox: z
360
+ .string()
361
+ .optional()
362
+ .describe("Mailbox to list messages from. Omit to list from all mailboxes."),
363
+ account: z.string().optional().describe("Account to list messages from"),
364
+ limit: z.number().optional().describe("Maximum number of messages (default: 50)"),
365
+ offset: z.number().optional().describe("Number of messages to skip (for pagination)"),
366
+ from: z.string().optional().describe("Filter by sender email address or name"),
367
+ unreadOnly: z.boolean().optional().describe("Only show unread messages"),
368
+ },
369
+ outputSchema: LIST_OUTPUT_SCHEMA,
286
370
  }, withErrorHandling(async ({ mailbox, account, limit = 50, offset = 0, from, unreadOnly }) => {
287
371
  // IMAP backend (issue #43): server-side listing when this account is
288
372
  // explicitly configured for IMAP; otherwise fall through to AppleScript.
289
373
  if (isImapAccount(account)) {
290
- return successResponse(await imapListMessages({ mailbox, account, limit, offset, from, unreadOnly }));
374
+ const r = await imapListMessages({ mailbox, account, limit, offset, from, unreadOnly });
375
+ return successResponse(r.text, {
376
+ messages: r.messages,
377
+ count: r.count,
378
+ partial: r.partial,
379
+ });
291
380
  }
292
381
  const { messages, diagnostics } = mailManager.listMessagesWithDiagnostics(mailbox, account, limit, from, offset);
293
382
  const coverageBlock = partialCoverageBlock(diagnostics);
@@ -311,62 +400,99 @@ server.tool("list-messages", "Use when: browsing a mailbox's recent messages (op
311
400
  return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}`, structured);
312
401
  }, "Error listing messages"));
313
402
  // --- send-email ---
314
- server.tool("send-email", "Use when: the user has explicitly confirmed they want to send a single email now to the given recipients (to/cc/bcc are arrays), optionally with attachments and a chosen transport.\nReturns: a confirmation naming the recipients and attachment count.\nDo not use when: the user wants to review first (use create-draft), is replying to or forwarding an existing message (use reply-to-message / forward-message), or wants per-recipient personalized copies (use send-serial-email).\nSafety: this SENDS real email immediately and it cannot be unsent — require explicit user confirmation of the exact recipients, subject, and body before calling. Prefer create-draft when there is any doubt.", {
315
- to: z.array(z.string()).min(1, "At least one recipient is required"),
316
- subject: z.string().min(1, "Subject is required"),
317
- body: z.string().min(1, "Body is required"),
318
- cc: z.array(z.string()).optional().describe("CC recipients"),
319
- bcc: z.array(z.string()).optional().describe("BCC recipients"),
320
- account: z.string().optional().describe("Account to send from"),
321
- attachments: ATTACHMENTS_SCHEMA,
322
- transport: z
323
- .enum(["applescript", "smtp"])
324
- .optional()
325
- .describe("Send transport. 'applescript' (default) sends through Mail.app. " +
326
- "'smtp' submits clean MIME directly via SMTP, avoiding the macOS 15+ " +
327
- "Mail.app <blockquote> wrapping (issue #12); requires APPLE_MAIL_MCP_SMTP_* env config."),
403
+ server.registerTool("send-email", {
404
+ description: "Use when: the user has explicitly confirmed they want to send a single email now to the given recipients (to/cc/bcc are arrays), optionally with attachments and a chosen transport.\nReturns: a confirmation naming the recipients and attachment count.\nDo not use when: the user wants to review first (use create-draft), is replying to or forwarding an existing message (use reply-to-message / forward-message), or wants per-recipient personalized copies (use send-serial-email).\nSafety: this SENDS real email immediately and it cannot be unsent — require explicit user confirmation of the exact recipients, subject, and body before calling. Prefer create-draft when there is any doubt.",
405
+ inputSchema: {
406
+ to: z.array(z.string()).min(1, "At least one recipient is required"),
407
+ subject: z.string().min(1, "Subject is required"),
408
+ body: z.string().min(1, "Body is required"),
409
+ cc: z.array(z.string()).optional().describe("CC recipients"),
410
+ bcc: z.array(z.string()).optional().describe("BCC recipients"),
411
+ account: z.string().optional().describe("Account to send from"),
412
+ attachments: ATTACHMENTS_SCHEMA,
413
+ transport: z
414
+ .enum(["applescript", "smtp"])
415
+ .optional()
416
+ .describe("Send transport. 'applescript' (default) sends through Mail.app. " +
417
+ "'smtp' submits clean MIME directly via SMTP, avoiding the macOS 15+ " +
418
+ "Mail.app <blockquote> wrapping (issue #12); requires APPLE_MAIL_MCP_SMTP_* env config."),
419
+ },
420
+ outputSchema: {
421
+ ok: z.boolean().optional(),
422
+ recipients: z.array(z.string()).optional(),
423
+ attachmentCount: z.number().optional(),
424
+ transport: z.string().optional(),
425
+ },
328
426
  }, withErrorHandling(async ({ to, subject, body, cc, bcc, account, attachments, transport }) => {
329
427
  const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
428
+ const attachmentCount = attachments?.length ?? 0;
330
429
  if (transport === "smtp") {
331
430
  const result = await sendViaSmtp({ to, subject, body, cc, bcc, from: account, attachments });
332
431
  if (!result.success) {
333
432
  return errorResponse(result.error ?? "Failed to send email via SMTP.");
334
433
  }
335
- return successResponse(`Email sent via SMTP to ${to.join(", ")}${attachInfo}`);
434
+ return successResponse(`Email sent via SMTP to ${to.join(", ")}${attachInfo}`, {
435
+ ok: true,
436
+ recipients: to,
437
+ attachmentCount,
438
+ transport: "smtp",
439
+ });
336
440
  }
337
441
  const success = mailManager.sendEmail(to, subject, body, cc, bcc, account, attachments);
338
442
  if (!success) {
339
443
  return errorResponse("Failed to send email. Check Mail.app configuration.");
340
444
  }
341
- return successResponse(`Email sent to ${to.join(", ")}${attachInfo}`);
445
+ return successResponse(`Email sent to ${to.join(", ")}${attachInfo}`, {
446
+ ok: true,
447
+ recipients: to,
448
+ attachmentCount,
449
+ transport: "applescript",
450
+ });
342
451
  }, "Error sending email"));
343
452
  // --- send-serial-email ---
344
- server.tool("send-serial-email", "Use when: the user has confirmed a mail-merge — sending individually personalized copies to many recipients (max 100), with {{Key}} placeholders in subject/body replaced per-recipient from each recipient's variables. Recipients do not see each other.\nReturns: a per-recipient sent/failed report with counts.\nDo not use when: sending one message to a shared recipient list (use send-email) or saving for review (use create-draft).\nSafety: this SENDS many real emails immediately and they cannot be unsent — require explicit user confirmation of the recipient list, the subject/body template, and the placeholder substitutions before calling.", {
345
- recipients: z
346
- .array(z.object({
347
- email: z.string().min(1, "Recipient email is required"),
348
- variables: z
349
- .record(z.string())
350
- .describe("Placeholder values, e.g. { Name: 'Alice', Company: 'Acme' }"),
351
- }))
352
- .min(1, "At least one recipient is required")
353
- .max(100, "Cannot send to more than 100 recipients in a single batch")
354
- .describe("List of recipients with personalization variables (max 100)"),
355
- subject: z
356
- .string()
357
- .min(1, "Subject is required")
358
- .describe("Subject line — use {{Key}} for placeholders"),
359
- body: z
360
- .string()
361
- .min(1, "Body is required")
362
- .describe("Email body — use {{Key}} for placeholders"),
363
- account: z.string().optional().describe("Account to send from"),
364
- delayMs: z
365
- .number()
366
- .min(0)
367
- .max(10000)
368
- .optional()
369
- .describe("Delay between sends in ms (default: 500, max: 10000)"),
453
+ server.registerTool("send-serial-email", {
454
+ description: "Use when: the user has confirmed a mail-merge — sending individually personalized copies to many recipients (max 100), with {{Key}} placeholders in subject/body replaced per-recipient from each recipient's variables. Recipients do not see each other.\nReturns: a per-recipient sent/failed report with counts.\nDo not use when: sending one message to a shared recipient list (use send-email) or saving for review (use create-draft).\nSafety: this SENDS many real emails immediately and they cannot be unsent — require explicit user confirmation of the recipient list, the subject/body template, and the placeholder substitutions before calling.",
455
+ inputSchema: {
456
+ recipients: z
457
+ .array(z.object({
458
+ email: z.string().min(1, "Recipient email is required"),
459
+ variables: z
460
+ .record(z.string())
461
+ .describe("Placeholder values, e.g. { Name: 'Alice', Company: 'Acme' }"),
462
+ }))
463
+ .min(1, "At least one recipient is required")
464
+ .max(100, "Cannot send to more than 100 recipients in a single batch")
465
+ .describe("List of recipients with personalization variables (max 100)"),
466
+ subject: z
467
+ .string()
468
+ .min(1, "Subject is required")
469
+ .describe("Subject line — use {{Key}} for placeholders"),
470
+ body: z
471
+ .string()
472
+ .min(1, "Body is required")
473
+ .describe("Email body — use {{Key}} for placeholders"),
474
+ account: z.string().optional().describe("Account to send from"),
475
+ delayMs: z
476
+ .number()
477
+ .min(0)
478
+ .max(10000)
479
+ .optional()
480
+ .describe("Delay between sends in ms (default: 500, max: 10000)"),
481
+ },
482
+ outputSchema: {
483
+ ok: z.boolean().optional(),
484
+ sent: z.number().optional(),
485
+ failed: z.number().optional(),
486
+ results: z
487
+ .array(z
488
+ .object({
489
+ email: z.string().optional(),
490
+ success: z.boolean().optional(),
491
+ error: z.string().optional(),
492
+ })
493
+ .passthrough())
494
+ .optional(),
495
+ },
370
496
  }, withErrorHandling(({ recipients, subject, body, account, delayMs }) => {
371
497
  const results = mailManager.sendSerialEmail(recipients, subject, body, account, delayMs);
372
498
  const successCount = results.filter((r) => r.success).length;
@@ -374,228 +500,347 @@ server.tool("send-serial-email", "Use when: the user has confirmed a mail-merge
374
500
  const details = results
375
501
  .map((r) => ` - ${r.email}: ${r.success ? "sent" : `FAILED (${r.error})`}`)
376
502
  .join("\n");
503
+ const structured = {
504
+ ok: failCount === 0,
505
+ sent: successCount,
506
+ failed: failCount,
507
+ results: results.map((r) => ({ email: r.email, success: r.success, error: r.error })),
508
+ };
377
509
  if (failCount === 0) {
378
- return successResponse(`Successfully sent ${successCount} email(s):\n${details}`);
510
+ return successResponse(`Successfully sent ${successCount} email(s):\n${details}`, structured);
379
511
  }
380
512
  else if (successCount === 0) {
381
513
  return errorResponse(`Failed to send all ${failCount} email(s):\n${details}`);
382
514
  }
383
515
  else {
384
- return successResponse(`Sent ${successCount} of ${results.length} email(s), ${failCount} failed:\n${details}`);
516
+ return successResponse(`Sent ${successCount} of ${results.length} email(s), ${failCount} failed:\n${details}`, structured);
385
517
  }
386
518
  }, "Error sending serial emails"));
387
519
  // --- create-draft ---
388
- server.tool("create-draft", "Use when: composing an email the user should review in Mail.app before sending — the safe default for any new message (to/cc/bcc are arrays, optional attachments).\nReturns: a confirmation that the draft was created, with recipients and attachment count.\nDo not use when: the user has already confirmed they want it sent now (use send-email).\nSafety: low risk — creates a draft only and sends nothing; the user must open Mail.app and send it themselves.", {
389
- to: z.array(z.string()).min(1, "At least one recipient is required"),
390
- subject: z.string().min(1, "Subject is required"),
391
- body: z.string().min(1, "Body is required"),
392
- cc: z.array(z.string()).optional().describe("CC recipients"),
393
- bcc: z.array(z.string()).optional().describe("BCC recipients"),
394
- account: z.string().optional().describe("Account to create draft in"),
395
- attachments: ATTACHMENTS_SCHEMA,
520
+ server.registerTool("create-draft", {
521
+ description: "Use when: composing an email the user should review in Mail.app before sending — the safe default for any new message (to/cc/bcc are arrays, optional attachments).\nReturns: a confirmation that the draft was created, with recipients and attachment count.\nDo not use when: the user has already confirmed they want it sent now (use send-email).\nSafety: low risk — creates a draft only and sends nothing; the user must open Mail.app and send it themselves.",
522
+ inputSchema: {
523
+ to: z.array(z.string()).min(1, "At least one recipient is required"),
524
+ subject: z.string().min(1, "Subject is required"),
525
+ body: z.string().min(1, "Body is required"),
526
+ cc: z.array(z.string()).optional().describe("CC recipients"),
527
+ bcc: z.array(z.string()).optional().describe("BCC recipients"),
528
+ account: z.string().optional().describe("Account to create draft in"),
529
+ attachments: ATTACHMENTS_SCHEMA,
530
+ },
531
+ outputSchema: {
532
+ ok: z.boolean().optional(),
533
+ recipients: z.array(z.string()).optional(),
534
+ attachmentCount: z.number().optional(),
535
+ },
396
536
  }, withErrorHandling(({ to, subject, body, cc, bcc, account, attachments }) => {
397
537
  const success = mailManager.createDraft(to, subject, body, cc, bcc, account, attachments);
398
538
  if (!success) {
399
539
  return errorResponse("Failed to create draft. Check Mail.app configuration.");
400
540
  }
401
- const attachInfo = attachments?.length ? ` with ${attachments.length} attachment(s)` : "";
402
- return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`);
541
+ const attachmentCount = attachments?.length ?? 0;
542
+ const attachInfo = attachmentCount ? ` with ${attachmentCount} attachment(s)` : "";
543
+ return successResponse(`Draft created for ${to.join(", ")}${attachInfo}`, {
544
+ ok: true,
545
+ recipients: to,
546
+ attachmentCount,
547
+ });
403
548
  }, "Error creating draft"));
404
549
  // --- reply-to-message ---
405
- server.tool("reply-to-message", "Use when: replying to an existing message by id, preserving its threading headers. Set replyAll for all recipients; set send=false to save as a draft instead of sending.\nReturns: a confirmation that the reply was sent or saved as a draft.\nDo not use when: composing a brand-new message (use send-email / create-draft) or forwarding to new recipients (use forward-message).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and body, or pass send=false to let the user review.", {
406
- id: MESSAGE_ID_SCHEMA,
407
- body: z.string().min(1, "Reply body is required"),
408
- replyAll: z.boolean().optional().default(false).describe("Reply to all recipients"),
409
- send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
550
+ server.registerTool("reply-to-message", {
551
+ description: "Use when: replying to an existing message by id, preserving its threading headers. Set replyAll for all recipients; set send=false to save as a draft instead of sending.\nReturns: a confirmation that the reply was sent or saved as a draft.\nDo not use when: composing a brand-new message (use send-email / create-draft) or forwarding to new recipients (use forward-message).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and body, or pass send=false to let the user review.",
552
+ inputSchema: {
553
+ id: MESSAGE_ID_SCHEMA,
554
+ body: z.string().min(1, "Reply body is required"),
555
+ replyAll: z.boolean().optional().default(false).describe("Reply to all recipients"),
556
+ send: z
557
+ .boolean()
558
+ .optional()
559
+ .default(true)
560
+ .describe("Send immediately (false = save as draft)"),
561
+ },
562
+ outputSchema: {
563
+ ok: z.boolean().optional(),
564
+ sent: z.boolean().optional(),
565
+ id: z.string().optional(),
566
+ },
410
567
  }, withErrorHandling(({ id, body, replyAll, send }) => {
411
568
  const success = mailManager.replyToMessage(id, body, replyAll, send);
412
569
  if (!success) {
413
570
  return errorResponse(`Failed to reply to message "${id}"`);
414
571
  }
415
- return successResponse(send ? "Reply sent" : "Reply saved as draft");
572
+ return successResponse(send ? "Reply sent" : "Reply saved as draft", {
573
+ ok: true,
574
+ sent: send,
575
+ id,
576
+ });
416
577
  }, "Error replying to message"));
417
578
  // --- forward-message ---
418
- server.tool("forward-message", "Use when: forwarding an existing message (by id) to new recipients (to is an array), with an optional body to prepend. Set send=false to save as a draft.\nReturns: a confirmation that the message was forwarded or saved as a draft.\nDo not use when: replying to the sender/recipients (use reply-to-message) or composing a new message (use send-email / create-draft).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and any prepended body, or pass send=false to let the user review.", {
419
- id: MESSAGE_ID_SCHEMA,
420
- to: z.array(z.string()).min(1, "At least one recipient is required"),
421
- body: z.string().optional().describe("Optional message to prepend"),
422
- send: z.boolean().optional().default(true).describe("Send immediately (false = save as draft)"),
579
+ server.registerTool("forward-message", {
580
+ description: "Use when: forwarding an existing message (by id) to new recipients (to is an array), with an optional body to prepend. Set send=false to save as a draft.\nReturns: a confirmation that the message was forwarded or saved as a draft.\nDo not use when: replying to the sender/recipients (use reply-to-message) or composing a new message (use send-email / create-draft).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and any prepended body, or pass send=false to let the user review.",
581
+ inputSchema: {
582
+ id: MESSAGE_ID_SCHEMA,
583
+ to: z.array(z.string()).min(1, "At least one recipient is required"),
584
+ body: z.string().optional().describe("Optional message to prepend"),
585
+ send: z
586
+ .boolean()
587
+ .optional()
588
+ .default(true)
589
+ .describe("Send immediately (false = save as draft)"),
590
+ },
591
+ outputSchema: {
592
+ ok: z.boolean().optional(),
593
+ sent: z.boolean().optional(),
594
+ recipients: z.array(z.string()).optional(),
595
+ id: z.string().optional(),
596
+ },
423
597
  }, withErrorHandling(({ id, to, body, send }) => {
424
598
  const success = mailManager.forwardMessage(id, to, body, send);
425
599
  if (!success) {
426
600
  return errorResponse(`Failed to forward message "${id}"`);
427
601
  }
428
- return successResponse(send ? `Message forwarded to ${to.join(", ")}` : "Forward saved as draft");
602
+ return successResponse(send ? `Message forwarded to ${to.join(", ")}` : "Forward saved as draft", { ok: true, sent: send, recipients: to, id });
429
603
  }, "Error forwarding message"));
430
604
  // --- mark-as-read ---
431
- server.tool("mark-as-read", "Use when: marking a single message (by id) as read.\nReturns: a confirmation that the message was marked read.\nDo not use when: marking several at once (use batch-mark-as-read) or marking unread (use mark-as-unread). Get the id from search-messages or list-messages first.", {
432
- id: MESSAGE_ID_SCHEMA,
605
+ server.registerTool("mark-as-read", {
606
+ description: "Use when: marking a single message (by id) as read.\nReturns: a confirmation that the message was marked read.\nDo not use when: marking several at once (use batch-mark-as-read) or marking unread (use mark-as-unread). Get the id from search-messages or list-messages first.",
607
+ inputSchema: {
608
+ id: MESSAGE_ID_SCHEMA,
609
+ },
610
+ outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
433
611
  }, withErrorHandling(({ id }) => routeMessage(id, {
434
612
  imap: () => imapMarkRead(id),
435
613
  apple: () => mailManager.markAsRead(id)
436
- ? successResponse("Message marked as read")
614
+ ? successResponse("Message marked as read", { ok: true, id })
437
615
  : errorResponse(`Failed to mark message "${id}" as read`),
438
616
  ok: "Message marked as read",
439
617
  fail: `Failed to mark message "${id}" as read`,
618
+ structured: { ok: true, id },
440
619
  }), "Error marking message as read"));
441
620
  // --- mark-as-unread ---
442
- server.tool("mark-as-unread", "Use when: marking a single message (by id) as unread.\nReturns: a confirmation that the message was marked unread.\nDo not use when: marking several at once (use batch-mark-as-unread) or marking read (use mark-as-read). Get the id from search-messages or list-messages first.", {
443
- id: MESSAGE_ID_SCHEMA,
621
+ server.registerTool("mark-as-unread", {
622
+ description: "Use when: marking a single message (by id) as unread.\nReturns: a confirmation that the message was marked unread.\nDo not use when: marking several at once (use batch-mark-as-unread) or marking read (use mark-as-read). Get the id from search-messages or list-messages first.",
623
+ inputSchema: {
624
+ id: MESSAGE_ID_SCHEMA,
625
+ },
626
+ outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
444
627
  }, withErrorHandling(({ id }) => routeMessage(id, {
445
628
  imap: () => imapMarkUnread(id),
446
629
  apple: () => mailManager.markAsUnread(id)
447
- ? successResponse("Message marked as unread")
630
+ ? successResponse("Message marked as unread", { ok: true, id })
448
631
  : errorResponse(`Failed to mark message "${id}" as unread`),
449
632
  ok: "Message marked as unread",
450
633
  fail: `Failed to mark message "${id}" as unread`,
634
+ structured: { ok: true, id },
451
635
  }), "Error marking message as unread"));
452
636
  // --- flag-message ---
453
- server.tool("flag-message", "Use when: flagging a single message (by id).\nReturns: a confirmation that the message was flagged.\nDo not use when: flagging several at once (use batch-flag-messages) or removing a flag (use unflag-message). Get the id from search-messages or list-messages first.", {
454
- id: MESSAGE_ID_SCHEMA,
637
+ server.registerTool("flag-message", {
638
+ description: "Use when: flagging a single message (by id).\nReturns: a confirmation that the message was flagged.\nDo not use when: flagging several at once (use batch-flag-messages) or removing a flag (use unflag-message). Get the id from search-messages or list-messages first.",
639
+ inputSchema: {
640
+ id: MESSAGE_ID_SCHEMA,
641
+ },
642
+ outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
455
643
  }, withErrorHandling(({ id }) => routeMessage(id, {
456
644
  imap: () => imapFlagMessage(id),
457
645
  apple: () => mailManager.flagMessage(id)
458
- ? successResponse("Message flagged")
646
+ ? successResponse("Message flagged", { ok: true, id })
459
647
  : errorResponse(`Failed to flag message "${id}"`),
460
648
  ok: "Message flagged",
461
649
  fail: `Failed to flag message "${id}"`,
650
+ structured: { ok: true, id },
462
651
  }), "Error flagging message"));
463
652
  // --- unflag-message ---
464
- server.tool("unflag-message", "Use when: removing the flag from a single message (by id).\nReturns: a confirmation that the message was unflagged.\nDo not use when: unflagging several at once (use batch-unflag-messages) or adding a flag (use flag-message). Get the id from search-messages or list-messages first.", {
465
- id: MESSAGE_ID_SCHEMA,
653
+ server.registerTool("unflag-message", {
654
+ description: "Use when: removing the flag from a single message (by id).\nReturns: a confirmation that the message was unflagged.\nDo not use when: unflagging several at once (use batch-unflag-messages) or adding a flag (use flag-message). Get the id from search-messages or list-messages first.",
655
+ inputSchema: {
656
+ id: MESSAGE_ID_SCHEMA,
657
+ },
658
+ outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
466
659
  }, withErrorHandling(({ id }) => routeMessage(id, {
467
660
  imap: () => imapUnflagMessage(id),
468
661
  apple: () => mailManager.unflagMessage(id)
469
- ? successResponse("Message unflagged")
662
+ ? successResponse("Message unflagged", { ok: true, id })
470
663
  : errorResponse(`Failed to unflag message "${id}"`),
471
664
  ok: "Message unflagged",
472
665
  fail: `Failed to unflag message "${id}"`,
666
+ structured: { ok: true, id },
473
667
  }), "Error unflagging message"));
474
668
  // --- delete-message ---
475
- server.tool("delete-message", "Use when: deleting a single message by id (moves it to Trash).\nReturns: a confirmation that the message was deleted.\nDo not use when: deleting several at once (use batch-delete-messages) or just filing it away (use move-message).\nSafety: destructive — require explicit user confirmation, and search-messages/list-messages first to confirm you have the right id before deleting.", {
476
- id: MESSAGE_ID_SCHEMA,
669
+ server.registerTool("delete-message", {
670
+ description: "Use when: deleting a single message by id (moves it to Trash).\nReturns: a confirmation that the message was deleted.\nDo not use when: deleting several at once (use batch-delete-messages) or just filing it away (use move-message).\nSafety: destructive — require explicit user confirmation, and search-messages/list-messages first to confirm you have the right id before deleting.",
671
+ inputSchema: {
672
+ id: MESSAGE_ID_SCHEMA,
673
+ },
674
+ outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
477
675
  }, withErrorHandling(({ id }) => routeMessage(id, {
478
676
  imap: () => imapDeleteMessageById(id),
479
677
  apple: () => {
480
678
  const { success, error } = mailManager.deleteMessage(id);
481
679
  return success
482
- ? successResponse("Message deleted")
680
+ ? successResponse("Message deleted", { ok: true, id })
483
681
  : errorResponse(error || `Failed to delete message "${id}"`);
484
682
  },
485
683
  ok: "Message deleted",
486
684
  fail: `Failed to delete message "${id}"`,
685
+ structured: { ok: true, id },
487
686
  }), "Error deleting message"));
488
687
  // --- move-message ---
489
- server.tool("move-message", "Use when: moving a single message (by id) into another mailbox/folder, e.g. archiving or filing.\nReturns: a confirmation naming the destination mailbox.\nDo not use when: moving several at once (use batch-move-messages) or deleting (use delete-message). Use list-mailboxes to confirm the destination name exists.\nSafety: moves a real message between folders — confirm the destination mailbox, and search-messages/list-messages first to confirm the id.", {
490
- id: MESSAGE_ID_SCHEMA,
491
- mailbox: z.string().min(1, "Destination mailbox is required"),
492
- account: z.string().optional().describe("Account containing the destination mailbox"),
688
+ server.registerTool("move-message", {
689
+ description: "Use when: moving a single message (by id) into another mailbox/folder, e.g. archiving or filing.\nReturns: a confirmation naming the destination mailbox.\nDo not use when: moving several at once (use batch-move-messages) or deleting (use delete-message). Use list-mailboxes to confirm the destination name exists.\nSafety: moves a real message between folders — confirm the destination mailbox, and search-messages/list-messages first to confirm the id.",
690
+ inputSchema: {
691
+ id: MESSAGE_ID_SCHEMA,
692
+ mailbox: z.string().min(1, "Destination mailbox is required"),
693
+ account: z.string().optional().describe("Account containing the destination mailbox"),
694
+ },
695
+ outputSchema: {
696
+ ok: z.boolean().optional(),
697
+ id: z.string().optional(),
698
+ mailbox: z.string().optional(),
699
+ },
493
700
  }, withErrorHandling(({ id, mailbox, account }) => routeMessage(id, {
494
701
  imap: () => imapMoveMessageById(id, mailbox),
495
702
  apple: () => {
496
703
  const { success, error } = mailManager.moveMessage(id, mailbox, account);
497
704
  return success
498
- ? successResponse(`Message moved to "${mailbox}"`)
705
+ ? successResponse(`Message moved to "${mailbox}"`, { ok: true, id, mailbox })
499
706
  : errorResponse(error || `Failed to move message to "${mailbox}"`);
500
707
  },
501
708
  ok: `Message moved to "${mailbox}"`,
502
709
  fail: `Failed to move message to "${mailbox}"`,
710
+ structured: { ok: true, id, mailbox },
503
711
  }), "Error moving message"));
504
712
  // --- batch-delete-messages ---
505
- server.tool("batch-delete-messages", "Use when: deleting multiple messages in one call (1–100 ids; moves them to Trash).\nReturns: counts of how many were deleted and how many failed.\nDo not use when: deleting just one (use delete-message) or filing messages away (use batch-move-messages).\nSafety: destructive and applies to many messages at once — require explicit user confirmation, and search-messages/list-messages first to confirm every id is correct before deleting.", {
506
- ids: BATCH_IDS_SCHEMA,
713
+ server.registerTool("batch-delete-messages", {
714
+ description: "Use when: deleting multiple messages in one call (1–100 ids; moves them to Trash).\nReturns: counts of how many were deleted and how many failed.\nDo not use when: deleting just one (use delete-message) or filing messages away (use batch-move-messages).\nSafety: destructive and applies to many messages at once — require explicit user confirmation, and search-messages/list-messages first to confirm every id is correct before deleting.",
715
+ inputSchema: {
716
+ ids: BATCH_IDS_SCHEMA,
717
+ },
718
+ outputSchema: BATCH_COUNT_OUTPUT_SCHEMA,
507
719
  }, withErrorHandling(async ({ ids }) => {
508
720
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchDeleteMessages(n), (im) => imapBatchDelete(im));
721
+ const structured = { ok: failCount === 0, success: successCount, failed: failCount };
509
722
  if (failCount === 0) {
510
- return successResponse(`Successfully deleted ${successCount} message(s)`);
723
+ return successResponse(`Successfully deleted ${successCount} message(s)`, structured);
511
724
  }
512
725
  else if (successCount === 0) {
513
726
  return errorResponse(`Failed to delete all ${failCount} message(s)`);
514
727
  }
515
728
  else {
516
- return successResponse(`Deleted ${successCount} message(s), ${failCount} failed`);
729
+ return successResponse(`Deleted ${successCount} message(s), ${failCount} failed`, structured);
517
730
  }
518
731
  }, "Error batch deleting messages"));
519
732
  // --- batch-move-messages ---
520
- server.tool("batch-move-messages", "Use when: moving multiple messages (1–100 ids) into the same destination mailbox/folder in one call, e.g. bulk archiving.\nReturns: counts of how many were moved and how many failed.\nDo not use when: moving just one (use move-message) or deleting (use batch-delete-messages). Use list-mailboxes to confirm the destination name exists.\nSafety: moves many real messages at once — confirm the destination mailbox, and search-messages/list-messages first to confirm the ids.", {
521
- ids: BATCH_IDS_SCHEMA,
522
- mailbox: z.string().min(1, "Destination mailbox is required"),
523
- account: z.string().optional().describe("Account containing the destination mailbox"),
733
+ server.registerTool("batch-move-messages", {
734
+ description: "Use when: moving multiple messages (1–100 ids) into the same destination mailbox/folder in one call, e.g. bulk archiving.\nReturns: counts of how many were moved and how many failed.\nDo not use when: moving just one (use move-message) or deleting (use batch-delete-messages). Use list-mailboxes to confirm the destination name exists.\nSafety: moves many real messages at once — confirm the destination mailbox, and search-messages/list-messages first to confirm the ids.",
735
+ inputSchema: {
736
+ ids: BATCH_IDS_SCHEMA,
737
+ mailbox: z.string().min(1, "Destination mailbox is required"),
738
+ account: z.string().optional().describe("Account containing the destination mailbox"),
739
+ },
740
+ outputSchema: BATCH_COUNT_OUTPUT_SCHEMA,
524
741
  }, withErrorHandling(async ({ ids, mailbox, account }) => {
525
742
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMoveMessages(n, mailbox, account), (im) => imapBatchMove(im, mailbox, { account }));
743
+ const structured = { ok: failCount === 0, success: successCount, failed: failCount, mailbox };
526
744
  if (failCount === 0) {
527
- return successResponse(`Successfully moved ${successCount} message(s) to "${mailbox}"`);
745
+ return successResponse(`Successfully moved ${successCount} message(s) to "${mailbox}"`, structured);
528
746
  }
529
747
  else if (successCount === 0) {
530
748
  return errorResponse(`Failed to move all ${failCount} message(s)`);
531
749
  }
532
750
  else {
533
- return successResponse(`Moved ${successCount} message(s) to "${mailbox}", ${failCount} failed`);
751
+ return successResponse(`Moved ${successCount} message(s) to "${mailbox}", ${failCount} failed`, structured);
534
752
  }
535
753
  }, "Error batch moving messages"));
536
754
  // --- batch-mark-as-read ---
537
- server.tool("batch-mark-as-read", "Use when: marking multiple messages (1–100 ids) as read in one call.\nReturns: counts of how many were marked read and how many failed.\nDo not use when: marking just one (use mark-as-read) or marking unread (use batch-mark-as-unread). Get the ids from search-messages or list-messages first.", {
538
- ids: BATCH_IDS_SCHEMA,
755
+ server.registerTool("batch-mark-as-read", {
756
+ description: "Use when: marking multiple messages (1–100 ids) as read in one call.\nReturns: counts of how many were marked read and how many failed.\nDo not use when: marking just one (use mark-as-read) or marking unread (use batch-mark-as-unread). Get the ids from search-messages or list-messages first.",
757
+ inputSchema: {
758
+ ids: BATCH_IDS_SCHEMA,
759
+ },
760
+ outputSchema: BATCH_COUNT_OUTPUT_SCHEMA,
539
761
  }, withErrorHandling(async ({ ids }) => {
540
762
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMarkAsRead(n), (im) => imapBatchMarkRead(im));
763
+ const structured = { ok: failCount === 0, success: successCount, failed: failCount };
541
764
  if (failCount === 0) {
542
- return successResponse(`Successfully marked ${successCount} message(s) as read`);
765
+ return successResponse(`Successfully marked ${successCount} message(s) as read`, structured);
543
766
  }
544
767
  else if (successCount === 0) {
545
768
  return errorResponse(`Failed to mark all ${failCount} message(s) as read`);
546
769
  }
547
770
  else {
548
- return successResponse(`Marked ${successCount} message(s) as read, ${failCount} failed`);
771
+ return successResponse(`Marked ${successCount} message(s) as read, ${failCount} failed`, structured);
549
772
  }
550
773
  }, "Error batch marking messages as read"));
551
774
  // --- batch-mark-as-unread ---
552
- server.tool("batch-mark-as-unread", "Use when: marking multiple messages (1–100 ids) as unread in one call.\nReturns: counts of how many were marked unread and how many failed.\nDo not use when: marking just one (use mark-as-unread) or marking read (use batch-mark-as-read). Get the ids from search-messages or list-messages first.", {
553
- ids: BATCH_IDS_SCHEMA,
775
+ server.registerTool("batch-mark-as-unread", {
776
+ description: "Use when: marking multiple messages (1–100 ids) as unread in one call.\nReturns: counts of how many were marked unread and how many failed.\nDo not use when: marking just one (use mark-as-unread) or marking read (use batch-mark-as-read). Get the ids from search-messages or list-messages first.",
777
+ inputSchema: {
778
+ ids: BATCH_IDS_SCHEMA,
779
+ },
780
+ outputSchema: BATCH_COUNT_OUTPUT_SCHEMA,
554
781
  }, withErrorHandling(async ({ ids }) => {
555
782
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchMarkAsUnread(n), (im) => imapBatchMarkUnread(im));
783
+ const structured = { ok: failCount === 0, success: successCount, failed: failCount };
556
784
  if (failCount === 0) {
557
- return successResponse(`Successfully marked ${successCount} message(s) as unread`);
785
+ return successResponse(`Successfully marked ${successCount} message(s) as unread`, structured);
558
786
  }
559
787
  else if (successCount === 0) {
560
788
  return errorResponse(`Failed to mark all ${failCount} message(s) as unread`);
561
789
  }
562
790
  else {
563
- return successResponse(`Marked ${successCount} message(s) as unread, ${failCount} failed`);
791
+ return successResponse(`Marked ${successCount} message(s) as unread, ${failCount} failed`, structured);
564
792
  }
565
793
  }, "Error batch marking messages as unread"));
566
794
  // --- batch-flag-messages ---
567
- server.tool("batch-flag-messages", "Use when: flagging multiple messages (1–100 ids) in one call.\nReturns: counts of how many were flagged and how many failed.\nDo not use when: flagging just one (use flag-message) or removing flags (use batch-unflag-messages). Get the ids from search-messages or list-messages first.", {
568
- ids: BATCH_IDS_SCHEMA,
795
+ server.registerTool("batch-flag-messages", {
796
+ description: "Use when: flagging multiple messages (1–100 ids) in one call.\nReturns: counts of how many were flagged and how many failed.\nDo not use when: flagging just one (use flag-message) or removing flags (use batch-unflag-messages). Get the ids from search-messages or list-messages first.",
797
+ inputSchema: {
798
+ ids: BATCH_IDS_SCHEMA,
799
+ },
800
+ outputSchema: BATCH_COUNT_OUTPUT_SCHEMA,
569
801
  }, withErrorHandling(async ({ ids }) => {
570
802
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchFlagMessages(n), (im) => imapBatchFlag(im));
803
+ const structured = { ok: failCount === 0, success: successCount, failed: failCount };
571
804
  if (failCount === 0) {
572
- return successResponse(`Successfully flagged ${successCount} message(s)`);
805
+ return successResponse(`Successfully flagged ${successCount} message(s)`, structured);
573
806
  }
574
807
  else if (successCount === 0) {
575
808
  return errorResponse(`Failed to flag all ${failCount} message(s)`);
576
809
  }
577
810
  else {
578
- return successResponse(`Flagged ${successCount} message(s), ${failCount} failed`);
811
+ return successResponse(`Flagged ${successCount} message(s), ${failCount} failed`, structured);
579
812
  }
580
813
  }, "Error batch flagging messages"));
581
814
  // --- batch-unflag-messages ---
582
- server.tool("batch-unflag-messages", "Use when: removing flags from multiple messages (1–100 ids) in one call.\nReturns: counts of how many were unflagged and how many failed.\nDo not use when: unflagging just one (use unflag-message) or adding flags (use batch-flag-messages). Get the ids from search-messages or list-messages first.", {
583
- ids: BATCH_IDS_SCHEMA,
815
+ server.registerTool("batch-unflag-messages", {
816
+ description: "Use when: removing flags from multiple messages (1–100 ids) in one call.\nReturns: counts of how many were unflagged and how many failed.\nDo not use when: unflagging just one (use unflag-message) or adding flags (use batch-flag-messages). Get the ids from search-messages or list-messages first.",
817
+ inputSchema: {
818
+ ids: BATCH_IDS_SCHEMA,
819
+ },
820
+ outputSchema: BATCH_COUNT_OUTPUT_SCHEMA,
584
821
  }, withErrorHandling(async ({ ids }) => {
585
822
  const { success: successCount, fail: failCount } = await hybridBatchCounts(ids, (n) => mailManager.batchUnflagMessages(n), (im) => imapBatchUnflag(im));
823
+ const structured = { ok: failCount === 0, success: successCount, failed: failCount };
586
824
  if (failCount === 0) {
587
- return successResponse(`Successfully unflagged ${successCount} message(s)`);
825
+ return successResponse(`Successfully unflagged ${successCount} message(s)`, structured);
588
826
  }
589
827
  else if (successCount === 0) {
590
828
  return errorResponse(`Failed to unflag all ${failCount} message(s)`);
591
829
  }
592
830
  else {
593
- return successResponse(`Unflagged ${successCount} message(s), ${failCount} failed`);
831
+ return successResponse(`Unflagged ${successCount} message(s), ${failCount} failed`, structured);
594
832
  }
595
833
  }, "Error batch unflagging messages"));
596
834
  // --- list-attachments ---
597
- server.tool("list-attachments", "Use when: enumerating a message's attachments (by id) to discover their names, MIME types, and sizes — typically before saving or fetching one.\nReturns: each attachment's name, MIME type, and size, plus a count.\nDo not use when: you want the bytes (use fetch-attachment for inline base64, or save-attachment to write to disk). Get the message id from search-messages or list-messages first.", {
598
- id: MESSAGE_ID_SCHEMA,
835
+ server.registerTool("list-attachments", {
836
+ description: "Use when: enumerating a message's attachments (by id) to discover their names, MIME types, and sizes — typically before saving or fetching one.\nReturns: each attachment's name, MIME type, and size, plus a count.\nDo not use when: you want the bytes (use fetch-attachment for inline base64, or save-attachment to write to disk). Get the message id from search-messages or list-messages first.",
837
+ inputSchema: {
838
+ id: MESSAGE_ID_SCHEMA,
839
+ },
840
+ outputSchema: {
841
+ attachments: z.array(z.object({}).passthrough()).optional(),
842
+ count: z.number().optional(),
843
+ },
599
844
  }, withErrorHandling(async ({ id }) => {
600
845
  // IMAP (I1): BODYSTRUCTURE enumerates parts (incl. MIME attachments
601
846
  // AppleScript can't see) without downloading the message.
@@ -620,10 +865,18 @@ server.tool("list-attachments", "Use when: enumerating a message's attachments (
620
865
  return successResponse(`Found ${attachments.length} attachment(s):\n${attachmentList}`, structured);
621
866
  }, "Error listing attachments"));
622
867
  // --- save-attachment ---
623
- server.tool("save-attachment", "Use when: writing one of a message's attachments to disk, by message id and attachmentName, into the savePath directory (saved as savePath/attachmentName).\nReturns: a confirmation of the saved file path.\nDo not use when: you don't know the attachment name (use list-attachments first) or want the bytes inline rather than on disk (use fetch-attachment).\nSafety: writes a file to disk — savePath must be a directory inside the configured allowed roots, and attachmentName may not contain path separators or '..'; calls outside those constraints are rejected.", {
624
- id: MESSAGE_ID_SCHEMA,
625
- attachmentName: z.string().min(1, "Attachment name is required"),
626
- savePath: z.string().min(1, "Save directory path is required"),
868
+ server.registerTool("save-attachment", {
869
+ description: "Use when: writing one of a message's attachments to disk, by message id and attachmentName, into the savePath directory (saved as savePath/attachmentName).\nReturns: a confirmation of the saved file path.\nDo not use when: you don't know the attachment name (use list-attachments first) or want the bytes inline rather than on disk (use fetch-attachment).\nSafety: writes a file to disk — savePath must be a directory inside the configured allowed roots, and attachmentName may not contain path separators or '..'; calls outside those constraints are rejected.",
870
+ inputSchema: {
871
+ id: MESSAGE_ID_SCHEMA,
872
+ attachmentName: z.string().min(1, "Attachment name is required"),
873
+ savePath: z.string().min(1, "Save directory path is required"),
874
+ },
875
+ outputSchema: {
876
+ ok: z.boolean().optional(),
877
+ attachmentName: z.string().optional(),
878
+ savedPath: z.string().optional(),
879
+ },
627
880
  }, withErrorHandling(async ({ id, attachmentName, savePath }) => {
628
881
  // IMAP (I1): fetch the part's bytes via IMAP, then write into savePath (a
629
882
  // directory) as savePath/attachmentName — mirroring the AppleScript path,
@@ -640,19 +893,37 @@ server.tool("save-attachment", "Use when: writing one of a message's attachments
640
893
  if (!r.success || !r.base64) {
641
894
  return errorResponse(r.error || `Failed to fetch attachment "${attachmentName}"`);
642
895
  }
643
- writeFileSync(joinPath(resolvedDir, attachmentName), Buffer.from(r.base64, "base64"));
644
- return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
896
+ const savedPath = joinPath(resolvedDir, attachmentName);
897
+ writeFileSync(savedPath, Buffer.from(r.base64, "base64"));
898
+ return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`, {
899
+ ok: true,
900
+ attachmentName,
901
+ savedPath,
902
+ });
645
903
  }
646
904
  const success = mailManager.saveAttachment(id, attachmentName, savePath);
647
905
  if (!success) {
648
906
  return errorResponse(`Failed to save attachment "${attachmentName}"`);
649
907
  }
650
- return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`);
908
+ return successResponse(`Attachment "${attachmentName}" saved to ${savePath}`, {
909
+ ok: true,
910
+ attachmentName,
911
+ savedPath: joinPath(savePath, attachmentName),
912
+ });
651
913
  }, "Error saving attachment"));
652
914
  // --- fetch-attachment ---
653
- server.tool("fetch-attachment", "Use when: retrieving an attachment's raw bytes inline as base64 (by message id and attachmentName), e.g. to process its contents without touching disk.\nReturns: the attachment's bytes base64-encoded, with its size and (for IMAP) MIME type.\nDo not use when: you don't know the attachment name (use list-attachments first) or you just want it saved to disk (use save-attachment).", {
654
- id: MESSAGE_ID_SCHEMA,
655
- attachmentName: z.string().min(1, "Attachment name is required"),
915
+ server.registerTool("fetch-attachment", {
916
+ description: "Use when: retrieving an attachment's raw bytes inline as base64 (by message id and attachmentName), e.g. to process its contents without touching disk.\nReturns: the attachment's bytes base64-encoded, with its size and (for IMAP) MIME type.\nDo not use when: you don't know the attachment name (use list-attachments first) or you just want it saved to disk (use save-attachment).",
917
+ inputSchema: {
918
+ id: MESSAGE_ID_SCHEMA,
919
+ attachmentName: z.string().min(1, "Attachment name is required"),
920
+ },
921
+ outputSchema: {
922
+ attachmentName: z.string().optional(),
923
+ bytes: z.number().optional(),
924
+ mimeType: z.string().optional(),
925
+ contentBase64: z.string().optional(),
926
+ },
656
927
  }, withErrorHandling(async ({ id, attachmentName }) => {
657
928
  // Returns the attachment bytes as base64 (B4) — the read counterpart to
658
929
  // sending inline base64 content. IMAP (I1) fetches the part directly; numeric
@@ -674,8 +945,15 @@ server.tool("fetch-attachment", "Use when: retrieving an attachment's raw bytes
674
945
  // Mailbox Tools
675
946
  // =============================================================================
676
947
  // --- list-mailboxes ---
677
- server.tool("list-mailboxes", "Use when: discovering the mailbox/folder names (and unread/message counts) available in an account, e.g. before moving messages or searching a specific mailbox.\nReturns: each mailbox's name with its unread (and, for IMAP, total message) count, plus a count.\nDo not use when: you want the messages inside a mailbox (use list-messages or search-messages) or the list of accounts (use list-accounts).", {
678
- account: z.string().optional().describe("Account to list mailboxes from"),
948
+ server.registerTool("list-mailboxes", {
949
+ description: "Use when: discovering the mailbox/folder names (and unread/message counts) available in an account, e.g. before moving messages or searching a specific mailbox.\nReturns: each mailbox's name with its unread (and, for IMAP, total message) count, plus a count.\nDo not use when: you want the messages inside a mailbox (use list-messages or search-messages) or the list of accounts (use list-accounts).",
950
+ inputSchema: {
951
+ account: z.string().optional().describe("Account to list mailboxes from"),
952
+ },
953
+ outputSchema: {
954
+ mailboxes: z.array(z.object({}).passthrough()).optional(),
955
+ count: z.number().optional(),
956
+ },
679
957
  }, withErrorHandling(async ({ account }) => {
680
958
  // IMAP (I6): LIST + per-mailbox STATUS — sees the true server hierarchy and
681
959
  // authoritative counts; falls back to AppleScript for non-IMAP accounts.
@@ -703,9 +981,17 @@ server.tool("list-mailboxes", "Use when: discovering the mailbox/folder names (a
703
981
  return successResponse(`Found ${mailboxes.length} mailbox(es):\n${mailboxList}`, structured);
704
982
  }, "Error listing mailboxes"));
705
983
  // --- get-unread-count ---
706
- server.tool("get-unread-count", "Use when: you only need the number of unread messages (optionally scoped to one mailbox and/or account), without listing the messages themselves.\nReturns: the unread count for the requested scope.\nDo not use when: you need the actual unread messages and their ids (use list-messages with unreadOnly, or search-messages with isRead=false) or broader totals (use get-mail-stats).", {
707
- mailbox: z.string().optional().describe("Mailbox to check (default: all)"),
708
- account: z.string().optional().describe("Account to check"),
984
+ server.registerTool("get-unread-count", {
985
+ description: "Use when: you only need the number of unread messages (optionally scoped to one mailbox and/or account), without listing the messages themselves.\nReturns: the unread count for the requested scope.\nDo not use when: you need the actual unread messages and their ids (use list-messages with unreadOnly, or search-messages with isRead=false) or broader totals (use get-mail-stats).",
986
+ inputSchema: {
987
+ mailbox: z.string().optional().describe("Mailbox to check (default: all)"),
988
+ account: z.string().optional().describe("Account to check"),
989
+ },
990
+ outputSchema: {
991
+ unread: z.number().optional(),
992
+ mailbox: z.string().optional(),
993
+ account: z.string().optional(),
994
+ },
709
995
  }, withErrorHandling(async ({ mailbox, account }) => {
710
996
  // IMAP (I4): STATUS (UNSEEN) is authoritative and fast even on huge
711
997
  // mailboxes; falls back to AppleScript for non-IMAP accounts.
@@ -720,9 +1006,16 @@ server.tool("get-unread-count", "Use when: you only need the number of unread me
720
1006
  });
721
1007
  }, "Error getting unread count"));
722
1008
  // --- create-mailbox ---
723
- server.tool("create-mailbox", "Use when: creating a new mailbox/folder in an account.\nReturns: a confirmation that the mailbox was created.\nDo not use when: renaming an existing one (use rename-mailbox) or deleting one (use delete-mailbox). Use list-mailboxes to see what already exists.\nSafety: creates a real folder in the mail account — confirm the name and target account first.", {
724
- name: z.string().min(1, "Mailbox name is required"),
725
- account: z.string().optional().describe("Account to create the mailbox in"),
1009
+ server.registerTool("create-mailbox", {
1010
+ description: "Use when: creating a new mailbox/folder in an account.\nReturns: a confirmation that the mailbox was created.\nDo not use when: renaming an existing one (use rename-mailbox) or deleting one (use delete-mailbox). Use list-mailboxes to see what already exists.\nSafety: creates a real folder in the mail account — confirm the name and target account first.",
1011
+ inputSchema: {
1012
+ name: z.string().min(1, "Mailbox name is required"),
1013
+ account: z.string().optional().describe("Account to create the mailbox in"),
1014
+ },
1015
+ outputSchema: {
1016
+ ok: z.boolean().optional(),
1017
+ name: z.string().optional(),
1018
+ },
726
1019
  }, withErrorHandling(async ({ name, account }) => {
727
1020
  // IMAP backend (issue #43, Phase 2): server-side folder op when this account
728
1021
  // is IMAP-configured; otherwise AppleScript.
@@ -730,55 +1023,85 @@ server.tool("create-mailbox", "Use when: creating a new mailbox/folder in an acc
730
1023
  const r = await imapCreateMailbox(name, { account });
731
1024
  if (!r.success)
732
1025
  return errorResponse(r.error || `Failed to create mailbox "${name}"`);
733
- return successResponse(r.info || `Mailbox "${name}" created`);
1026
+ return successResponse(r.info || `Mailbox "${name}" created`, { ok: true, name });
734
1027
  }
735
1028
  const success = mailManager.createMailbox(name, account);
736
1029
  if (!success) {
737
1030
  return errorResponse(`Failed to create mailbox "${name}"`);
738
1031
  }
739
- return successResponse(`Mailbox "${name}" created`);
1032
+ return successResponse(`Mailbox "${name}" created`, { ok: true, name });
740
1033
  }, "Error creating mailbox"));
741
1034
  // --- delete-mailbox ---
742
- server.tool("delete-mailbox", "Use when: deleting a mailbox/folder from an account.\nReturns: a confirmation that the mailbox was deleted.\nDo not use when: renaming it (use rename-mailbox) or deleting messages within it (use delete-message / batch-delete-messages).\nSafety: destructive — deleting a mailbox removes the folder and any messages it contains. Require explicit user confirmation and use list-mailboxes first to confirm the exact name.", {
743
- name: z.string().min(1, "Mailbox name is required"),
744
- account: z.string().optional().describe("Account containing the mailbox"),
1035
+ server.registerTool("delete-mailbox", {
1036
+ description: "Use when: deleting a mailbox/folder from an account.\nReturns: a confirmation that the mailbox was deleted.\nDo not use when: renaming it (use rename-mailbox) or deleting messages within it (use delete-message / batch-delete-messages).\nSafety: destructive — deleting a mailbox removes the folder and any messages it contains. Require explicit user confirmation and use list-mailboxes first to confirm the exact name.",
1037
+ inputSchema: {
1038
+ name: z.string().min(1, "Mailbox name is required"),
1039
+ account: z.string().optional().describe("Account containing the mailbox"),
1040
+ },
1041
+ outputSchema: {
1042
+ ok: z.boolean().optional(),
1043
+ name: z.string().optional(),
1044
+ },
745
1045
  }, withErrorHandling(async ({ name, account }) => {
746
1046
  if (isImapAccount(account)) {
747
1047
  const r = await imapDeleteMailbox(name, { account });
748
1048
  if (!r.success)
749
1049
  return errorResponse(r.error || `Failed to delete mailbox "${name}"`);
750
- return successResponse(r.info || `Mailbox "${name}" deleted`);
1050
+ return successResponse(r.info || `Mailbox "${name}" deleted`, { ok: true, name });
751
1051
  }
752
1052
  const { success, error } = mailManager.deleteMailbox(name, account);
753
1053
  if (!success) {
754
1054
  return errorResponse(error || `Failed to delete mailbox "${name}"`);
755
1055
  }
756
- return successResponse(`Mailbox "${name}" deleted`);
1056
+ return successResponse(`Mailbox "${name}" deleted`, { ok: true, name });
757
1057
  }, "Error deleting mailbox"));
758
1058
  // --- rename-mailbox ---
759
- server.tool("rename-mailbox", "Use when: renaming an existing mailbox/folder from oldName to newName within an account.\nReturns: a confirmation naming the old and new mailbox names.\nDo not use when: creating a new folder (use create-mailbox) or deleting one (use delete-mailbox). Use list-mailboxes to confirm the current name.\nSafety: renames a real folder in the mail account — confirm oldName matches exactly (case-sensitive) before calling.", {
760
- oldName: z.string().min(1, "Current mailbox name is required"),
761
- newName: z.string().min(1, "New mailbox name is required"),
762
- account: z.string().optional().describe("Account containing the mailbox"),
1059
+ server.registerTool("rename-mailbox", {
1060
+ description: "Use when: renaming an existing mailbox/folder from oldName to newName within an account.\nReturns: a confirmation naming the old and new mailbox names.\nDo not use when: creating a new folder (use create-mailbox) or deleting one (use delete-mailbox). Use list-mailboxes to confirm the current name.\nSafety: renames a real folder in the mail account — confirm oldName matches exactly (case-sensitive) before calling.",
1061
+ inputSchema: {
1062
+ oldName: z.string().min(1, "Current mailbox name is required"),
1063
+ newName: z.string().min(1, "New mailbox name is required"),
1064
+ account: z.string().optional().describe("Account containing the mailbox"),
1065
+ },
1066
+ outputSchema: {
1067
+ ok: z.boolean().optional(),
1068
+ oldName: z.string().optional(),
1069
+ newName: z.string().optional(),
1070
+ },
763
1071
  }, withErrorHandling(async ({ oldName, newName, account }) => {
764
1072
  if (isImapAccount(account)) {
765
1073
  const r = await imapRenameMailbox(oldName, newName, { account });
766
1074
  if (!r.success) {
767
1075
  return errorResponse(r.error || `Failed to rename mailbox "${oldName}" to "${newName}"`);
768
1076
  }
769
- return successResponse(r.info || `Mailbox renamed from "${oldName}" to "${newName}"`);
1077
+ return successResponse(r.info || `Mailbox renamed from "${oldName}" to "${newName}"`, {
1078
+ ok: true,
1079
+ oldName,
1080
+ newName,
1081
+ });
770
1082
  }
771
1083
  const { success, error } = mailManager.renameMailbox(oldName, newName, account);
772
1084
  if (!success) {
773
1085
  return errorResponse(error || `Failed to rename mailbox "${oldName}" to "${newName}"`);
774
1086
  }
775
- return successResponse(`Mailbox renamed from "${oldName}" to "${newName}"`);
1087
+ return successResponse(`Mailbox renamed from "${oldName}" to "${newName}"`, {
1088
+ ok: true,
1089
+ oldName,
1090
+ newName,
1091
+ });
776
1092
  }, "Error renaming mailbox"));
777
1093
  // =============================================================================
778
1094
  // Account Tools
779
1095
  // =============================================================================
780
1096
  // --- list-accounts ---
781
- server.tool("list-accounts", "Use when: discovering the configured Mail accounts (e.g. iCloud, Gmail) so you can pass an exact account name to other tools.\nReturns: the account names and a count.\nDo not use when: you want the folders within an account (use list-mailboxes) or messages (use list-messages / search-messages).", {}, withErrorHandling(() => {
1097
+ server.registerTool("list-accounts", {
1098
+ description: "Use when: discovering the configured Mail accounts (e.g. iCloud, Gmail) so you can pass an exact account name to other tools.\nReturns: the account names and a count.\nDo not use when: you want the folders within an account (use list-mailboxes) or messages (use list-messages / search-messages).",
1099
+ inputSchema: {},
1100
+ outputSchema: {
1101
+ accounts: z.array(z.object({}).passthrough()).optional(),
1102
+ count: z.number().optional(),
1103
+ },
1104
+ }, withErrorHandling(() => {
782
1105
  const accounts = mailManager.listAccounts();
783
1106
  const structured = { accounts, count: accounts.length };
784
1107
  if (accounts.length === 0) {
@@ -791,59 +1114,93 @@ server.tool("list-accounts", "Use when: discovering the configured Mail accounts
791
1114
  // Mail Rules Tools
792
1115
  // =============================================================================
793
1116
  // --- list-rules ---
794
- server.tool("list-rules", "Use when: discovering the Mail rules that exist and whether each is enabled or disabled, e.g. before enabling/disabling/deleting one.\nReturns: each rule's name and enabled/disabled state.\nDo not use when: you want to change a rule (use enable-rule / disable-rule / create-rule / delete-rule).", {}, withErrorHandling(() => {
1117
+ server.registerTool("list-rules", {
1118
+ description: "Use when: discovering the Mail rules that exist and whether each is enabled or disabled, e.g. before enabling/disabling/deleting one.\nReturns: each rule's name and enabled/disabled state.\nDo not use when: you want to change a rule (use enable-rule / disable-rule / create-rule / delete-rule).",
1119
+ inputSchema: {},
1120
+ outputSchema: {
1121
+ rules: z.array(z.object({}).passthrough()).optional(),
1122
+ count: z.number().optional(),
1123
+ },
1124
+ }, withErrorHandling(() => {
795
1125
  const rules = mailManager.listRules();
1126
+ const structured = {
1127
+ rules: rules.map((r) => ({ name: r.name, enabled: r.enabled })),
1128
+ count: rules.length,
1129
+ };
796
1130
  if (rules.length === 0) {
797
- return successResponse("No mail rules found");
1131
+ return successResponse("No mail rules found", structured);
798
1132
  }
799
1133
  const ruleList = rules
800
1134
  .map((r) => ` - ${r.name} [${r.enabled ? "enabled" : "disabled"}]`)
801
1135
  .join("\n");
802
- return successResponse(`Found ${rules.length} rule(s):\n${ruleList}`);
1136
+ return successResponse(`Found ${rules.length} rule(s):\n${ruleList}`, structured);
803
1137
  }, "Error listing rules"));
804
1138
  // --- enable-rule ---
805
- server.tool("enable-rule", "Use when: turning on an existing Mail rule by name.\nReturns: a confirmation that the rule was enabled.\nDo not use when: turning a rule off (use disable-rule), creating one (use create-rule), or deleting one (use delete-rule). Use list-rules to confirm the exact rule name.", {
806
- name: z.string().min(1, "Rule name is required"),
1139
+ server.registerTool("enable-rule", {
1140
+ description: "Use when: turning on an existing Mail rule by name.\nReturns: a confirmation that the rule was enabled.\nDo not use when: turning a rule off (use disable-rule), creating one (use create-rule), or deleting one (use delete-rule). Use list-rules to confirm the exact rule name.",
1141
+ inputSchema: {
1142
+ name: z.string().min(1, "Rule name is required"),
1143
+ },
1144
+ outputSchema: {
1145
+ ok: z.boolean().optional(),
1146
+ name: z.string().optional(),
1147
+ enabled: z.boolean().optional(),
1148
+ },
807
1149
  }, withErrorHandling(({ name }) => {
808
1150
  const success = mailManager.setRuleEnabled(name, true);
809
1151
  if (!success) {
810
1152
  return errorResponse(`Failed to enable rule "${name}"`);
811
1153
  }
812
- return successResponse(`Rule "${name}" enabled`);
1154
+ return successResponse(`Rule "${name}" enabled`, { ok: true, name, enabled: true });
813
1155
  }, "Error enabling rule"));
814
1156
  // --- disable-rule ---
815
- server.tool("disable-rule", "Use when: turning off an existing Mail rule by name (without deleting it).\nReturns: a confirmation that the rule was disabled.\nDo not use when: turning a rule on (use enable-rule), creating one (use create-rule), or removing it permanently (use delete-rule). Use list-rules to confirm the exact rule name.", {
816
- name: z.string().min(1, "Rule name is required"),
1157
+ server.registerTool("disable-rule", {
1158
+ description: "Use when: turning off an existing Mail rule by name (without deleting it).\nReturns: a confirmation that the rule was disabled.\nDo not use when: turning a rule on (use enable-rule), creating one (use create-rule), or removing it permanently (use delete-rule). Use list-rules to confirm the exact rule name.",
1159
+ inputSchema: {
1160
+ name: z.string().min(1, "Rule name is required"),
1161
+ },
1162
+ outputSchema: {
1163
+ ok: z.boolean().optional(),
1164
+ name: z.string().optional(),
1165
+ enabled: z.boolean().optional(),
1166
+ },
817
1167
  }, withErrorHandling(({ name }) => {
818
1168
  const success = mailManager.setRuleEnabled(name, false);
819
1169
  if (!success) {
820
1170
  return errorResponse(`Failed to disable rule "${name}"`);
821
1171
  }
822
- return successResponse(`Rule "${name}" disabled`);
1172
+ return successResponse(`Rule "${name}" disabled`, { ok: true, name, enabled: false });
823
1173
  }, "Error disabling rule"));
824
1174
  // --- create-rule ---
825
- server.tool("create-rule", "Use when: creating a new Mail rule with one or more conditions (field/operator/value) and at least one action (markRead, markFlagged, delete, or moveTo). Set matchAll to require all conditions vs. any.\nReturns: a confirmation naming the rule and its condition count.\nDo not use when: toggling an existing rule (use enable-rule / disable-rule) or removing one (use delete-rule). Use list-rules to avoid duplicating an existing rule.\nSafety: creates a rule that automatically acts on real mail (including delete/move actions) on an ongoing basis — confirm the conditions and actions with the user before calling.", {
826
- name: z.string().min(1, "Rule name is required"),
827
- conditions: z
828
- .array(z.object({
829
- field: z.enum(["from", "to", "cc", "subject", "content"]),
830
- operator: z
831
- .enum(["contains", "notContains", "equals", "beginsWith", "endsWith"])
832
- .default("contains"),
833
- value: z.string().min(1, "Condition value is required"),
834
- }))
835
- .min(1, "At least one condition is required"),
836
- actions: z
837
- .object({
838
- markRead: z.boolean().optional(),
839
- markFlagged: z.boolean().optional(),
840
- delete: z.boolean().optional(),
841
- moveTo: z.string().optional(),
842
- moveToAccount: z.string().optional(),
843
- })
844
- .refine((a) => a.markRead || a.markFlagged || a.delete || a.moveTo, "At least one action is required (markRead, markFlagged, delete, or moveTo)"),
845
- matchAll: z.boolean().default(true),
846
- enabled: z.boolean().default(true),
1175
+ server.registerTool("create-rule", {
1176
+ description: "Use when: creating a new Mail rule with one or more conditions (field/operator/value) and at least one action (markRead, markFlagged, delete, or moveTo). Set matchAll to require all conditions vs. any.\nReturns: a confirmation naming the rule and its condition count.\nDo not use when: toggling an existing rule (use enable-rule / disable-rule) or removing one (use delete-rule). Use list-rules to avoid duplicating an existing rule.\nSafety: creates a rule that automatically acts on real mail (including delete/move actions) on an ongoing basis — confirm the conditions and actions with the user before calling.",
1177
+ inputSchema: {
1178
+ name: z.string().min(1, "Rule name is required"),
1179
+ conditions: z
1180
+ .array(z.object({
1181
+ field: z.enum(["from", "to", "cc", "subject", "content"]),
1182
+ operator: z
1183
+ .enum(["contains", "notContains", "equals", "beginsWith", "endsWith"])
1184
+ .default("contains"),
1185
+ value: z.string().min(1, "Condition value is required"),
1186
+ }))
1187
+ .min(1, "At least one condition is required"),
1188
+ actions: z
1189
+ .object({
1190
+ markRead: z.boolean().optional(),
1191
+ markFlagged: z.boolean().optional(),
1192
+ delete: z.boolean().optional(),
1193
+ moveTo: z.string().optional(),
1194
+ moveToAccount: z.string().optional(),
1195
+ })
1196
+ .refine((a) => a.markRead || a.markFlagged || a.delete || a.moveTo, "At least one action is required (markRead, markFlagged, delete, or moveTo)"),
1197
+ matchAll: z.boolean().default(true),
1198
+ enabled: z.boolean().default(true),
1199
+ },
1200
+ outputSchema: {
1201
+ name: z.string().optional(),
1202
+ created: z.boolean().optional(),
1203
+ },
847
1204
  }, withErrorHandling((args) => {
848
1205
  const result = mailManager.createRule(args);
849
1206
  if (!result.success) {
@@ -852,8 +1209,15 @@ server.tool("create-rule", "Use when: creating a new Mail rule with one or more
852
1209
  return successResponse(`Rule "${args.name}" created with ${args.conditions.length} condition(s).`, { name: args.name, created: true });
853
1210
  }, "Error creating rule"));
854
1211
  // --- delete-rule ---
855
- server.tool("delete-rule", "Use when: permanently removing a Mail rule by name.\nReturns: a confirmation that the rule was deleted.\nDo not use when: you only want to pause it (use disable-rule) or create one (use create-rule).\nSafety: destructive — the rule is removed permanently. Require explicit user confirmation and use list-rules first to confirm the exact name.", {
856
- name: z.string().min(1, "Rule name is required"),
1212
+ server.registerTool("delete-rule", {
1213
+ description: "Use when: permanently removing a Mail rule by name.\nReturns: a confirmation that the rule was deleted.\nDo not use when: you only want to pause it (use disable-rule) or create one (use create-rule).\nSafety: destructive — the rule is removed permanently. Require explicit user confirmation and use list-rules first to confirm the exact name.",
1214
+ inputSchema: {
1215
+ name: z.string().min(1, "Rule name is required"),
1216
+ },
1217
+ outputSchema: {
1218
+ name: z.string().optional(),
1219
+ deleted: z.boolean().optional(),
1220
+ },
857
1221
  }, withErrorHandling(({ name }) => {
858
1222
  const success = mailManager.deleteRule(name);
859
1223
  if (!success) {
@@ -865,12 +1229,23 @@ server.tool("delete-rule", "Use when: permanently removing a Mail rule by name.\
865
1229
  // Contacts Tools
866
1230
  // =============================================================================
867
1231
  // --- search-contacts ---
868
- server.tool("search-contacts", "Use when: looking up a person in Contacts.app by name to find their email address(es) before composing or sending mail.\nReturns: matching contacts with their names and email addresses.\nDo not use when: searching email messages (use search-messages) — this queries Contacts, not the mailbox.", {
869
- query: z.string().min(1, "Search query is required"),
1232
+ server.registerTool("search-contacts", {
1233
+ description: "Use when: looking up a person in Contacts.app by name to find their email address(es) before composing or sending mail.\nReturns: matching contacts with their names and email addresses.\nDo not use when: searching email messages (use search-messages) — this queries Contacts, not the mailbox.",
1234
+ inputSchema: {
1235
+ query: z.string().min(1, "Search query is required"),
1236
+ },
1237
+ outputSchema: {
1238
+ contacts: z.array(z.object({}).passthrough()).optional(),
1239
+ count: z.number().optional(),
1240
+ },
870
1241
  }, withErrorHandling(({ query }) => {
871
1242
  const contacts = mailManager.searchContacts(query);
1243
+ const structured = {
1244
+ contacts: contacts.map((c) => ({ name: c.name, emails: c.emails })),
1245
+ count: contacts.length,
1246
+ };
872
1247
  if (contacts.length === 0) {
873
- return successResponse("No contacts found");
1248
+ return successResponse("No contacts found", structured);
874
1249
  }
875
1250
  const contactList = contacts
876
1251
  .map((c) => {
@@ -878,37 +1253,71 @@ server.tool("search-contacts", "Use when: looking up a person in Contacts.app by
878
1253
  return ` - ${c.name} (${emails})`;
879
1254
  })
880
1255
  .join("\n");
881
- return successResponse(`Found ${contacts.length} contact(s):\n${contactList}`);
1256
+ return successResponse(`Found ${contacts.length} contact(s):\n${contactList}`, structured);
882
1257
  }, "Error searching contacts"));
883
1258
  // =============================================================================
884
1259
  // Email Template Tools
885
1260
  // =============================================================================
886
1261
  // --- save-template ---
887
- server.tool("save-template", "Use when: creating a reusable email template (name, subject, body, optional default to/cc), or updating one by passing its existing id. Subject/body may contain placeholders for later use.\nReturns: the saved template's name and id (reuse the id with use-template / get-template / delete-template).\nDo not use when: composing a one-off message (use create-draft / send-email) or filling in a template to send (use use-template).\nSafety: writes the template to the on-disk templates store (APPLE_MAIL_MCP_TEMPLATES_FILE) and persists across restarts; passing an existing id overwrites that template.", {
888
- name: z.string().min(1, "Template name is required"),
889
- subject: z.string().min(1, "Subject is required"),
890
- body: z.string().min(1, "Body is required"),
891
- to: z.array(z.string()).optional().describe("Default recipients"),
892
- cc: z.array(z.string()).optional().describe("Default CC recipients"),
893
- id: z.string().optional().describe("Template ID (for updating existing template)"),
1262
+ server.registerTool("save-template", {
1263
+ description: "Use when: creating a reusable email template (name, subject, body, optional default to/cc), or updating one by passing its existing id. Subject/body may contain placeholders for later use.\nReturns: the saved template's name and id (reuse the id with use-template / get-template / delete-template).\nDo not use when: composing a one-off message (use create-draft / send-email) or filling in a template to send (use use-template).\nSafety: writes the template to the on-disk templates store (APPLE_MAIL_MCP_TEMPLATES_FILE) and persists across restarts; passing an existing id overwrites that template.",
1264
+ inputSchema: {
1265
+ name: z.string().min(1, "Template name is required"),
1266
+ subject: z.string().min(1, "Subject is required"),
1267
+ body: z.string().min(1, "Body is required"),
1268
+ to: z.array(z.string()).optional().describe("Default recipients"),
1269
+ cc: z.array(z.string()).optional().describe("Default CC recipients"),
1270
+ id: z.string().optional().describe("Template ID (for updating existing template)"),
1271
+ },
1272
+ outputSchema: {
1273
+ ok: z.boolean().optional(),
1274
+ id: z.string().optional(),
1275
+ name: z.string().optional(),
1276
+ },
894
1277
  }, withErrorHandling(({ name, subject, body, to, cc, id }) => {
895
1278
  const template = mailManager.saveTemplate(name, subject, body, to, cc, id);
896
- return successResponse(`Template "${template.name}" saved with ID: ${template.id}`);
1279
+ return successResponse(`Template "${template.name}" saved with ID: ${template.id}`, {
1280
+ ok: true,
1281
+ id: template.id,
1282
+ name: template.name,
1283
+ });
897
1284
  }, "Error saving template"));
898
1285
  // --- list-templates ---
899
- server.tool("list-templates", "Use when: discovering the saved email templates and their ids, e.g. before using or editing one.\nReturns: each template's id, name, and subject.\nDo not use when: you want a single template's full body (use get-template) or want to apply one (use use-template).", {}, withErrorHandling(() => {
1286
+ server.registerTool("list-templates", {
1287
+ description: "Use when: discovering the saved email templates and their ids, e.g. before using or editing one.\nReturns: each template's id, name, and subject.\nDo not use when: you want a single template's full body (use get-template) or want to apply one (use use-template).",
1288
+ inputSchema: {},
1289
+ outputSchema: {
1290
+ templates: z.array(z.object({}).passthrough()).optional(),
1291
+ count: z.number().optional(),
1292
+ },
1293
+ }, withErrorHandling(() => {
900
1294
  const templates = mailManager.listTemplates();
1295
+ const structured = {
1296
+ templates: templates.map((t) => ({ id: t.id, name: t.name, subject: t.subject })),
1297
+ count: templates.length,
1298
+ };
901
1299
  if (templates.length === 0) {
902
- return successResponse("No templates saved");
1300
+ return successResponse("No templates saved", structured);
903
1301
  }
904
1302
  const templateList = templates
905
1303
  .map((t) => ` - [${t.id}] ${t.name} — "${t.subject}"`)
906
1304
  .join("\n");
907
- return successResponse(`Found ${templates.length} template(s):\n${templateList}`);
1305
+ return successResponse(`Found ${templates.length} template(s):\n${templateList}`, structured);
908
1306
  }, "Error listing templates"));
909
1307
  // --- get-template ---
910
- server.tool("get-template", "Use when: reading the full contents of one saved template by id — its name, subject, default to/cc, and body.\nReturns: the template's name, subject, default recipients, and body text.\nDo not use when: you don't have the id (use list-templates first) or want to apply the template into a draft (use use-template).", {
911
- id: z.string().min(1, "Template ID is required"),
1308
+ server.registerTool("get-template", {
1309
+ description: "Use when: reading the full contents of one saved template by id — its name, subject, default to/cc, and body.\nReturns: the template's name, subject, default recipients, and body text.\nDo not use when: you don't have the id (use list-templates first) or want to apply the template into a draft (use use-template).",
1310
+ inputSchema: {
1311
+ id: z.string().min(1, "Template ID is required"),
1312
+ },
1313
+ outputSchema: {
1314
+ id: z.string().optional(),
1315
+ name: z.string().optional(),
1316
+ subject: z.string().optional(),
1317
+ to: z.array(z.string()).optional(),
1318
+ cc: z.array(z.string()).optional(),
1319
+ body: z.string().optional(),
1320
+ },
912
1321
  }, withErrorHandling(({ id }) => {
913
1322
  const template = mailManager.getTemplate(id);
914
1323
  if (!template) {
@@ -923,37 +1332,65 @@ server.tool("get-template", "Use when: reading the full contents of one saved te
923
1332
  ]
924
1333
  .filter(Boolean)
925
1334
  .join("\n");
926
- return successResponse(lines);
1335
+ return successResponse(lines, {
1336
+ id: template.id,
1337
+ name: template.name,
1338
+ subject: template.subject,
1339
+ to: template.to ?? [],
1340
+ cc: template.cc ?? [],
1341
+ body: template.body,
1342
+ });
927
1343
  }, "Error getting template"));
928
1344
  // --- delete-template ---
929
- server.tool("delete-template", "Use when: permanently removing a saved email template by id.\nReturns: a confirmation that the template was deleted.\nDo not use when: you only want to view it (use get-template) or update it (use save-template with the existing id).\nSafety: destructive — removes the template from the on-disk store permanently. Require explicit user confirmation and use list-templates first to confirm the id.", {
930
- id: z.string().min(1, "Template ID is required"),
1345
+ server.registerTool("delete-template", {
1346
+ description: "Use when: permanently removing a saved email template by id.\nReturns: a confirmation that the template was deleted.\nDo not use when: you only want to view it (use get-template) or update it (use save-template with the existing id).\nSafety: destructive — removes the template from the on-disk store permanently. Require explicit user confirmation and use list-templates first to confirm the id.",
1347
+ inputSchema: {
1348
+ id: z.string().min(1, "Template ID is required"),
1349
+ },
1350
+ outputSchema: {
1351
+ ok: z.boolean().optional(),
1352
+ id: z.string().optional(),
1353
+ },
931
1354
  }, withErrorHandling(({ id }) => {
932
1355
  const success = mailManager.deleteTemplate(id);
933
1356
  if (!success) {
934
1357
  return errorResponse(`Template "${id}" not found`);
935
1358
  }
936
- return successResponse(`Template "${id}" deleted`);
1359
+ return successResponse(`Template "${id}" deleted`, { ok: true, id });
937
1360
  }, "Error deleting template"));
938
1361
  // --- use-template ---
939
- server.tool("use-template", "Use when: composing a new draft from a saved template (by id), optionally overriding the recipients, subject, or body. Creates a draft in Mail.app for the user to review and send.\nReturns: a confirmation that a draft was created from the template.\nDo not use when: you want to inspect the template without composing (use get-template) or send immediately without a draft (use send-email).", {
940
- id: z.string().min(1, "Template ID is required"),
941
- to: z.array(z.string()).optional().describe("Override recipients"),
942
- cc: z.array(z.string()).optional().describe("Override CC recipients"),
943
- subject: z.string().optional().describe("Override subject"),
944
- body: z.string().optional().describe("Override body"),
1362
+ server.registerTool("use-template", {
1363
+ description: "Use when: composing a new draft from a saved template (by id), optionally overriding the recipients, subject, or body. Creates a draft in Mail.app for the user to review and send.\nReturns: a confirmation that a draft was created from the template.\nDo not use when: you want to inspect the template without composing (use get-template) or send immediately without a draft (use send-email).",
1364
+ inputSchema: {
1365
+ id: z.string().min(1, "Template ID is required"),
1366
+ to: z.array(z.string()).optional().describe("Override recipients"),
1367
+ cc: z.array(z.string()).optional().describe("Override CC recipients"),
1368
+ subject: z.string().optional().describe("Override subject"),
1369
+ body: z.string().optional().describe("Override body"),
1370
+ },
1371
+ outputSchema: {
1372
+ ok: z.boolean().optional(),
1373
+ id: z.string().optional(),
1374
+ },
945
1375
  }, withErrorHandling(({ id, to, cc, subject, body }) => {
946
1376
  const success = mailManager.useTemplate(id, { to, cc, subject, body });
947
1377
  if (!success) {
948
1378
  return errorResponse(`Failed to use template "${id}". Template not found or no recipients.`);
949
1379
  }
950
- return successResponse(`Draft created from template "${id}"`);
1380
+ return successResponse(`Draft created from template "${id}"`, { ok: true, id });
951
1381
  }, "Error using template"));
952
1382
  // =============================================================================
953
1383
  // Diagnostics Tools
954
1384
  // =============================================================================
955
1385
  // --- health-check ---
956
- server.tool("health-check", "Use when: doing a quick check that Mail.app is reachable and the server's basic checks pass.\nReturns: an overall healthy/unhealthy status with a pass/fail line per check.\nDo not use when: you need detailed permission/account/IMAP/SMTP diagnostics with remediation steps (use doctor).", {}, withErrorHandling(() => {
1386
+ server.registerTool("health-check", {
1387
+ description: "Use when: doing a quick check that Mail.app is reachable and the server's basic checks pass.\nReturns: an overall healthy/unhealthy status with a pass/fail line per check.\nDo not use when: you need detailed permission/account/IMAP/SMTP diagnostics with remediation steps (use doctor).",
1388
+ inputSchema: {},
1389
+ outputSchema: {
1390
+ healthy: z.boolean().optional(),
1391
+ checks: z.array(CHECK_ITEM_SCHEMA).optional(),
1392
+ },
1393
+ }, withErrorHandling(() => {
957
1394
  const result = mailManager.healthCheck();
958
1395
  const statusIcon = result.healthy ? "✓" : "✗";
959
1396
  const statusText = result.healthy ? "All checks passed" : "Issues detected";
@@ -963,21 +1400,39 @@ server.tool("health-check", "Use when: doing a quick check that Mail.app is reac
963
1400
  return ` ${icon} ${c.name}: ${c.message}`;
964
1401
  })
965
1402
  .join("\n");
966
- return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`);
1403
+ return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`, { ...result });
967
1404
  }, "Error running health check"));
968
1405
  // --- doctor ---
969
- server.tool("doctor", "Use when: troubleshooting setup problems — diagnoses Mail.app automation permissions, account state, and the IMAP/SMTP backends with actionable remediation messages.\nReturns: a detailed diagnostic report (formatted text plus structured checks).\nDo not use when: you just want a quick up/down status (use health-check) or message counts (use get-mail-stats).", {}, withErrorHandling(async () => {
1406
+ server.registerTool("doctor", {
1407
+ description: "Use when: troubleshooting setup problems — diagnoses Mail.app automation permissions, account state, and the IMAP/SMTP backends with actionable remediation messages.\nReturns: a detailed diagnostic report (formatted text plus structured checks).\nDo not use when: you just want a quick up/down status (use health-check) or message counts (use get-mail-stats).",
1408
+ inputSchema: {},
1409
+ outputSchema: {
1410
+ healthy: z.boolean().optional(),
1411
+ checks: z.array(CHECK_ITEM_SCHEMA).optional(),
1412
+ },
1413
+ }, withErrorHandling(async () => {
970
1414
  // Diagnoses Mail.app permission, account state, and the IMAP/SMTP backends
971
1415
  // with actionable messages (C3). structuredContent carries the raw checks.
972
1416
  const report = await runDoctor(mailManager);
973
1417
  return successResponse(formatDoctorReport(report), { ...report });
974
1418
  }, "Error running doctor"));
975
1419
  // --- get-mail-stats ---
976
- server.tool("get-mail-stats", "Use when: you want aggregate mailbox statistics — total and unread message counts, recently-received counts (last 24h/7d/30d), and (for the all-accounts path) a per-account breakdown.\nReturns: totals, unread counts, recent-activity counts, and per-account figures.\nDo not use when: you only need a single unread number (use get-unread-count) or want to list the messages themselves (use list-messages / search-messages).", {
977
- account: z
978
- .string()
979
- .optional()
980
- .describe("Limit to one account; uses fast IMAP STATUS if that account is IMAP-configured"),
1420
+ server.registerTool("get-mail-stats", {
1421
+ description: "Use when: you want aggregate mailbox statistics — total and unread message counts, recently-received counts (last 24h/7d/30d), and (for the all-accounts path) a per-account breakdown.\nReturns: totals, unread counts, recent-activity counts, and per-account figures.\nDo not use when: you only need a single unread number (use get-unread-count) or want to list the messages themselves (use list-messages / search-messages).",
1422
+ inputSchema: {
1423
+ account: z
1424
+ .string()
1425
+ .optional()
1426
+ .describe("Limit to one account; uses fast IMAP STATUS if that account is IMAP-configured"),
1427
+ },
1428
+ outputSchema: {
1429
+ account: z.string().optional(),
1430
+ totalMessages: z.number().optional(),
1431
+ totalUnread: z.number().optional(),
1432
+ accounts: z.array(z.object({}).passthrough()).optional(),
1433
+ recentlyReceived: z.object({}).passthrough().optional(),
1434
+ recent: z.object({}).passthrough().optional(),
1435
+ },
981
1436
  }, withErrorHandling(async ({ account }) => {
982
1437
  // IMAP (I3): for a named IMAP account, STATUS gives authoritative counts and
983
1438
  // SEARCH SINCE gives recent activity — fast even on huge mailboxes.
@@ -1019,7 +1474,17 @@ server.tool("get-mail-stats", "Use when: you want aggregate mailbox statistics
1019
1474
  return successResponse(lines.join("\n"), { ...stats });
1020
1475
  }, "Error getting mail statistics"));
1021
1476
  // --- get-sync-status ---
1022
- server.tool("get-sync-status", "Use when: checking whether Mail.app is running and actively syncing, e.g. to explain why new mail hasn't appeared yet.\nReturns: whether Mail.app is running and whether sync activity was detected.\nDo not use when: you need message counts (use get-mail-stats) or a full setup diagnosis (use doctor).", {}, withErrorHandling(() => {
1477
+ server.registerTool("get-sync-status", {
1478
+ description: "Use when: checking whether Mail.app is running and actively syncing, e.g. to explain why new mail hasn't appeared yet.\nReturns: whether Mail.app is running and whether sync activity was detected.\nDo not use when: you need message counts (use get-mail-stats) or a full setup diagnosis (use doctor).",
1479
+ inputSchema: {},
1480
+ outputSchema: {
1481
+ syncDetected: z.boolean().optional(),
1482
+ pendingUpload: z.number().optional(),
1483
+ recentActivity: z.boolean().optional(),
1484
+ secondsSinceLastChange: z.number().optional(),
1485
+ error: z.string().optional(),
1486
+ },
1487
+ }, withErrorHandling(() => {
1023
1488
  const status = mailManager.getSyncStatus();
1024
1489
  const lines = [];
1025
1490
  lines.push(`🔄 Mail Sync Status`);