fluxmail 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/accounts/googleAuth.d.ts.map +1 -1
- package/dist/accounts/googleAuth.js +2 -1
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/registry.d.ts.map +1 -1
- package/dist/accounts/registry.js +12 -5
- package/dist/accounts/registry.js.map +1 -1
- package/dist/accounts/telemetry.d.ts +3 -0
- package/dist/accounts/telemetry.d.ts.map +1 -1
- package/dist/accounts/telemetry.js +18 -0
- package/dist/accounts/telemetry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +83 -24
- package/dist/cli.js.map +1 -1
- package/dist/cliInstances.d.ts +5 -2
- package/dist/cliInstances.d.ts.map +1 -1
- package/dist/cliInstances.js +74 -22
- package/dist/cliInstances.js.map +1 -1
- package/dist/cliMail.d.ts +2 -0
- package/dist/cliMail.d.ts.map +1 -1
- package/dist/cliMail.js +106 -18
- package/dist/cliMail.js.map +1 -1
- package/dist/cliOutput.d.ts +9 -0
- package/dist/cliOutput.d.ts.map +1 -0
- package/dist/cliOutput.js +76 -0
- package/dist/cliOutput.js.map +1 -0
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/http/admin.d.ts.map +1 -1
- package/dist/http/admin.js +2 -0
- package/dist/http/admin.js.map +1 -1
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +4 -0
- package/dist/http/app.js.map +1 -1
- package/dist/http/identity.d.ts.map +1 -1
- package/dist/http/identity.js +2 -0
- package/dist/http/identity.js.map +1 -1
- package/dist/http/rest.d.ts.map +1 -1
- package/dist/http/rest.js +250 -48
- package/dist/http/rest.js.map +1 -1
- package/dist/licensing/entitlements.d.ts +23 -5
- package/dist/licensing/entitlements.d.ts.map +1 -1
- package/dist/licensing/entitlements.js +101 -22
- package/dist/licensing/entitlements.js.map +1 -1
- package/dist/licensing/lease.d.ts +1 -1
- package/dist/licensing/lease.d.ts.map +1 -1
- package/dist/licensing/refresher.d.ts.map +1 -1
- package/dist/licensing/refresher.js +5 -2
- package/dist/licensing/refresher.js.map +1 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +264 -78
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/mcp/outputSchemas.d.ts +2923 -0
- package/dist/mcp/outputSchemas.d.ts.map +1 -0
- package/dist/mcp/outputSchemas.js +108 -0
- package/dist/mcp/outputSchemas.js.map +1 -0
- package/dist/scheduler/sendScheduler.d.ts +2 -0
- package/dist/scheduler/sendScheduler.d.ts.map +1 -1
- package/dist/scheduler/sendScheduler.js +13 -3
- package/dist/scheduler/sendScheduler.js.map +1 -1
- package/dist/service/deliveryCoordinator.d.ts +45 -0
- package/dist/service/deliveryCoordinator.d.ts.map +1 -0
- package/dist/service/deliveryCoordinator.js +296 -0
- package/dist/service/deliveryCoordinator.js.map +1 -0
- package/dist/service/emailService.d.ts +47 -5
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +245 -39
- package/dist/service/emailService.js.map +1 -1
- package/dist/service/publicErrors.d.ts +12 -0
- package/dist/service/publicErrors.d.ts.map +1 -0
- package/dist/service/publicErrors.js +41 -0
- package/dist/service/publicErrors.js.map +1 -0
- package/dist/service/searchCursor.d.ts.map +1 -1
- package/dist/service/searchCursor.js +2 -1
- package/dist/service/searchCursor.js.map +1 -1
- package/dist/storage/db.d.ts +252 -2
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +57 -1
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/members.d.ts.map +1 -1
- package/dist/storage/members.js +3 -1
- package/dist/storage/members.js.map +1 -1
- package/dist/storage/restIdempotency.d.ts +6 -0
- package/dist/storage/restIdempotency.d.ts.map +1 -1
- package/dist/storage/restIdempotency.js +8 -0
- package/dist/storage/restIdempotency.js.map +1 -1
- package/dist/storage/scheduledSends.d.ts +2 -1
- package/dist/storage/scheduledSends.d.ts.map +1 -1
- package/dist/storage/scheduledSends.js +6 -0
- package/dist/storage/scheduledSends.js.map +1 -1
- package/package.json +6 -5
package/dist/mcp/buildServer.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import { VERSION } from '../version.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
4
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
5
|
+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
7
|
import { EmailError, isEmailError, mergeEmailQueries, parseEmailSearch, parseSingleAddress, } from '@fluxmail/core';
|
|
5
8
|
import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
|
|
6
9
|
import { FULL_PERMISSION_POLICY, hasCapability, normalizePermissionPolicy, } from '../permissions.js';
|
|
7
10
|
import { captureOperation } from '../telemetry.js';
|
|
8
11
|
import { logFailure } from '../logging.js';
|
|
12
|
+
import { ClientInputError, publicError } from '../service/publicErrors.js';
|
|
13
|
+
import { outputSchemas } from './outputSchemas.js';
|
|
9
14
|
const MAX_BODY_CHARS = 50_000;
|
|
10
15
|
const TELEMETRY_ERROR = Symbol('telemetryError');
|
|
16
|
+
const EMPTY_INPUT_SCHEMA = {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {},
|
|
19
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
20
|
+
};
|
|
21
|
+
function toolJsonSchema(shape, pipeStrategy) {
|
|
22
|
+
const schema = zodToJsonSchema(z.object(shape), { strictUnions: true, pipeStrategy });
|
|
23
|
+
// MCP clients use JSON Schema 2020-12 when no dialect is declared.
|
|
24
|
+
if (pipeStrategy === 'output')
|
|
25
|
+
delete schema.$schema;
|
|
26
|
+
return schema;
|
|
27
|
+
}
|
|
11
28
|
const accountIdParam = z
|
|
12
29
|
.string()
|
|
13
30
|
.min(1)
|
|
@@ -50,7 +67,10 @@ const draftShape = {
|
|
|
50
67
|
cc: addressList.optional(),
|
|
51
68
|
bcc: addressList.optional(),
|
|
52
69
|
subject: z.string().optional().describe('Defaults to "Re: ..." when replying'),
|
|
53
|
-
bodyText: z
|
|
70
|
+
bodyText: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('Plain-text body. Line breaks appear in the sent email. Keep each prose paragraph on one continuous line and separate paragraphs with blank lines.'),
|
|
54
74
|
bodyHtml: z.string().optional().describe('HTML body'),
|
|
55
75
|
replyToMessageId: idParam
|
|
56
76
|
.optional()
|
|
@@ -70,14 +90,14 @@ function parseAddresses(raw) {
|
|
|
70
90
|
const parsed = raw.map((r) => {
|
|
71
91
|
const addr = parseSingleAddress(r);
|
|
72
92
|
if (!addr)
|
|
73
|
-
throw new
|
|
93
|
+
throw new ClientInputError('invalid_request', `Could not parse email address: "${r}"`);
|
|
74
94
|
return addr;
|
|
75
95
|
});
|
|
76
96
|
return parsed;
|
|
77
97
|
}
|
|
78
98
|
function toSendInput(args) {
|
|
79
99
|
if (args.replyAll && !args.replyToMessageId) {
|
|
80
|
-
throw new
|
|
100
|
+
throw new ClientInputError('invalid_request', 'replyAll requires replyToMessageId');
|
|
81
101
|
}
|
|
82
102
|
const input = {
|
|
83
103
|
body: {
|
|
@@ -121,33 +141,51 @@ export function toSendRequest(args) {
|
|
|
121
141
|
'from',
|
|
122
142
|
];
|
|
123
143
|
if (contentKeys.some((key) => args[key] !== undefined)) {
|
|
124
|
-
throw new
|
|
144
|
+
throw new ClientInputError('invalid_request', 'draftId cannot be combined with message content; update the draft before sending it');
|
|
125
145
|
}
|
|
126
146
|
return { draftId: args.draftId };
|
|
127
147
|
}
|
|
128
148
|
return toSendInput(args);
|
|
129
149
|
}
|
|
130
|
-
function
|
|
131
|
-
if (!message.body)
|
|
132
|
-
return message;
|
|
133
|
-
const body = {
|
|
150
|
+
function selectBody(message, format, budget) {
|
|
151
|
+
if (!message.body || format === 'none')
|
|
152
|
+
return { ...message, body: undefined };
|
|
153
|
+
const body = {};
|
|
154
|
+
const bodyTruncation = {};
|
|
155
|
+
let remaining = budget;
|
|
134
156
|
for (const key of ['text', 'html']) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
157
|
+
if (format !== 'both' && format !== key)
|
|
158
|
+
continue;
|
|
159
|
+
const value = message.body[key];
|
|
160
|
+
if (value === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
const selected = value.slice(0, remaining);
|
|
163
|
+
body[key] = selected;
|
|
164
|
+
bodyTruncation[key] = {
|
|
165
|
+
totalChars: value.length,
|
|
166
|
+
...(selected.length < value.length ? { nextOffset: selected.length } : {}),
|
|
167
|
+
};
|
|
168
|
+
remaining -= selected.length;
|
|
139
169
|
}
|
|
140
|
-
return { ...message, body };
|
|
170
|
+
return { ...message, body, bodyTruncation };
|
|
141
171
|
}
|
|
142
172
|
function ok(data) {
|
|
143
173
|
return {
|
|
144
|
-
|
|
174
|
+
structuredContent: { data },
|
|
175
|
+
content: [{ type: 'text', text: JSON.stringify({ data }, null, 2) }],
|
|
145
176
|
};
|
|
146
177
|
}
|
|
147
|
-
function
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
178
|
+
function deliveryResult(operation) {
|
|
179
|
+
const result = ok(operation);
|
|
180
|
+
if (operation.status === 'failed' || operation.status === 'uncertain') {
|
|
181
|
+
result.isError = true;
|
|
182
|
+
result[TELEMETRY_ERROR] = true;
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
function toolError(err, requestId) {
|
|
187
|
+
const safe = publicError(err, requestId);
|
|
188
|
+
const payload = { error: safe.code, message: safe.message, requestId, ...(safe.data ? { data: safe.data } : {}) };
|
|
151
189
|
return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
152
190
|
}
|
|
153
191
|
function toolFeatureProperties(tool, args) {
|
|
@@ -168,7 +206,7 @@ function toolFeatureProperties(tool, args) {
|
|
|
168
206
|
case 'modify_emails':
|
|
169
207
|
return typeof input.action === 'string' ? { action: input.action } : {};
|
|
170
208
|
case 'download_attachment':
|
|
171
|
-
return { destination: 'inline' };
|
|
209
|
+
return { destination: input.inline === true ? 'inline' : 'resource' };
|
|
172
210
|
default:
|
|
173
211
|
return {};
|
|
174
212
|
}
|
|
@@ -197,10 +235,12 @@ function handleResult(tool, fn, gate, options = {}) {
|
|
|
197
235
|
return result;
|
|
198
236
|
}
|
|
199
237
|
catch (err) {
|
|
238
|
+
const requestId = randomUUID();
|
|
200
239
|
logFailure(options.logger, 'mcp.operation_failed', err, {
|
|
201
240
|
productSurface: 'mcp',
|
|
202
241
|
operation: tool,
|
|
203
242
|
durationMs: performance.now() - startedAt,
|
|
243
|
+
details: { request_id: requestId },
|
|
204
244
|
});
|
|
205
245
|
captureOperation(options.telemetry, {
|
|
206
246
|
productSurface: 'mcp',
|
|
@@ -211,7 +251,7 @@ function handleResult(tool, fn, gate, options = {}) {
|
|
|
211
251
|
transport: options.transport ?? 'unknown',
|
|
212
252
|
properties: toolFeatureProperties(tool, args[0]),
|
|
213
253
|
});
|
|
214
|
-
return toolError(err);
|
|
254
|
+
return toolError(err, requestId);
|
|
215
255
|
}
|
|
216
256
|
finally {
|
|
217
257
|
finishActivity?.();
|
|
@@ -297,39 +337,56 @@ export function buildMcpServer(service, options = {}) {
|
|
|
297
337
|
"care about, e.g. 'Sent \"Quarterly report\" to ann@example.com' or 'Archived the thread', rather " +
|
|
298
338
|
'than echoing raw payloads, ids, or field names.',
|
|
299
339
|
});
|
|
340
|
+
const listedTools = [];
|
|
341
|
+
const registerTool = ((name, config, callback) => {
|
|
342
|
+
const outputSchema = outputSchemas[name];
|
|
343
|
+
const registered = server.registerTool(name, { ...config, outputSchema }, callback);
|
|
344
|
+
const inputShape = config.inputSchema;
|
|
345
|
+
listedTools.push({
|
|
346
|
+
name,
|
|
347
|
+
title: config.title,
|
|
348
|
+
description: config.description,
|
|
349
|
+
inputSchema: inputShape && Object.keys(inputShape).length ? toolJsonSchema(inputShape, 'input') : EMPTY_INPUT_SCHEMA,
|
|
350
|
+
outputSchema: toolJsonSchema(outputSchema, 'output'),
|
|
351
|
+
annotations: config.annotations,
|
|
352
|
+
execution: { taskSupport: 'forbidden' },
|
|
353
|
+
_meta: config._meta,
|
|
354
|
+
});
|
|
355
|
+
return registered;
|
|
356
|
+
});
|
|
300
357
|
if (can('mail.read'))
|
|
301
|
-
|
|
358
|
+
registerTool('list_accounts', {
|
|
302
359
|
description: 'List connected email accounts (id, provider, email, status, capabilities).',
|
|
303
360
|
inputSchema: {},
|
|
304
361
|
annotations: { readOnlyHint: true },
|
|
305
362
|
}, gated('list_accounts', 'mail.read', async () => service.listAccounts()));
|
|
306
363
|
if (can('mail.read'))
|
|
307
|
-
|
|
364
|
+
registerTool('get_status', {
|
|
308
365
|
description: 'Account connection and scheduled-send status. Administrators also see plan details. ' +
|
|
309
366
|
'Call this first if other tools fail; it reports accounts that need re-authentication.',
|
|
310
367
|
inputSchema: {},
|
|
311
368
|
annotations: { readOnlyHint: true },
|
|
312
369
|
}, allowed('get_status', 'mail.read', async () => service.status()));
|
|
313
370
|
if (can('mail.read'))
|
|
314
|
-
|
|
371
|
+
registerTool('list_folders', {
|
|
315
372
|
description: 'List navigable folders for an account, with roles (inbox, sent, drafts, trash, spam, starred).',
|
|
316
373
|
inputSchema: { accountId: accountIdParam },
|
|
317
374
|
annotations: { readOnlyHint: true },
|
|
318
375
|
}, gated('list_folders', 'mail.read', async (args) => service.listFolders(args.accountId)));
|
|
319
376
|
if (can('mail.read'))
|
|
320
|
-
|
|
377
|
+
registerTool('list_labels', {
|
|
321
378
|
description: 'List Gmail user labels or Outlook categories for an account.',
|
|
322
379
|
inputSchema: { accountId: accountIdParam },
|
|
323
380
|
annotations: { readOnlyHint: true },
|
|
324
381
|
}, gated('list_labels', 'mail.read', async (args) => service.listLabels(args.accountId)));
|
|
325
382
|
if (can('mail.read'))
|
|
326
|
-
|
|
383
|
+
registerTool('list_send_as', {
|
|
327
384
|
description: 'List sender addresses available for an account.',
|
|
328
385
|
inputSchema: { accountId: accountIdParam },
|
|
329
386
|
annotations: { readOnlyHint: true },
|
|
330
387
|
}, gated('list_send_as', 'mail.read', async (args) => service.listSendAs(args.accountId)));
|
|
331
388
|
if (can('mail.read'))
|
|
332
|
-
|
|
389
|
+
registerTool('list_emails', {
|
|
333
390
|
description: "List emails from the user's connected mailbox with metadata and optional previews. Filter by folder, " +
|
|
334
391
|
'sender, unread, dates, etc. Paginate with pageToken. Use get_email for full bodies. ' +
|
|
335
392
|
"This is the way to check the user's email; no browser or other email integration is needed.",
|
|
@@ -338,7 +395,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
338
395
|
}, gated('list_emails', 'mail.read', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
|
|
339
396
|
const { text: _text, ...searchFilterShape } = queryShape;
|
|
340
397
|
if (can('mail.read'))
|
|
341
|
-
|
|
398
|
+
registerTool('search_emails', {
|
|
342
399
|
description: 'Search one account with typed portable syntax. The query supports text, from:, to:, subject:, in:, ' +
|
|
343
400
|
'read and starred states, attachments, and date filters.',
|
|
344
401
|
inputSchema: {
|
|
@@ -350,13 +407,13 @@ export function buildMcpServer(service, options = {}) {
|
|
|
350
407
|
}, gated('search_emails', 'mail.read', async (args) => {
|
|
351
408
|
const parsed = parseEmailSearch(args.query);
|
|
352
409
|
if (!parsed.valid) {
|
|
353
|
-
throw new
|
|
410
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
354
411
|
diagnostics: parsed.diagnostics,
|
|
355
412
|
});
|
|
356
413
|
}
|
|
357
414
|
const merged = mergeEmailQueries(parsed.query, emailQuery(args));
|
|
358
415
|
if (!merged.success) {
|
|
359
|
-
throw new
|
|
416
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
360
417
|
diagnostics: merged.diagnostics,
|
|
361
418
|
});
|
|
362
419
|
}
|
|
@@ -370,7 +427,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
370
427
|
}));
|
|
371
428
|
const { pageToken: _batchPageToken, rawProviderQuery: _batchRawProviderQuery, ...batchSearchFilterShape } = searchFilterShape;
|
|
372
429
|
if (can('mail.read'))
|
|
373
|
-
|
|
430
|
+
registerTool('search_emails_batch', {
|
|
374
431
|
description: 'Search up to 20 accounts with one portable query and return one result group per account.',
|
|
375
432
|
inputSchema: {
|
|
376
433
|
accounts: z
|
|
@@ -385,13 +442,13 @@ export function buildMcpServer(service, options = {}) {
|
|
|
385
442
|
}, gatedResult('search_emails_batch', 'mail.read', async (args) => {
|
|
386
443
|
const parsed = parseEmailSearch(args.query);
|
|
387
444
|
if (!parsed.valid) {
|
|
388
|
-
throw new
|
|
445
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
389
446
|
diagnostics: parsed.diagnostics,
|
|
390
447
|
});
|
|
391
448
|
}
|
|
392
449
|
const merged = mergeEmailQueries(parsed.query, emailQuery(args));
|
|
393
450
|
if (!merged.success) {
|
|
394
|
-
throw new
|
|
451
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
395
452
|
diagnostics: merged.diagnostics,
|
|
396
453
|
});
|
|
397
454
|
}
|
|
@@ -422,22 +479,91 @@ export function buildMcpServer(service, options = {}) {
|
|
|
422
479
|
return response;
|
|
423
480
|
}));
|
|
424
481
|
if (can('mail.read'))
|
|
425
|
-
|
|
482
|
+
registerTool('get_email', {
|
|
426
483
|
description: 'Fetch one email in full: body (text and/or HTML), recipients, attachment metadata.',
|
|
427
|
-
inputSchema: {
|
|
484
|
+
inputSchema: {
|
|
485
|
+
accountId: accountIdParam,
|
|
486
|
+
messageId: idParam,
|
|
487
|
+
bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
|
|
488
|
+
},
|
|
489
|
+
annotations: { readOnlyHint: true },
|
|
490
|
+
}, gated('get_email', 'mail.read', async (args) => selectBody(await service.getMessage(args.accountId, args.messageId), args.bodyFormat ?? 'both', MAX_BODY_CHARS)));
|
|
491
|
+
if (can('mail.read'))
|
|
492
|
+
registerTool('get_email_body', {
|
|
493
|
+
description: 'Read a bounded portion of one email body. Use nextOffset to continue.',
|
|
494
|
+
inputSchema: {
|
|
495
|
+
accountId: accountIdParam,
|
|
496
|
+
messageId: idParam,
|
|
497
|
+
format: z.enum(['text', 'html']),
|
|
498
|
+
offset: z.number().int().min(0).optional(),
|
|
499
|
+
maxChars: z.number().int().min(1).max(MAX_BODY_CHARS).optional(),
|
|
500
|
+
},
|
|
428
501
|
annotations: { readOnlyHint: true },
|
|
429
|
-
}, gated('
|
|
502
|
+
}, gated('get_email_body', 'mail.read', async (args) => {
|
|
503
|
+
const message = await service.getMessage(args.accountId, args.messageId);
|
|
504
|
+
const value = message.body?.[args.format] ?? '';
|
|
505
|
+
const offset = args.offset ?? 0;
|
|
506
|
+
const text = value.slice(offset, offset + (args.maxChars ?? MAX_BODY_CHARS));
|
|
507
|
+
return {
|
|
508
|
+
format: args.format,
|
|
509
|
+
text,
|
|
510
|
+
offset,
|
|
511
|
+
totalChars: value.length,
|
|
512
|
+
...(offset + text.length < value.length ? { nextOffset: offset + text.length } : {}),
|
|
513
|
+
};
|
|
514
|
+
}));
|
|
430
515
|
if (can('mail.read'))
|
|
431
|
-
|
|
432
|
-
description: 'Fetch a
|
|
433
|
-
inputSchema: {
|
|
516
|
+
registerTool('get_thread', {
|
|
517
|
+
description: 'Fetch a page of conversation messages with bounded body content.',
|
|
518
|
+
inputSchema: {
|
|
519
|
+
accountId: accountIdParam,
|
|
520
|
+
threadId: idParam,
|
|
521
|
+
pageSize: z.number().int().min(1).max(25).optional(),
|
|
522
|
+
pageToken: z.string().min(1).optional(),
|
|
523
|
+
bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
|
|
524
|
+
},
|
|
434
525
|
annotations: { readOnlyHint: true },
|
|
435
526
|
}, gated('get_thread', 'mail.read', async (args) => {
|
|
436
527
|
const thread = await service.getThread(args.accountId, args.threadId);
|
|
437
|
-
|
|
528
|
+
let offset = 0;
|
|
529
|
+
if (args.pageToken) {
|
|
530
|
+
try {
|
|
531
|
+
const parsed = JSON.parse(Buffer.from(args.pageToken, 'base64url').toString('utf8'));
|
|
532
|
+
if (parsed.threadId !== args.threadId || !Number.isInteger(parsed.offset) || parsed.offset < 0)
|
|
533
|
+
throw new Error('token');
|
|
534
|
+
offset = parsed.offset;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
throw new ClientInputError('invalid_request', 'Invalid thread page token.');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const pageSize = args.pageSize ?? 10;
|
|
541
|
+
let budget = MAX_BODY_CHARS;
|
|
542
|
+
const messages = thread.messages.slice(offset, offset + pageSize).map((message) => {
|
|
543
|
+
const selected = selectBody(message, args.bodyFormat ?? 'both', budget);
|
|
544
|
+
budget -= (selected.body?.text?.length ?? 0) + (selected.body?.html?.length ?? 0);
|
|
545
|
+
return selected;
|
|
546
|
+
});
|
|
547
|
+
const nextOffset = offset + messages.length;
|
|
548
|
+
return {
|
|
549
|
+
id: thread.id,
|
|
550
|
+
subject: thread.subject,
|
|
551
|
+
messages,
|
|
552
|
+
...(nextOffset < thread.messages.length
|
|
553
|
+
? {
|
|
554
|
+
nextPageToken: Buffer.from(JSON.stringify({ threadId: args.threadId, offset: nextOffset })).toString('base64url'),
|
|
555
|
+
}
|
|
556
|
+
: {}),
|
|
557
|
+
};
|
|
438
558
|
}));
|
|
439
559
|
if (can('mail.drafts'))
|
|
440
|
-
|
|
560
|
+
registerTool('get_draft', {
|
|
561
|
+
description: 'Read an existing draft by its draft ID.',
|
|
562
|
+
inputSchema: { accountId: accountIdParam, draftId: idParam },
|
|
563
|
+
annotations: { readOnlyHint: true },
|
|
564
|
+
}, gated('get_draft', 'mail.drafts', async (args) => service.getDraft(args.accountId, args.draftId)));
|
|
565
|
+
if (can('mail.drafts'))
|
|
566
|
+
registerTool('create_draft', {
|
|
441
567
|
description: 'Create a draft. For a reply draft, pass replyToMessageId (recipients/subject are derived; replyAll for reply-all).',
|
|
442
568
|
inputSchema: draftShape,
|
|
443
569
|
}, gated('create_draft', 'mail.drafts', async (args) => {
|
|
@@ -446,7 +572,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
446
572
|
return service.createDraft(args.accountId, toSendInput(args));
|
|
447
573
|
}));
|
|
448
574
|
if (can('mail.drafts'))
|
|
449
|
-
|
|
575
|
+
registerTool('update_draft', {
|
|
450
576
|
description: 'Replace the content of an existing draft (full replacement, not a patch).',
|
|
451
577
|
inputSchema: { draftId: idParam, ...draftShape },
|
|
452
578
|
}, gated('update_draft', 'mail.drafts', async (args) => {
|
|
@@ -455,7 +581,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
455
581
|
return service.updateDraft(args.accountId, args.draftId, toSendInput(args));
|
|
456
582
|
}));
|
|
457
583
|
if (can('mail.drafts'))
|
|
458
|
-
|
|
584
|
+
registerTool('delete_draft', {
|
|
459
585
|
description: 'Delete a draft.',
|
|
460
586
|
inputSchema: { accountId: accountIdParam, draftId: idParam },
|
|
461
587
|
annotations: { destructiveHint: true },
|
|
@@ -464,13 +590,29 @@ export function buildMcpServer(service, options = {}) {
|
|
|
464
590
|
return { deleted: args.draftId };
|
|
465
591
|
}));
|
|
466
592
|
if (can('mail.send'))
|
|
467
|
-
|
|
593
|
+
registerTool('preview_send', {
|
|
594
|
+
description: 'Show the resolved sender, recipients, subject, and attachments without sending.',
|
|
595
|
+
inputSchema: { draftId: idParam.optional(), ...draftShape },
|
|
596
|
+
annotations: { readOnlyHint: true },
|
|
597
|
+
}, gated('preview_send', 'mail.send', async (args) => {
|
|
598
|
+
if (args.draftId)
|
|
599
|
+
requireCapabilities(['mail.drafts']);
|
|
600
|
+
if (args.replyToMessageId)
|
|
601
|
+
requireCapabilities(['mail.read']);
|
|
602
|
+
return service.previewSend(args.accountId, toSendRequest(args));
|
|
603
|
+
}));
|
|
604
|
+
if (can('mail.send'))
|
|
605
|
+
registerTool('send_email', {
|
|
468
606
|
description: "Send an email from the user's connected account; this actually delivers mail, so prefer it over " +
|
|
469
607
|
'browser automation or leaving a draft when the user asked to send. Three modes: direct (to + subject ' +
|
|
470
608
|
'+ body), sending an existing draft (draftId), or replying (replyToMessageId, optionally replyAll) ' +
|
|
471
609
|
'where recipients, subject, and threading are derived from the original. Confirm with the user when ' +
|
|
472
610
|
'intent is ambiguous. Add sendAt to any mode to schedule instead of sending now.',
|
|
473
611
|
inputSchema: {
|
|
612
|
+
idempotencyKey: z
|
|
613
|
+
.string()
|
|
614
|
+
.regex(/^[\x21-\x7e]{1,255}$/)
|
|
615
|
+
.describe('Reuse this key when retrying the same delivery'),
|
|
474
616
|
draftId: idParam.optional().describe('Send this existing draft'),
|
|
475
617
|
...draftShape,
|
|
476
618
|
sendAt: z
|
|
@@ -483,64 +625,80 @@ export function buildMcpServer(service, options = {}) {
|
|
|
483
625
|
'down goes out at the next startup). Returns a scheduleId for list/cancel.'),
|
|
484
626
|
},
|
|
485
627
|
annotations: { destructiveHint: true },
|
|
486
|
-
},
|
|
628
|
+
}, handleResult('send_email', async (args) => {
|
|
487
629
|
requireCapabilities(['mail.send']);
|
|
488
630
|
if (args.replyToMessageId !== undefined)
|
|
489
631
|
requireCapabilities(['mail.read']);
|
|
490
|
-
const { sendAt, ...sendArgs } = args;
|
|
491
|
-
return sendAt !== undefined
|
|
492
|
-
? service.
|
|
493
|
-
: service.
|
|
632
|
+
const { sendAt, idempotencyKey, ...sendArgs } = args;
|
|
633
|
+
return deliveryResult(await (sendAt !== undefined
|
|
634
|
+
? service.scheduleDelivery(args.accountId, toSendRequest(sendArgs), sendAt, idempotencyKey)
|
|
635
|
+
: service.deliver(args.accountId, toSendRequest(sendArgs), idempotencyKey)));
|
|
494
636
|
}, () => service.enforceQuota(), options));
|
|
495
637
|
if (can('mail.read'))
|
|
496
|
-
|
|
638
|
+
registerTool('list_scheduled_emails', {
|
|
497
639
|
description: 'List scheduled sends: pending ones first (with sendAt), then past ones (sent, failed, canceled). ' +
|
|
498
640
|
'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
|
|
499
641
|
'Fluxmail server is running.',
|
|
500
642
|
inputSchema: { accountId: accountIdParam },
|
|
501
643
|
annotations: { readOnlyHint: true },
|
|
502
644
|
}, gated('list_scheduled_emails', 'mail.read', async (args) => service.listScheduled(args.accountId)));
|
|
645
|
+
if (can('mail.send'))
|
|
646
|
+
registerTool('get_delivery_operation', {
|
|
647
|
+
description: 'Check whether a send or forward succeeded, failed, or has an uncertain outcome.',
|
|
648
|
+
inputSchema: { accountId: idParam, operationId: idParam },
|
|
649
|
+
annotations: { readOnlyHint: true },
|
|
650
|
+
}, allowed('get_delivery_operation', 'mail.send', async (args) => service.getDelivery(args.accountId, args.operationId)));
|
|
503
651
|
if (can('mail.drafts'))
|
|
504
|
-
|
|
652
|
+
registerTool('cancel_scheduled_email', {
|
|
505
653
|
description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
|
|
506
654
|
'The draft stays in the Drafts folder, so the content is not lost.',
|
|
507
655
|
inputSchema: { scheduleId: idParam },
|
|
508
656
|
}, gated('cancel_scheduled_email', 'mail.drafts', async (args) => service.cancelScheduled(args.scheduleId)));
|
|
509
657
|
if (canAll(['mail.send', 'mail.read']))
|
|
510
|
-
|
|
658
|
+
registerTool('forward_email', {
|
|
511
659
|
description: 'Forward an email to new recipients: quoted original body, "Fwd:" subject, original attachments included ' +
|
|
512
660
|
'unless includeAttachments=false. Optional comment appears above the forwarded content.',
|
|
513
661
|
inputSchema: {
|
|
662
|
+
idempotencyKey: z
|
|
663
|
+
.string()
|
|
664
|
+
.regex(/^[\x21-\x7e]{1,255}$/)
|
|
665
|
+
.describe('Reuse this key when retrying the same forward'),
|
|
514
666
|
accountId: accountIdParam,
|
|
515
667
|
messageId: idParam,
|
|
516
668
|
from: z.string().email().optional().describe('Connected address or an available send-as address'),
|
|
517
669
|
to: addressList.min(1),
|
|
518
670
|
cc: addressList.optional(),
|
|
519
|
-
comment: z
|
|
671
|
+
comment: z
|
|
672
|
+
.string()
|
|
673
|
+
.optional()
|
|
674
|
+
.describe('Comment above the forwarded message. Keep prose paragraphs on one continuous line and separate paragraphs with blank lines.'),
|
|
520
675
|
includeAttachments: z.boolean().optional().describe('Default true'),
|
|
521
676
|
},
|
|
522
677
|
annotations: { destructiveHint: true },
|
|
523
|
-
},
|
|
678
|
+
}, handleResult('forward_email', async (args) => {
|
|
524
679
|
requireCapabilities(['mail.send', 'mail.read']);
|
|
525
680
|
const to = parseAddresses(args.to) ?? [];
|
|
526
681
|
const cc = parseAddresses(args.cc);
|
|
527
|
-
return service.
|
|
682
|
+
return deliveryResult(await service.deliverForward(args.accountId, {
|
|
528
683
|
messageId: args.messageId,
|
|
529
684
|
to,
|
|
530
685
|
...(cc?.length ? { cc } : {}),
|
|
531
686
|
...(args.comment !== undefined ? { comment: args.comment } : {}),
|
|
532
687
|
...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
|
|
533
688
|
...(args.from !== undefined ? { from: args.from } : {}),
|
|
534
|
-
});
|
|
689
|
+
}, args.idempotencyKey));
|
|
535
690
|
}, () => service.enforceQuota(), options));
|
|
536
691
|
const modifyActions = Object.keys(MODIFY_CAPABILITIES).filter((action) => can(MODIFY_CAPABILITIES[action]));
|
|
537
692
|
if (modifyActions.length) {
|
|
538
693
|
const allowedModifyActions = new Set(modifyActions);
|
|
539
|
-
|
|
694
|
+
registerTool('modify_emails', {
|
|
540
695
|
description: 'Batch-modify emails using the actions allowed for this connection. Moving requires folder; labels require labels.',
|
|
541
696
|
inputSchema: {
|
|
542
697
|
accountId: accountIdParam,
|
|
543
|
-
messageIds: z
|
|
698
|
+
messageIds: z
|
|
699
|
+
.array(idParam)
|
|
700
|
+
.min(1)
|
|
701
|
+
.refine((ids) => new Set(ids).size <= 100, 'At most 100 distinct message IDs are allowed.'),
|
|
544
702
|
action: z.enum(modifyActions),
|
|
545
703
|
folder: z.string().min(1).optional().describe('Target folder for action=move'),
|
|
546
704
|
labels: z.array(z.string().min(1)).max(100).optional().describe('Labels for addLabels/removeLabels'),
|
|
@@ -548,7 +706,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
548
706
|
annotations: {
|
|
549
707
|
destructiveHint: canAny(['mail.trash', 'mail.delete']),
|
|
550
708
|
},
|
|
551
|
-
},
|
|
709
|
+
}, handleResult('modify_emails', async (args) => {
|
|
552
710
|
if (!allowedModifyActions.has(args.action)) {
|
|
553
711
|
throw new EmailError('permission_denied', `This MCP connection does not allow action=${args.action}.`);
|
|
554
712
|
}
|
|
@@ -556,63 +714,91 @@ export function buildMcpServer(service, options = {}) {
|
|
|
556
714
|
let action;
|
|
557
715
|
if (args.action === 'move') {
|
|
558
716
|
if (!args.folder)
|
|
559
|
-
throw new
|
|
717
|
+
throw new ClientInputError('invalid_request', 'action=move requires "folder"');
|
|
560
718
|
if (PROTECTED_MOVE_DESTINATIONS.has(args.folder.trim().toLowerCase())) {
|
|
561
|
-
throw new
|
|
719
|
+
throw new ClientInputError('invalid_request', 'Use the dedicated archive or trash action for this folder');
|
|
562
720
|
}
|
|
563
721
|
action = { move: args.folder };
|
|
564
722
|
}
|
|
565
723
|
else if (args.action === 'addLabels' || args.action === 'removeLabels') {
|
|
566
724
|
if (!args.labels?.length) {
|
|
567
|
-
throw new
|
|
725
|
+
throw new ClientInputError('invalid_request', `action=${args.action} requires "labels"`);
|
|
568
726
|
}
|
|
569
727
|
action = args.action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
|
|
570
728
|
}
|
|
571
729
|
else {
|
|
572
730
|
action = args.action;
|
|
573
731
|
}
|
|
574
|
-
await service.modify(args.accountId, args.messageIds, action);
|
|
575
|
-
|
|
732
|
+
const result = await service.modify(args.accountId, args.messageIds, action);
|
|
733
|
+
const response = ok({ ...result, action: args.action });
|
|
734
|
+
if (result.failed.length || result.uncertainIds.length) {
|
|
735
|
+
response.isError = true;
|
|
736
|
+
response[TELEMETRY_ERROR] = true;
|
|
737
|
+
}
|
|
738
|
+
return response;
|
|
576
739
|
}, () => service.enforceQuota(), options));
|
|
577
740
|
}
|
|
578
741
|
if (can('mail.read'))
|
|
579
|
-
|
|
580
|
-
description: '
|
|
742
|
+
registerTool('download_attachment', {
|
|
743
|
+
description: 'Get attachment metadata and a fetchable resource link. Set inline to embed the bytes.',
|
|
581
744
|
inputSchema: {
|
|
582
|
-
accountId:
|
|
745
|
+
accountId: idParam,
|
|
583
746
|
messageId: idParam,
|
|
584
747
|
attachmentId: attachmentIdParam,
|
|
748
|
+
inline: z.boolean().optional(),
|
|
585
749
|
},
|
|
586
750
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
587
751
|
}, gatedResult('download_attachment', 'mail.read', async (args) => {
|
|
588
752
|
const { meta, content } = await service.getAttachment(args.accountId, args.messageId, args.attachmentId, maxAttachmentBytes);
|
|
589
753
|
if (content.length > maxAttachmentBytes) {
|
|
590
|
-
throw new
|
|
754
|
+
throw new ClientInputError('invalid_request', 'Attachment is too large to return through MCP.', {
|
|
591
755
|
sizeBytes: content.length,
|
|
592
756
|
maxBytes: maxAttachmentBytes,
|
|
593
757
|
});
|
|
594
758
|
}
|
|
595
759
|
const uri = [
|
|
596
760
|
'fluxmail://attachment',
|
|
597
|
-
encodeURIComponent(args.accountId
|
|
761
|
+
encodeURIComponent(args.accountId),
|
|
598
762
|
encodeURIComponent(args.messageId),
|
|
599
763
|
encodeURIComponent(args.attachmentId),
|
|
600
764
|
encodeURIComponent(meta.filename),
|
|
601
765
|
].join('/');
|
|
602
766
|
return {
|
|
767
|
+
structuredContent: { data: meta },
|
|
603
768
|
content: [
|
|
604
|
-
{ type: 'text', text: JSON.stringify(meta, null, 2) },
|
|
605
|
-
{
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
769
|
+
{ type: 'text', text: JSON.stringify({ data: meta }, null, 2) },
|
|
770
|
+
{ type: 'resource_link', name: meta.filename, uri, mimeType: meta.mimeType },
|
|
771
|
+
...(args.inline
|
|
772
|
+
? [
|
|
773
|
+
{
|
|
774
|
+
type: 'resource',
|
|
775
|
+
resource: {
|
|
776
|
+
uri,
|
|
777
|
+
mimeType: meta.mimeType || 'application/octet-stream',
|
|
778
|
+
blob: content.toString('base64'),
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
]
|
|
782
|
+
: []),
|
|
613
783
|
],
|
|
614
784
|
};
|
|
615
785
|
}));
|
|
786
|
+
if (can('mail.read'))
|
|
787
|
+
server.registerResource('attachment', new ResourceTemplate('fluxmail://attachment/{accountId}/{messageId}/{attachmentId}/{filename}', {
|
|
788
|
+
list: undefined,
|
|
789
|
+
}), { description: 'Read one attachment from an authorized mailbox.' }, async (uri, variables) => {
|
|
790
|
+
requireCapabilities(['mail.read']);
|
|
791
|
+
service.enforceQuota();
|
|
792
|
+
const { meta, content } = await service.getAttachment(decodeURIComponent(String(variables.accountId)), decodeURIComponent(String(variables.messageId)), decodeURIComponent(String(variables.attachmentId)), maxAttachmentBytes);
|
|
793
|
+
if (content.length > maxAttachmentBytes) {
|
|
794
|
+
throw new ClientInputError('invalid_request', 'Attachment is too large to return through MCP.');
|
|
795
|
+
}
|
|
796
|
+
return { contents: [{ uri: uri.href, mimeType: meta.mimeType, blob: content.toString('base64') }] };
|
|
797
|
+
});
|
|
798
|
+
if (listedTools.length) {
|
|
799
|
+
// The SDK's list handler declares draft-07 for output schemas, which strict clients reject.
|
|
800
|
+
server.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: listedTools }));
|
|
801
|
+
}
|
|
616
802
|
return server;
|
|
617
803
|
}
|
|
618
804
|
//# sourceMappingURL=buildServer.js.map
|