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