fluxmail 0.23.0 → 0.24.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.
@@ -4,12 +4,12 @@ import { randomUUID } from 'node:crypto';
4
4
  import { z } from 'zod';
5
5
  import { CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, McpServer, ResourceTemplate, } from '@modelcontextprotocol/server';
6
6
  import { isEmailError, mergeEmailQueries, parseEmailSearch, parseSingleAddress, } from '@fluxmail/core';
7
- import { ATTACHMENT_URI_TEMPLATE, attachmentUri, bodyPage, MAX_BODY_CHARS, MAX_THREAD_PAGE_TOKEN_CHARS, selectBody, sharedTool, threadPage, threadPageOffset, toModifyAction, } from '@fluxmail/core/mcp';
8
- import { batchFailureCause } from '../service/emailService.js';
7
+ import { ATTACHMENT_URI_TEMPLATE, attachmentUri, bodyPage, MAX_BODY_CHARS, MAX_THREAD_PAGE_TOKEN_CHARS, selectBody, sharedTool, sharedToolTarget, threadPage, threadPageOffset, toModifyAction, } from '@fluxmail/core/mcp';
8
+ import { batchFailureCause, modifyFailure, } from '../service/emailService.js';
9
9
  import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
10
10
  import { DEFAULT_PERMISSION_POLICY, MCP_CAPABILITIES, MCP_CAPABILITY_DESCRIPTIONS, enableCapabilitiesInstructions, hasCapability, normalizePermissionPolicy, } from '../permissions.js';
11
11
  import { captureOperation } from '../telemetry.js';
12
- import { logFailure } from '../logging.js';
12
+ import { logCodedFailure, logFailure } from '../logging.js';
13
13
  import { attachmentTooLarge, ClientInputError, isAttachmentSizeError, MissingCapabilityError, publicError, } from '../service/publicErrors.js';
14
14
  import { sendAtSchema } from '../sendAt.js';
15
15
  import { outputSchemas } from './outputSchemas.js';
@@ -32,6 +32,8 @@ function sizeLabel(bytes) {
32
32
  const TELEMETRY_ERROR = Symbol('telemetryError');
33
33
  /** The error behind a partial failure, read only for telemetry failure detail. */
34
34
  const TELEMETRY_FAILURE = Symbol('telemetryFailure');
35
+ /** The telemetry error code for a partial failure. Defaults to `account_failure`. */
36
+ const TELEMETRY_ERROR_CODE = Symbol('telemetryErrorCode');
35
37
  const EMPTY_INPUT_SCHEMA = {
36
38
  type: 'object',
37
39
  properties: {},
@@ -74,15 +76,76 @@ function toolJsonSchema(shape, pipeStrategy) {
74
76
  delete schema.$schema;
75
77
  return schema;
76
78
  }
79
+ const MAX_REPORTED_ARGUMENT_PROBLEMS = 10;
80
+ const MAX_REPORTED_ARGUMENT_CHARS = 100;
81
+ function argumentPath(path) {
82
+ const formatted = path
83
+ .map((step, index) => {
84
+ if (typeof step === 'number')
85
+ return `[${step}]`;
86
+ return index ? `.${String(step)}` : String(step);
87
+ })
88
+ .join('');
89
+ return formatted.slice(0, MAX_REPORTED_ARGUMENT_CHARS);
90
+ }
91
+ /** Tool arguments that do not match the input schema. The message names client-supplied keys. */
92
+ class InvalidToolArgumentsError extends ClientInputError {
93
+ constructor(tool, issues) {
94
+ // A misspelled argument often causes the other problems, so unknown arguments come first.
95
+ const problems = [
96
+ ...issues.flatMap((issue) => issue.code === 'unrecognized_keys'
97
+ ? issue.keys.map((key) => `unknown argument ${JSON.stringify(argumentPath([...issue.path, key]))}`)
98
+ : []),
99
+ ...issues
100
+ .filter((issue) => issue.code !== 'unrecognized_keys')
101
+ .map((issue) => `${issue.path.length ? argumentPath(issue.path) : 'arguments'}: ${issue.message}`),
102
+ ];
103
+ const shown = problems.slice(0, MAX_REPORTED_ARGUMENT_PROBLEMS);
104
+ const more = problems.length - shown.length;
105
+ super('invalid_request', `Invalid arguments for ${tool}: ${shown.join('; ')}${more ? `; and ${more} more` : ''}. ` +
106
+ "Check the tool's input schema.");
107
+ }
108
+ }
109
+ /**
110
+ * Raw tool arguments, parsed by the tool handler after its quota gate so that every
111
+ * schema problem becomes one invalid_request error.
112
+ */
113
+ class ToolInput {
114
+ tool;
115
+ raw;
116
+ schema;
117
+ constructor(tool, raw, schema) {
118
+ this.tool = tool;
119
+ this.raw = raw;
120
+ this.schema = schema;
121
+ }
122
+ async parse() {
123
+ const result = await this.schema.safeParseAsync(this.raw ?? {});
124
+ if (!result.success)
125
+ throw new InvalidToolArgumentsError(this.tool, result.error.issues);
126
+ return result.data;
127
+ }
128
+ }
129
+ /**
130
+ * Tools with arguments reject undeclared ones, as their schemas advertise, even when every
131
+ * argument is optional. Dropping one could widen a search. Tools without arguments advertise
132
+ * an open schema and ignore extras, which some clients send to tools that take no arguments.
133
+ */
134
+ function toolInputSchema(shape) {
135
+ return Object.keys(shape).length ? z.object(shape).strict() : z.object(shape);
136
+ }
77
137
  // Retain the existing validators, transforms and JSON Schemas at the SDK boundary.
78
- // The SDK consumes only Standard Schema here.
79
- function mcpSchema(shape, strict = false) {
80
- const schema = strict ? z.object(shape).strict() : z.object(shape);
138
+ // The SDK consumes only Standard Schema here. Tool handlers parse inputs themselves, so the
139
+ // input validator passes arguments through unchanged.
140
+ function mcpSchema(shape, io) {
141
+ const schema = io === 'output' ? z.object(shape) : undefined;
81
142
  return {
82
143
  '~standard': {
83
144
  version: 1,
84
145
  vendor: 'fluxmail',
85
146
  validate: async (value) => {
147
+ if (!schema)
148
+ return { value };
86
149
  const result = await schema.safeParseAsync(value);
87
150
  return result.success ? { value: result.data } : { issues: result.error.issues };
88
151
  },
@@ -117,21 +180,11 @@ const addressList = z
117
180
  .max(MAX_RECIPIENTS)
118
181
  .describe('Recipients, each "Name <a@x.com>" or "a@x.com"');
119
182
  const searchString = z.string().max(MAX_SEARCH_CHARS);
120
- const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
121
183
  const queryShape = {
122
184
  folder: searchString
123
185
  .min(1)
124
186
  .optional()
125
187
  .describe("Folder role (inbox, sent, drafts, trash, spam, starred, archive, all) or a label/folder name. Use all or omit this field to search all mail except Spam and Trash. An IMAP server's \\All mailbox may use different rules."),
126
- text: searchString.optional().describe('Literal full-text search terms'),
127
- from: searchString.optional(),
128
- to: searchString.optional(),
129
- subject: searchString.optional(),
130
- read: z.boolean().optional(),
131
- starred: z.boolean().optional(),
132
- hasAttachment: z.boolean().optional(),
133
- after: isoDate.optional().describe('YYYY-MM-DD received date, inclusive in UTC'),
134
- before: isoDate.optional().describe('YYYY-MM-DD received date, exclusive in UTC'),
135
188
  rawProviderQuery: searchString
136
189
  .optional()
137
190
  .describe('Provider-native Gmail syntax or Outlook KQL for one compatible account'),
@@ -141,8 +194,14 @@ const queryShape = {
141
194
  includeSearchContext: z
142
195
  .boolean()
143
196
  .optional()
144
- .describe('Include a match-centered body excerpt; requires a portable text query'),
197
+ .describe('Include a match-centered body excerpt; requires search words in query'),
145
198
  };
199
+ const sendAtParam = sendAtSchema
200
+ .optional()
201
+ .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
202
+ '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
203
+ 'for delivery by fluxmail serve or fluxmail scheduled run using the same store. Stdio does not ' +
204
+ 'deliver schedules. Overdue mail may send when a worker starts. Returns a scheduleId for list/cancel.');
146
205
  const draftShape = {
147
206
  accountId: accountIdParam,
148
207
  from: z.string().email().optional().describe('Connected address or an available send-as address'),
@@ -170,7 +229,8 @@ const draftShape = {
170
229
  .optional()
171
230
  .describe('With replyToMessageId: set to false to leave the original message out of the reply. Defaults to true.'),
172
231
  attachments: z
173
- .array(z.object({
232
+ .array(z
233
+ .object({
174
234
  filename: z.string().min(1).max(MAX_ATTACHMENT_FIELD_CHARS),
175
235
  mimeType: z.string().min(1).max(MAX_ATTACHMENT_FIELD_CHARS),
176
236
  content: z.string().describe('base64'),
@@ -181,7 +241,8 @@ const draftShape = {
181
241
  .optional()
182
242
  .describe('Content-ID for an inline image, referenced from bodyHtml as cid:<contentId>'),
183
243
  disposition: z.enum(['inline', 'attachment']).optional().describe('Defaults to attachment'),
184
- }))
244
+ })
245
+ .strict())
185
246
  .optional(),
186
247
  };
187
248
  function parseAddresses(raw) {
@@ -289,7 +350,12 @@ function toolFeatureProperties(tool, args) {
289
350
  reply_all: input.replyAll === true,
290
351
  };
291
352
  case 'forward_email':
292
- return { include_attachments: input.includeAttachments !== false };
353
+ return { include_attachments: input.includeAttachments !== false, scheduled: input.sendAt !== undefined };
354
+ case 'list_emails':
355
+ case 'search_emails':
356
+ case 'list_emails_batch':
357
+ case 'search_emails_batch':
358
+ return { typed_query: typeof input.query === 'string' && input.query.trim() !== '' };
293
359
  case 'download_attachment':
294
360
  return {
295
361
  destination: input.inline === true ? 'inline' : typeof input.pages === 'string' ? 'pdf_pages' : 'resource',
@@ -304,14 +370,21 @@ function toolTelemetryProperties(tool, args, options) {
304
370
  ...options.clientProperties?.(args[1]),
305
371
  };
306
372
  }
373
+ /** Handlers built by handleResult, which parse their tool input. Only these can be registered. */
374
+ const toolHandlers = new WeakSet();
307
375
  function handleResult(tool, fn, gate, options = {}) {
308
- return async (...args) => {
376
+ const handler = async (...received) => {
309
377
  const finishActivity = options.telemetry?.beginActivity?.();
310
378
  const startedAt = performance.now();
379
+ const input = received[0] instanceof ToolInput ? received[0] : undefined;
380
+ // Until the input parses, telemetry reports no properties derived from it.
381
+ let args = (input ? [undefined, ...received.slice(1)] : received);
311
382
  try {
312
383
  // The gate throws when a lapsed license leaves the instance over quota,
313
384
  // and yields a renewal warning to attach while the license is in grace.
314
385
  const warning = gate?.();
386
+ if (input)
387
+ args = [await input.parse(), ...received.slice(1)];
315
388
  const result = await fn(...args);
316
389
  if (warning)
317
390
  result.content.push({ type: 'text', text: `Note: ${warning}` });
@@ -321,7 +394,10 @@ function handleResult(tool, fn, gate, options = {}) {
321
394
  operation: tool,
322
395
  outcome: telemetryFailed ? 'error' : 'success',
323
396
  ...(telemetryFailed
324
- ? { errorCode: 'account_failure', error: result[TELEMETRY_FAILURE] }
397
+ ? {
398
+ errorCode: result[TELEMETRY_ERROR_CODE] ?? 'account_failure',
399
+ error: result[TELEMETRY_FAILURE],
400
+ }
325
401
  : {}),
326
402
  durationMs: performance.now() - startedAt,
327
403
  transport: options.transport ?? 'unknown',
@@ -331,12 +407,18 @@ function handleResult(tool, fn, gate, options = {}) {
331
407
  }
332
408
  catch (err) {
333
409
  const requestId = randomUUID();
334
- logFailure(options.logger, 'mcp.operation_failed', err, {
410
+ const logContext = {
335
411
  productSurface: 'mcp',
336
412
  operation: tool,
337
413
  durationMs: performance.now() - startedAt,
338
414
  details: { request_id: requestId },
339
- });
415
+ };
416
+ // The client sees which arguments were wrong; the log omits their names.
417
+ if (err instanceof InvalidToolArgumentsError) {
418
+ logCodedFailure(options.logger, 'mcp.operation_failed', err.code, 'Tool arguments did not match the input schema', logContext);
419
+ }
420
+ else
421
+ logFailure(options.logger, 'mcp.operation_failed', err, logContext);
340
422
  captureOperation(options.telemetry, {
341
423
  productSurface: 'mcp',
342
424
  operation: tool,
@@ -353,6 +435,8 @@ function handleResult(tool, fn, gate, options = {}) {
353
435
  finishActivity?.();
354
436
  }
355
437
  };
438
+ toolHandlers.add(handler);
439
+ return handler;
356
440
  }
357
441
  function handle(tool, fn, gate, options = {}) {
358
442
  return handleResult(tool, async (...args) => ok(await fn(...args)), gate, options);
@@ -365,20 +449,26 @@ function pageOpts(args) {
365
449
  ...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
366
450
  };
367
451
  }
452
+ function invalidSearch(diagnostics) {
453
+ return new ClientInputError('invalid_request', diagnostics.map((item) => item.message).join(' '), { diagnostics });
454
+ }
455
+ /**
456
+ * Parses a typed query and merges it with the structured filters sent beside it. Rejects either step's errors.
457
+ * A missing or blank query leaves the structured filters as they are.
458
+ */
459
+ function typedSearch(query, structured) {
460
+ if (query === undefined || query.trim() === '')
461
+ return { query: structured, warnings: [] };
462
+ const parsed = parseEmailSearch(query);
463
+ if (!parsed.valid)
464
+ throw invalidSearch(parsed.diagnostics);
465
+ const merged = mergeEmailQueries(parsed.query, structured);
466
+ if (!merged.success)
467
+ throw invalidSearch(merged.diagnostics);
468
+ return { query: merged.query, warnings: parsed.diagnostics };
469
+ }
368
470
  function emailQuery(args) {
369
- const keys = [
370
- 'folder',
371
- 'text',
372
- 'from',
373
- 'to',
374
- 'subject',
375
- 'read',
376
- 'starred',
377
- 'hasAttachment',
378
- 'after',
379
- 'before',
380
- 'rawProviderQuery',
381
- ];
471
+ const keys = ['folder', 'rawProviderQuery'];
382
472
  const q = {};
383
473
  for (const key of keys) {
384
474
  const value = args[key];
@@ -422,6 +512,7 @@ const PRODUCT_DESCRIPTIONS = {
422
512
  list_accounts: 'Each entry includes the account ID, provider, email address, status and capabilities.',
423
513
  list_emails: 'Use get_email for full bodies.',
424
514
  get_email: 'Use get_email_body to read the rest.',
515
+ forward_email: 'Add sendAt to schedule the forward instead of sending it now.',
425
516
  send_email: 'Confirm with the user when the intent is ambiguous. Add sendAt to any mode to schedule the message instead ' +
426
517
  'of sending it now.',
427
518
  delete_emails: "Gmail accounts connected through Fluxmail's built-in Google app cannot delete permanently; move their " +
@@ -526,14 +617,19 @@ export function buildMcpServer(service, options = {}) {
526
617
  const supportsResourceLinks = (ctx) => (protocolVersionOf(ctx) ?? '2025-03-26') >= '2025-06-18';
527
618
  const listedTools = [];
528
619
  const registerTool = (name, config, callback) => {
529
- const outputSchema = outputSchemas[name];
620
+ // An alias returns the same result as the tool it names.
621
+ const outputSchema = outputSchemas[sharedToolTarget(name)];
530
622
  const inputShape = config.inputSchema;
531
623
  const inputSchema = inputShape && (name === 'send_feedback' ? inputShape : nullableOptionalInputs(inputShape));
624
+ if (!toolHandlers.has(callback))
625
+ throw new Error(`${name} must be registered with a tool handler`);
626
+ const run = callback;
627
+ const parser = toolInputSchema(inputSchema ?? {});
532
628
  const registered = server.registerTool(name, {
533
629
  ...config,
534
- inputSchema: mcpSchema(inputSchema ?? {}, name === 'send_feedback'),
535
- outputSchema: mcpSchema(outputSchema),
536
- }, callback);
630
+ inputSchema: mcpSchema(inputSchema ?? {}, 'input'),
631
+ outputSchema: mcpSchema(outputSchema, 'output'),
632
+ }, ((args, ctx) => run(new ToolInput(name, args, parser), ctx)));
537
633
  listedTools.push({
538
634
  name,
539
635
  title: config.title,
@@ -546,7 +642,7 @@ export function buildMcpServer(service, options = {}) {
546
642
  return registered;
547
643
  };
548
644
  /** Registers a shared tool with its contract metadata when the connection has the tool's scopes. */
549
- const registerSharedTool = (name, inputSchema, callback, productDescription = PRODUCT_DESCRIPTIONS[name]) => {
645
+ const registerSharedTool = (name, inputSchema, callback, productDescription = PRODUCT_DESCRIPTIONS[sharedToolTarget(name)]) => {
550
646
  if (!canAll(toolScopes(name)))
551
647
  return;
552
648
  const tool = sharedTool(name);
@@ -565,7 +661,7 @@ export function buildMcpServer(service, options = {}) {
565
661
  description: 'Account connection and scheduled-send status, including accounts that need re-authentication, and ' +
566
662
  'the time zone for the date above the quoted message in replies. Administrators also see plan details.',
567
663
  inputSchema: {},
568
- annotations: { readOnlyHint: true },
664
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
569
665
  }, allowed('get_status', async () => {
570
666
  const update = options.availableUpdate?.();
571
667
  return { ...(await service.status()), ...(update ? { update } : {}) };
@@ -573,58 +669,28 @@ export function buildMcpServer(service, options = {}) {
573
669
  registerSharedTool('list_folders', { accountId: accountIdParam }, gated('list_folders', async (args) => service.listFolders(args.accountId)));
574
670
  registerSharedTool('list_labels', { accountId: accountIdParam }, gated('list_labels', async (args) => service.listLabels(args.accountId)));
575
671
  registerSharedTool('list_send_as', { accountId: accountIdParam }, gated('list_send_as', async (args) => service.listSendAs(args.accountId)));
576
- registerSharedTool('list_emails', { accountId: accountIdParam, ...queryShape }, gated('list_emails', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
577
- const { text: _text, ...searchFilterShape } = queryShape;
578
- registerSharedTool('search_emails', {
579
- accountId: accountIdParam,
580
- query: searchString.min(1).describe('Typed portable search syntax'),
581
- ...searchFilterShape,
582
- }, gated('search_emails', async (args) => {
583
- const parsed = parseEmailSearch(args.query);
584
- if (!parsed.valid) {
585
- throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
586
- diagnostics: parsed.diagnostics,
587
- });
588
- }
589
- const merged = mergeEmailQueries(parsed.query, emailQuery(args));
590
- if (!merged.success) {
591
- throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
592
- diagnostics: merged.diagnostics,
593
- });
594
- }
595
- const result = await service.listMessages(args.accountId, merged.query, pageOpts(args));
596
- return {
597
- ...result,
598
- ...([...parsed.diagnostics, ...(result.diagnostics ?? [])].length
599
- ? { diagnostics: [...parsed.diagnostics, ...(result.diagnostics ?? [])] }
600
- : {}),
601
- };
602
- }));
603
- const { pageToken: _batchPageToken, rawProviderQuery: _batchRawProviderQuery, ...batchSearchFilterShape } = searchFilterShape;
604
- registerSharedTool('search_emails_batch', {
605
- accounts: z
606
- .array(z.object(nullableOptionalInputs({ accountId: idParam, pageToken: z.string().min(1).optional() })).strict())
607
- .min(1)
608
- .max(20),
609
- query: searchString.min(1).describe('Typed portable search syntax'),
610
- ...batchSearchFilterShape,
611
- folder: z.enum(['inbox', 'sent', 'drafts', 'archive', 'spam', 'trash', 'all']).optional(),
612
- }, gatedResult('search_emails_batch', async (args) => {
613
- const parsed = parseEmailSearch(args.query);
614
- if (!parsed.valid) {
615
- throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
616
- diagnostics: parsed.diagnostics,
617
- });
618
- }
619
- const merged = mergeEmailQueries(parsed.query, emailQuery(args));
620
- if (!merged.success) {
621
- throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
622
- diagnostics: merged.diagnostics,
623
- });
624
- }
672
+ const typedQuery = searchString
673
+ .optional()
674
+ .describe('Typed search. Plain words search the message text; quote a phrase or a value that has spaces. Filters: ' +
675
+ 'from:, to:, subject:, in:inbox|sent|drafts|archive|spam|trash|all, is:read, is:unread, is:starred, ' +
676
+ 'is:unstarred, has:attachment, -has:attachment, after:YYYY-MM-DD (inclusive) and before:YYYY-MM-DD ' +
677
+ '(exclusive), in UTC. Terms combine with AND; OR and parentheses are searched as text.');
678
+ const listEmails = async (args) => {
679
+ const { query, warnings } = typedSearch(args.query, emailQuery(args));
680
+ const result = await service.listMessages(args.accountId, query, pageOpts(args));
681
+ const diagnostics = [...warnings, ...(result.diagnostics ?? [])];
682
+ return { ...result, ...(diagnostics.length ? { diagnostics } : {}) };
683
+ };
684
+ const listEmailsShape = { accountId: accountIdParam, query: typedQuery, ...queryShape };
685
+ // search_emails is an alias of list_emails. It reports its own operation name so telemetry can tell them apart.
686
+ for (const name of ['list_emails', 'search_emails']) {
687
+ registerSharedTool(name, listEmailsShape, gated(name, listEmails));
688
+ }
689
+ const listEmailsBatch = async (args) => {
690
+ const { query, warnings } = typedSearch(args.query, emailQuery(args));
625
691
  const result = await service.searchMessagesBatch({
626
692
  accounts: args.accounts,
627
- query: merged.query,
693
+ query: query,
628
694
  ...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
629
695
  ...(args.includeSnippet !== undefined ? { includeSnippet: args.includeSnippet } : {}),
630
696
  ...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
@@ -634,7 +700,7 @@ export function buildMcpServer(service, options = {}) {
634
700
  groups: result.groups.map((group) => {
635
701
  if (!group.page)
636
702
  return group;
637
- const diagnostics = [...parsed.diagnostics, ...(group.page.diagnostics ?? [])];
703
+ const diagnostics = [...warnings, ...(group.page.diagnostics ?? [])];
638
704
  return {
639
705
  ...group,
640
706
  page: { ...group.page, ...(diagnostics.length ? { diagnostics } : {}) },
@@ -648,7 +714,24 @@ export function buildMcpServer(service, options = {}) {
648
714
  response[TELEMETRY_FAILURE] = batchFailureCause(result.groups);
649
715
  }
650
716
  return response;
651
- }));
717
+ };
718
+ const { pageSize, includeSnippet, includeSearchContext } = queryShape;
719
+ const listEmailsBatchShape = {
720
+ accounts: z
721
+ .array(z.object(nullableOptionalInputs({ accountId: idParam, pageToken: z.string().min(1).optional() })).strict())
722
+ .min(1)
723
+ .max(20),
724
+ query: typedQuery,
725
+ // Custom folder names differ between accounts, so a batch takes only a folder role.
726
+ folder: z.enum(['inbox', 'sent', 'drafts', 'archive', 'spam', 'trash', 'all']).optional(),
727
+ pageSize,
728
+ includeSnippet,
729
+ includeSearchContext,
730
+ };
731
+ // search_emails_batch is an alias of list_emails_batch, with its own operation name in telemetry.
732
+ for (const name of ['list_emails_batch', 'search_emails_batch']) {
733
+ registerSharedTool(name, listEmailsBatchShape, gatedResult(name, listEmailsBatch));
734
+ }
652
735
  registerSharedTool('get_email', {
653
736
  accountId: accountIdParam,
654
737
  messageId: idParam,
@@ -661,12 +744,12 @@ export function buildMcpServer(service, options = {}) {
661
744
  registerSharedTool('get_email_body', {
662
745
  accountId: accountIdParam,
663
746
  messageId: idParam,
664
- format: z.enum(['text', 'html']),
747
+ bodyFormat: z.enum(['text', 'html']).describe('The body part to read'),
665
748
  offset: z.number().int().min(0).optional(),
666
749
  maxChars: z.number().int().min(1).max(MAX_BODY_CHARS).optional(),
667
750
  }, gated('get_email_body', async (args) => {
668
751
  const message = await service.getMessage(args.accountId, args.messageId);
669
- return bodyPage(message, args.format, args.offset, args.maxChars);
752
+ return bodyPage(message, args.bodyFormat, args.offset, args.maxChars);
670
753
  }));
671
754
  registerSharedTool('get_thread', {
672
755
  accountId: accountIdParam,
@@ -720,12 +803,7 @@ export function buildMcpServer(service, options = {}) {
720
803
  .describe('Reuse this key when retrying the same delivery'),
721
804
  draftId: idParam.optional().describe('Send this existing draft'),
722
805
  ...draftShape,
723
- sendAt: sendAtSchema
724
- .optional()
725
- .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
726
- '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
727
- 'for delivery by fluxmail serve or fluxmail scheduled run using the same store. Stdio does not ' +
728
- 'deliver schedules. Overdue mail may send when a worker starts. Returns a scheduleId for list/cancel.'),
806
+ sendAt: sendAtParam,
729
807
  }, handleResult('send_email', async (args) => {
730
808
  requireToolScopes('send_email');
731
809
  if (args.replyToMessageId !== undefined)
@@ -742,16 +820,16 @@ export function buildMcpServer(service, options = {}) {
742
820
  'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
743
821
  'Fluxmail server is running.',
744
822
  inputSchema: { accountId: accountIdParam },
745
- annotations: { readOnlyHint: true },
823
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
746
824
  }, gated('list_scheduled_emails', async (args) => service.listScheduled(args.accountId)));
747
- registerSharedTool('get_delivery_operation', { accountId: idParam, operationId: idParam }, allowed('get_delivery_operation', async (args) => service.getDelivery(args.accountId, args.operationId)));
825
+ registerSharedTool('get_delivery_operation', { accountId: accountIdParam, operationId: idParam }, allowed('get_delivery_operation', async (args) => service.getDelivery(args.accountId, args.operationId)));
748
826
  if (canAll(toolScopes('cancel_scheduled_email')))
749
827
  registerTool('cancel_scheduled_email', {
750
828
  title: 'Cancel scheduled email',
751
- description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
829
+ description: 'Cancel a pending scheduled send by scheduleId (from send_email or forward_email with sendAt, or list_scheduled_emails). ' +
752
830
  'The draft stays in the Drafts folder, so the content is not lost.',
753
831
  inputSchema: { scheduleId: idParam },
754
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
832
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
755
833
  }, gated('cancel_scheduled_email', async (args) => service.cancelScheduled(args.scheduleId)));
756
834
  registerSharedTool('forward_email', {
757
835
  idempotencyKey: z
@@ -763,23 +841,30 @@ export function buildMcpServer(service, options = {}) {
763
841
  from: z.string().email().optional().describe('Connected address or an available send-as address'),
764
842
  to: addressList.min(1),
765
843
  cc: addressList.optional(),
844
+ bcc: addressList.optional(),
766
845
  comment: z
767
846
  .string()
768
847
  .optional()
769
848
  .describe('Comment above the forwarded message. Keep prose paragraphs on one continuous line and separate paragraphs with blank lines.'),
770
849
  includeAttachments: z.boolean().optional().describe('Default true'),
850
+ sendAt: sendAtParam,
771
851
  }, handleResult('forward_email', async (args) => {
772
852
  requireToolScopes('forward_email');
773
853
  const to = parseAddresses(args.to) ?? [];
774
854
  const cc = parseAddresses(args.cc);
775
- return deliveryResult(await service.deliverForward(args.accountId, {
855
+ const bcc = parseAddresses(args.bcc);
856
+ const forward = {
776
857
  messageId: args.messageId,
777
858
  to,
778
859
  ...(cc?.length ? { cc } : {}),
860
+ ...(bcc?.length ? { bcc } : {}),
779
861
  ...(args.comment !== undefined ? { comment: args.comment } : {}),
780
862
  ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
781
863
  ...(args.from !== undefined ? { from: args.from } : {}),
782
- }, args.idempotencyKey));
864
+ };
865
+ return deliveryResult(await (args.sendAt !== undefined
866
+ ? service.scheduleDelivery(args.accountId, { forward }, args.sendAt, args.idempotencyKey)
867
+ : service.deliverForward(args.accountId, forward, args.idempotencyKey)));
783
868
  }, () => service.enforceQuota(), toolOptions));
784
869
  const modifyEmails = async (action, args) => {
785
870
  requireToolScopes(MODIFY_TOOLS[action].name);
@@ -788,9 +873,12 @@ export function buildMcpServer(service, options = {}) {
788
873
  throw new ClientInputError('invalid_request', mapped.error);
789
874
  const result = await service.modify(args.accountId, args.messageIds, mapped.action);
790
875
  const response = ok({ ...result, action });
791
- if (result.failed.length || result.uncertainIds.length) {
876
+ const failure = modifyFailure(result);
877
+ if (failure) {
792
878
  response.isError = true;
793
879
  response[TELEMETRY_ERROR] = true;
880
+ response[TELEMETRY_ERROR_CODE] = failure.errorCode;
881
+ response[TELEMETRY_FAILURE] = failure.cause;
794
882
  }
795
883
  return response;
796
884
  };
@@ -841,10 +929,17 @@ export function buildMcpServer(service, options = {}) {
841
929
  }), `The size limit is ${sizeLabel(maxAttachmentBytes)}.`);
842
930
  if (can('mail.read'))
843
931
  server.registerResource('attachment', new ResourceTemplate(ATTACHMENT_URI_TEMPLATE, { list: undefined }), { description: 'Read one attachment from an authorized mailbox.' }, async (uri, variables) => {
844
- requireCapabilities(['mail.read']);
845
- service.enforceQuota();
846
- const { meta, content } = await downloadAttachment(decodeURIComponent(String(variables.accountId)), decodeURIComponent(String(variables.messageId)), decodeURIComponent(String(variables.attachmentId)));
847
- return { contents: [attachmentContents(uri.href, meta.mimeType || 'application/octet-stream', content)] };
932
+ // Like a tool call, a read in progress holds off shutdown so its provider connection stays open.
933
+ const finishActivity = options.telemetry?.beginActivity?.();
934
+ try {
935
+ requireCapabilities(['mail.read']);
936
+ service.enforceQuota();
937
+ const { meta, content } = await downloadAttachment(decodeURIComponent(String(variables.accountId)), decodeURIComponent(String(variables.messageId)), decodeURIComponent(String(variables.attachmentId)));
938
+ return { contents: [attachmentContents(uri.href, meta.mimeType || 'application/octet-stream', content)] };
939
+ }
940
+ finally {
941
+ finishActivity?.();
942
+ }
848
943
  });
849
944
  if (can('mail.read'))
850
945
  server.server.registerCapabilities({ resources: { listChanged: false } });