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.
- package/dist/cli.d.ts +9 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +33 -16
- package/dist/cli.js.map +1 -1
- package/dist/cliMail.d.ts.map +1 -1
- package/dist/cliMail.js +8 -2
- package/dist/cliMail.js.map +1 -1
- package/dist/cliMailBackend.d.ts.map +1 -1
- package/dist/cliMailBackend.js +54 -5
- package/dist/cliMailBackend.js.map +1 -1
- package/dist/http/rest.d.ts.map +1 -1
- package/dist/http/rest.js +22 -9
- package/dist/http/rest.js.map +1 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +209 -114
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/mcp/outputSchemas.d.ts +1 -37
- package/dist/mcp/outputSchemas.d.ts.map +1 -1
- package/dist/mcp/outputSchemas.js +1 -2
- package/dist/mcp/outputSchemas.js.map +1 -1
- package/dist/service/emailService.d.ts +20 -4
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +82 -40
- package/dist/service/emailService.js.map +1 -1
- package/dist/storage/imapState.d.ts.map +1 -1
- package/dist/storage/imapState.js +9 -0
- package/dist/storage/imapState.js.map +1 -1
- package/package.json +5 -5
package/dist/mcp/buildServer.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? {},
|
|
535
|
-
outputSchema: mcpSchema(outputSchema),
|
|
536
|
-
},
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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:
|
|
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 = [...
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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 } });
|