fluxmail 0.9.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 +4 -2
- package/dist/accounts/googleAuth.d.ts.map +1 -1
- package/dist/accounts/googleAuth.js +20 -85
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/microsoftAuth.d.ts +2 -1
- package/dist/accounts/microsoftAuth.d.ts.map +1 -1
- package/dist/accounts/microsoftAuth.js +16 -80
- package/dist/accounts/microsoftAuth.js.map +1 -1
- package/dist/accounts/oauthCallback.d.ts +52 -0
- package/dist/accounts/oauthCallback.d.ts.map +1 -0
- package/dist/accounts/oauthCallback.js +237 -0
- package/dist/accounts/oauthCallback.js.map +1 -0
- 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 +6 -0
- package/dist/accounts/telemetry.d.ts.map +1 -1
- package/dist/accounts/telemetry.js +19 -0
- package/dist/accounts/telemetry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +114 -36
- 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 +113 -19
- 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 +270 -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 +236 -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 +48 -5
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +258 -37
- 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 +2 -0
- package/dist/service/searchCursor.d.ts.map +1 -1
- package/dist/service/searchCursor.js +6 -2
- 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
|
|
@@ -38,6 +41,10 @@ const queryShape = {
|
|
|
38
41
|
pageSize: z.number().int().min(1).max(100).optional().describe('Defaults to 25'),
|
|
39
42
|
pageToken: z.string().min(1).optional().describe('nextPageToken from a previous call'),
|
|
40
43
|
includeSnippet: z.boolean().optional().describe('Request or suppress message previews'),
|
|
44
|
+
includeSearchContext: z
|
|
45
|
+
.boolean()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('Include a match-centered body excerpt; requires a portable text query'),
|
|
41
48
|
};
|
|
42
49
|
const draftShape = {
|
|
43
50
|
accountId: accountIdParam,
|
|
@@ -46,7 +53,10 @@ const draftShape = {
|
|
|
46
53
|
cc: addressList.optional(),
|
|
47
54
|
bcc: addressList.optional(),
|
|
48
55
|
subject: z.string().optional().describe('Defaults to "Re: ..." when replying'),
|
|
49
|
-
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.'),
|
|
50
60
|
bodyHtml: z.string().optional().describe('HTML body'),
|
|
51
61
|
replyToMessageId: idParam
|
|
52
62
|
.optional()
|
|
@@ -66,14 +76,14 @@ function parseAddresses(raw) {
|
|
|
66
76
|
const parsed = raw.map((r) => {
|
|
67
77
|
const addr = parseSingleAddress(r);
|
|
68
78
|
if (!addr)
|
|
69
|
-
throw new
|
|
79
|
+
throw new ClientInputError('invalid_request', `Could not parse email address: "${r}"`);
|
|
70
80
|
return addr;
|
|
71
81
|
});
|
|
72
82
|
return parsed;
|
|
73
83
|
}
|
|
74
84
|
function toSendInput(args) {
|
|
75
85
|
if (args.replyAll && !args.replyToMessageId) {
|
|
76
|
-
throw new
|
|
86
|
+
throw new ClientInputError('invalid_request', 'replyAll requires replyToMessageId');
|
|
77
87
|
}
|
|
78
88
|
const input = {
|
|
79
89
|
body: {
|
|
@@ -117,33 +127,51 @@ export function toSendRequest(args) {
|
|
|
117
127
|
'from',
|
|
118
128
|
];
|
|
119
129
|
if (contentKeys.some((key) => args[key] !== undefined)) {
|
|
120
|
-
throw new
|
|
130
|
+
throw new ClientInputError('invalid_request', 'draftId cannot be combined with message content; update the draft before sending it');
|
|
121
131
|
}
|
|
122
132
|
return { draftId: args.draftId };
|
|
123
133
|
}
|
|
124
134
|
return toSendInput(args);
|
|
125
135
|
}
|
|
126
|
-
function
|
|
127
|
-
if (!message.body)
|
|
128
|
-
return message;
|
|
129
|
-
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;
|
|
130
142
|
for (const key of ['text', 'html']) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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;
|
|
135
155
|
}
|
|
136
|
-
return { ...message, body };
|
|
156
|
+
return { ...message, body, bodyTruncation };
|
|
137
157
|
}
|
|
138
158
|
function ok(data) {
|
|
139
159
|
return {
|
|
140
|
-
|
|
160
|
+
structuredContent: { data },
|
|
161
|
+
content: [{ type: 'text', text: JSON.stringify({ data }, null, 2) }],
|
|
141
162
|
};
|
|
142
163
|
}
|
|
143
|
-
function
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
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 } : {}) };
|
|
147
175
|
return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
148
176
|
}
|
|
149
177
|
function toolFeatureProperties(tool, args) {
|
|
@@ -164,7 +192,7 @@ function toolFeatureProperties(tool, args) {
|
|
|
164
192
|
case 'modify_emails':
|
|
165
193
|
return typeof input.action === 'string' ? { action: input.action } : {};
|
|
166
194
|
case 'download_attachment':
|
|
167
|
-
return { destination: 'inline' };
|
|
195
|
+
return { destination: input.inline === true ? 'inline' : 'resource' };
|
|
168
196
|
default:
|
|
169
197
|
return {};
|
|
170
198
|
}
|
|
@@ -193,10 +221,12 @@ function handleResult(tool, fn, gate, options = {}) {
|
|
|
193
221
|
return result;
|
|
194
222
|
}
|
|
195
223
|
catch (err) {
|
|
224
|
+
const requestId = randomUUID();
|
|
196
225
|
logFailure(options.logger, 'mcp.operation_failed', err, {
|
|
197
226
|
productSurface: 'mcp',
|
|
198
227
|
operation: tool,
|
|
199
228
|
durationMs: performance.now() - startedAt,
|
|
229
|
+
details: { request_id: requestId },
|
|
200
230
|
});
|
|
201
231
|
captureOperation(options.telemetry, {
|
|
202
232
|
productSurface: 'mcp',
|
|
@@ -207,7 +237,7 @@ function handleResult(tool, fn, gate, options = {}) {
|
|
|
207
237
|
transport: options.transport ?? 'unknown',
|
|
208
238
|
properties: toolFeatureProperties(tool, args[0]),
|
|
209
239
|
});
|
|
210
|
-
return toolError(err);
|
|
240
|
+
return toolError(err, requestId);
|
|
211
241
|
}
|
|
212
242
|
finally {
|
|
213
243
|
finishActivity?.();
|
|
@@ -222,6 +252,7 @@ function pageOpts(args) {
|
|
|
222
252
|
...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
|
|
223
253
|
...(args.pageToken !== undefined ? { pageToken: args.pageToken } : {}),
|
|
224
254
|
...(args.includeSnippet !== undefined ? { includeSnippet: args.includeSnippet } : {}),
|
|
255
|
+
...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
|
|
225
256
|
};
|
|
226
257
|
}
|
|
227
258
|
function emailQuery(args) {
|
|
@@ -292,39 +323,40 @@ export function buildMcpServer(service, options = {}) {
|
|
|
292
323
|
"care about, e.g. 'Sent \"Quarterly report\" to ann@example.com' or 'Archived the thread', rather " +
|
|
293
324
|
'than echoing raw payloads, ids, or field names.',
|
|
294
325
|
});
|
|
326
|
+
const registerTool = ((name, config, callback) => server.registerTool(name, { ...config, outputSchema: outputSchemas[name] }, callback));
|
|
295
327
|
if (can('mail.read'))
|
|
296
|
-
|
|
328
|
+
registerTool('list_accounts', {
|
|
297
329
|
description: 'List connected email accounts (id, provider, email, status, capabilities).',
|
|
298
330
|
inputSchema: {},
|
|
299
331
|
annotations: { readOnlyHint: true },
|
|
300
332
|
}, gated('list_accounts', 'mail.read', async () => service.listAccounts()));
|
|
301
333
|
if (can('mail.read'))
|
|
302
|
-
|
|
334
|
+
registerTool('get_status', {
|
|
303
335
|
description: 'Account connection and scheduled-send status. Administrators also see plan details. ' +
|
|
304
336
|
'Call this first if other tools fail; it reports accounts that need re-authentication.',
|
|
305
337
|
inputSchema: {},
|
|
306
338
|
annotations: { readOnlyHint: true },
|
|
307
339
|
}, allowed('get_status', 'mail.read', async () => service.status()));
|
|
308
340
|
if (can('mail.read'))
|
|
309
|
-
|
|
341
|
+
registerTool('list_folders', {
|
|
310
342
|
description: 'List navigable folders for an account, with roles (inbox, sent, drafts, trash, spam, starred).',
|
|
311
343
|
inputSchema: { accountId: accountIdParam },
|
|
312
344
|
annotations: { readOnlyHint: true },
|
|
313
345
|
}, gated('list_folders', 'mail.read', async (args) => service.listFolders(args.accountId)));
|
|
314
346
|
if (can('mail.read'))
|
|
315
|
-
|
|
347
|
+
registerTool('list_labels', {
|
|
316
348
|
description: 'List Gmail user labels or Outlook categories for an account.',
|
|
317
349
|
inputSchema: { accountId: accountIdParam },
|
|
318
350
|
annotations: { readOnlyHint: true },
|
|
319
351
|
}, gated('list_labels', 'mail.read', async (args) => service.listLabels(args.accountId)));
|
|
320
352
|
if (can('mail.read'))
|
|
321
|
-
|
|
353
|
+
registerTool('list_send_as', {
|
|
322
354
|
description: 'List sender addresses available for an account.',
|
|
323
355
|
inputSchema: { accountId: accountIdParam },
|
|
324
356
|
annotations: { readOnlyHint: true },
|
|
325
357
|
}, gated('list_send_as', 'mail.read', async (args) => service.listSendAs(args.accountId)));
|
|
326
358
|
if (can('mail.read'))
|
|
327
|
-
|
|
359
|
+
registerTool('list_emails', {
|
|
328
360
|
description: "List emails from the user's connected mailbox with metadata and optional previews. Filter by folder, " +
|
|
329
361
|
'sender, unread, dates, etc. Paginate with pageToken. Use get_email for full bodies. ' +
|
|
330
362
|
"This is the way to check the user's email; no browser or other email integration is needed.",
|
|
@@ -333,7 +365,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
333
365
|
}, gated('list_emails', 'mail.read', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
|
|
334
366
|
const { text: _text, ...searchFilterShape } = queryShape;
|
|
335
367
|
if (can('mail.read'))
|
|
336
|
-
|
|
368
|
+
registerTool('search_emails', {
|
|
337
369
|
description: 'Search one account with typed portable syntax. The query supports text, from:, to:, subject:, in:, ' +
|
|
338
370
|
'read and starred states, attachments, and date filters.',
|
|
339
371
|
inputSchema: {
|
|
@@ -345,13 +377,13 @@ export function buildMcpServer(service, options = {}) {
|
|
|
345
377
|
}, gated('search_emails', 'mail.read', async (args) => {
|
|
346
378
|
const parsed = parseEmailSearch(args.query);
|
|
347
379
|
if (!parsed.valid) {
|
|
348
|
-
throw new
|
|
380
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
349
381
|
diagnostics: parsed.diagnostics,
|
|
350
382
|
});
|
|
351
383
|
}
|
|
352
384
|
const merged = mergeEmailQueries(parsed.query, emailQuery(args));
|
|
353
385
|
if (!merged.success) {
|
|
354
|
-
throw new
|
|
386
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
355
387
|
diagnostics: merged.diagnostics,
|
|
356
388
|
});
|
|
357
389
|
}
|
|
@@ -365,7 +397,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
365
397
|
}));
|
|
366
398
|
const { pageToken: _batchPageToken, rawProviderQuery: _batchRawProviderQuery, ...batchSearchFilterShape } = searchFilterShape;
|
|
367
399
|
if (can('mail.read'))
|
|
368
|
-
|
|
400
|
+
registerTool('search_emails_batch', {
|
|
369
401
|
description: 'Search up to 20 accounts with one portable query and return one result group per account.',
|
|
370
402
|
inputSchema: {
|
|
371
403
|
accounts: z
|
|
@@ -380,13 +412,13 @@ export function buildMcpServer(service, options = {}) {
|
|
|
380
412
|
}, gatedResult('search_emails_batch', 'mail.read', async (args) => {
|
|
381
413
|
const parsed = parseEmailSearch(args.query);
|
|
382
414
|
if (!parsed.valid) {
|
|
383
|
-
throw new
|
|
415
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
384
416
|
diagnostics: parsed.diagnostics,
|
|
385
417
|
});
|
|
386
418
|
}
|
|
387
419
|
const merged = mergeEmailQueries(parsed.query, emailQuery(args));
|
|
388
420
|
if (!merged.success) {
|
|
389
|
-
throw new
|
|
421
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
390
422
|
diagnostics: merged.diagnostics,
|
|
391
423
|
});
|
|
392
424
|
}
|
|
@@ -395,6 +427,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
395
427
|
query: merged.query,
|
|
396
428
|
...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
|
|
397
429
|
...(args.includeSnippet !== undefined ? { includeSnippet: args.includeSnippet } : {}),
|
|
430
|
+
...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
|
|
398
431
|
});
|
|
399
432
|
const response = ok({
|
|
400
433
|
...result,
|
|
@@ -416,22 +449,91 @@ export function buildMcpServer(service, options = {}) {
|
|
|
416
449
|
return response;
|
|
417
450
|
}));
|
|
418
451
|
if (can('mail.read'))
|
|
419
|
-
|
|
452
|
+
registerTool('get_email', {
|
|
420
453
|
description: 'Fetch one email in full: body (text and/or HTML), recipients, attachment metadata.',
|
|
421
|
-
inputSchema: {
|
|
454
|
+
inputSchema: {
|
|
455
|
+
accountId: accountIdParam,
|
|
456
|
+
messageId: idParam,
|
|
457
|
+
bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
|
|
458
|
+
},
|
|
422
459
|
annotations: { readOnlyHint: true },
|
|
423
|
-
}, gated('get_email', 'mail.read', async (args) =>
|
|
460
|
+
}, gated('get_email', 'mail.read', async (args) => selectBody(await service.getMessage(args.accountId, args.messageId), args.bodyFormat ?? 'both', MAX_BODY_CHARS)));
|
|
424
461
|
if (can('mail.read'))
|
|
425
|
-
|
|
426
|
-
description: '
|
|
427
|
-
inputSchema: {
|
|
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
|
+
},
|
|
471
|
+
annotations: { readOnlyHint: true },
|
|
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
|
+
}));
|
|
485
|
+
if (can('mail.read'))
|
|
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
|
+
},
|
|
428
495
|
annotations: { readOnlyHint: true },
|
|
429
496
|
}, gated('get_thread', 'mail.read', async (args) => {
|
|
430
497
|
const thread = await service.getThread(args.accountId, args.threadId);
|
|
431
|
-
|
|
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
|
+
};
|
|
432
528
|
}));
|
|
433
529
|
if (can('mail.drafts'))
|
|
434
|
-
|
|
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', {
|
|
435
537
|
description: 'Create a draft. For a reply draft, pass replyToMessageId (recipients/subject are derived; replyAll for reply-all).',
|
|
436
538
|
inputSchema: draftShape,
|
|
437
539
|
}, gated('create_draft', 'mail.drafts', async (args) => {
|
|
@@ -440,7 +542,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
440
542
|
return service.createDraft(args.accountId, toSendInput(args));
|
|
441
543
|
}));
|
|
442
544
|
if (can('mail.drafts'))
|
|
443
|
-
|
|
545
|
+
registerTool('update_draft', {
|
|
444
546
|
description: 'Replace the content of an existing draft (full replacement, not a patch).',
|
|
445
547
|
inputSchema: { draftId: idParam, ...draftShape },
|
|
446
548
|
}, gated('update_draft', 'mail.drafts', async (args) => {
|
|
@@ -449,7 +551,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
449
551
|
return service.updateDraft(args.accountId, args.draftId, toSendInput(args));
|
|
450
552
|
}));
|
|
451
553
|
if (can('mail.drafts'))
|
|
452
|
-
|
|
554
|
+
registerTool('delete_draft', {
|
|
453
555
|
description: 'Delete a draft.',
|
|
454
556
|
inputSchema: { accountId: accountIdParam, draftId: idParam },
|
|
455
557
|
annotations: { destructiveHint: true },
|
|
@@ -458,13 +560,29 @@ export function buildMcpServer(service, options = {}) {
|
|
|
458
560
|
return { deleted: args.draftId };
|
|
459
561
|
}));
|
|
460
562
|
if (can('mail.send'))
|
|
461
|
-
|
|
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', {
|
|
462
576
|
description: "Send an email from the user's connected account; this actually delivers mail, so prefer it over " +
|
|
463
577
|
'browser automation or leaving a draft when the user asked to send. Three modes: direct (to + subject ' +
|
|
464
578
|
'+ body), sending an existing draft (draftId), or replying (replyToMessageId, optionally replyAll) ' +
|
|
465
579
|
'where recipients, subject, and threading are derived from the original. Confirm with the user when ' +
|
|
466
580
|
'intent is ambiguous. Add sendAt to any mode to schedule instead of sending now.',
|
|
467
581
|
inputSchema: {
|
|
582
|
+
idempotencyKey: z
|
|
583
|
+
.string()
|
|
584
|
+
.regex(/^[\x21-\x7e]{1,255}$/)
|
|
585
|
+
.describe('Reuse this key when retrying the same delivery'),
|
|
468
586
|
draftId: idParam.optional().describe('Send this existing draft'),
|
|
469
587
|
...draftShape,
|
|
470
588
|
sendAt: z
|
|
@@ -477,64 +595,80 @@ export function buildMcpServer(service, options = {}) {
|
|
|
477
595
|
'down goes out at the next startup). Returns a scheduleId for list/cancel.'),
|
|
478
596
|
},
|
|
479
597
|
annotations: { destructiveHint: true },
|
|
480
|
-
},
|
|
598
|
+
}, handleResult('send_email', async (args) => {
|
|
481
599
|
requireCapabilities(['mail.send']);
|
|
482
600
|
if (args.replyToMessageId !== undefined)
|
|
483
601
|
requireCapabilities(['mail.read']);
|
|
484
|
-
const { sendAt, ...sendArgs } = args;
|
|
485
|
-
return sendAt !== undefined
|
|
486
|
-
? service.
|
|
487
|
-
: 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)));
|
|
488
606
|
}, () => service.enforceQuota(), options));
|
|
489
607
|
if (can('mail.read'))
|
|
490
|
-
|
|
608
|
+
registerTool('list_scheduled_emails', {
|
|
491
609
|
description: 'List scheduled sends: pending ones first (with sendAt), then past ones (sent, failed, canceled). ' +
|
|
492
610
|
'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
|
|
493
611
|
'Fluxmail server is running.',
|
|
494
612
|
inputSchema: { accountId: accountIdParam },
|
|
495
613
|
annotations: { readOnlyHint: true },
|
|
496
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)));
|
|
497
621
|
if (can('mail.drafts'))
|
|
498
|
-
|
|
622
|
+
registerTool('cancel_scheduled_email', {
|
|
499
623
|
description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
|
|
500
624
|
'The draft stays in the Drafts folder, so the content is not lost.',
|
|
501
625
|
inputSchema: { scheduleId: idParam },
|
|
502
626
|
}, gated('cancel_scheduled_email', 'mail.drafts', async (args) => service.cancelScheduled(args.scheduleId)));
|
|
503
627
|
if (canAll(['mail.send', 'mail.read']))
|
|
504
|
-
|
|
628
|
+
registerTool('forward_email', {
|
|
505
629
|
description: 'Forward an email to new recipients: quoted original body, "Fwd:" subject, original attachments included ' +
|
|
506
630
|
'unless includeAttachments=false. Optional comment appears above the forwarded content.',
|
|
507
631
|
inputSchema: {
|
|
632
|
+
idempotencyKey: z
|
|
633
|
+
.string()
|
|
634
|
+
.regex(/^[\x21-\x7e]{1,255}$/)
|
|
635
|
+
.describe('Reuse this key when retrying the same forward'),
|
|
508
636
|
accountId: accountIdParam,
|
|
509
637
|
messageId: idParam,
|
|
510
638
|
from: z.string().email().optional().describe('Connected address or an available send-as address'),
|
|
511
639
|
to: addressList.min(1),
|
|
512
640
|
cc: addressList.optional(),
|
|
513
|
-
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.'),
|
|
514
645
|
includeAttachments: z.boolean().optional().describe('Default true'),
|
|
515
646
|
},
|
|
516
647
|
annotations: { destructiveHint: true },
|
|
517
|
-
},
|
|
648
|
+
}, handleResult('forward_email', async (args) => {
|
|
518
649
|
requireCapabilities(['mail.send', 'mail.read']);
|
|
519
650
|
const to = parseAddresses(args.to) ?? [];
|
|
520
651
|
const cc = parseAddresses(args.cc);
|
|
521
|
-
return service.
|
|
652
|
+
return deliveryResult(await service.deliverForward(args.accountId, {
|
|
522
653
|
messageId: args.messageId,
|
|
523
654
|
to,
|
|
524
655
|
...(cc?.length ? { cc } : {}),
|
|
525
656
|
...(args.comment !== undefined ? { comment: args.comment } : {}),
|
|
526
657
|
...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
|
|
527
658
|
...(args.from !== undefined ? { from: args.from } : {}),
|
|
528
|
-
});
|
|
659
|
+
}, args.idempotencyKey));
|
|
529
660
|
}, () => service.enforceQuota(), options));
|
|
530
661
|
const modifyActions = Object.keys(MODIFY_CAPABILITIES).filter((action) => can(MODIFY_CAPABILITIES[action]));
|
|
531
662
|
if (modifyActions.length) {
|
|
532
663
|
const allowedModifyActions = new Set(modifyActions);
|
|
533
|
-
|
|
664
|
+
registerTool('modify_emails', {
|
|
534
665
|
description: 'Batch-modify emails using the actions allowed for this connection. Moving requires folder; labels require labels.',
|
|
535
666
|
inputSchema: {
|
|
536
667
|
accountId: accountIdParam,
|
|
537
|
-
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.'),
|
|
538
672
|
action: z.enum(modifyActions),
|
|
539
673
|
folder: z.string().min(1).optional().describe('Target folder for action=move'),
|
|
540
674
|
labels: z.array(z.string().min(1)).max(100).optional().describe('Labels for addLabels/removeLabels'),
|
|
@@ -542,7 +676,7 @@ export function buildMcpServer(service, options = {}) {
|
|
|
542
676
|
annotations: {
|
|
543
677
|
destructiveHint: canAny(['mail.trash', 'mail.delete']),
|
|
544
678
|
},
|
|
545
|
-
},
|
|
679
|
+
}, handleResult('modify_emails', async (args) => {
|
|
546
680
|
if (!allowedModifyActions.has(args.action)) {
|
|
547
681
|
throw new EmailError('permission_denied', `This MCP connection does not allow action=${args.action}.`);
|
|
548
682
|
}
|
|
@@ -550,63 +684,87 @@ export function buildMcpServer(service, options = {}) {
|
|
|
550
684
|
let action;
|
|
551
685
|
if (args.action === 'move') {
|
|
552
686
|
if (!args.folder)
|
|
553
|
-
throw new
|
|
687
|
+
throw new ClientInputError('invalid_request', 'action=move requires "folder"');
|
|
554
688
|
if (PROTECTED_MOVE_DESTINATIONS.has(args.folder.trim().toLowerCase())) {
|
|
555
|
-
throw new
|
|
689
|
+
throw new ClientInputError('invalid_request', 'Use the dedicated archive or trash action for this folder');
|
|
556
690
|
}
|
|
557
691
|
action = { move: args.folder };
|
|
558
692
|
}
|
|
559
693
|
else if (args.action === 'addLabels' || args.action === 'removeLabels') {
|
|
560
694
|
if (!args.labels?.length) {
|
|
561
|
-
throw new
|
|
695
|
+
throw new ClientInputError('invalid_request', `action=${args.action} requires "labels"`);
|
|
562
696
|
}
|
|
563
697
|
action = args.action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
|
|
564
698
|
}
|
|
565
699
|
else {
|
|
566
700
|
action = args.action;
|
|
567
701
|
}
|
|
568
|
-
await service.modify(args.accountId, args.messageIds, action);
|
|
569
|
-
|
|
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;
|
|
570
709
|
}, () => service.enforceQuota(), options));
|
|
571
710
|
}
|
|
572
711
|
if (can('mail.read'))
|
|
573
|
-
|
|
574
|
-
description: '
|
|
712
|
+
registerTool('download_attachment', {
|
|
713
|
+
description: 'Get attachment metadata and a fetchable resource link. Set inline to embed the bytes.',
|
|
575
714
|
inputSchema: {
|
|
576
|
-
accountId:
|
|
715
|
+
accountId: idParam,
|
|
577
716
|
messageId: idParam,
|
|
578
717
|
attachmentId: attachmentIdParam,
|
|
718
|
+
inline: z.boolean().optional(),
|
|
579
719
|
},
|
|
580
720
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
581
721
|
}, gatedResult('download_attachment', 'mail.read', async (args) => {
|
|
582
722
|
const { meta, content } = await service.getAttachment(args.accountId, args.messageId, args.attachmentId, maxAttachmentBytes);
|
|
583
723
|
if (content.length > maxAttachmentBytes) {
|
|
584
|
-
throw new
|
|
724
|
+
throw new ClientInputError('invalid_request', 'Attachment is too large to return through MCP.', {
|
|
585
725
|
sizeBytes: content.length,
|
|
586
726
|
maxBytes: maxAttachmentBytes,
|
|
587
727
|
});
|
|
588
728
|
}
|
|
589
729
|
const uri = [
|
|
590
730
|
'fluxmail://attachment',
|
|
591
|
-
encodeURIComponent(args.accountId
|
|
731
|
+
encodeURIComponent(args.accountId),
|
|
592
732
|
encodeURIComponent(args.messageId),
|
|
593
733
|
encodeURIComponent(args.attachmentId),
|
|
594
734
|
encodeURIComponent(meta.filename),
|
|
595
735
|
].join('/');
|
|
596
736
|
return {
|
|
737
|
+
structuredContent: { data: meta },
|
|
597
738
|
content: [
|
|
598
|
-
{ type: 'text', text: JSON.stringify(meta, null, 2) },
|
|
599
|
-
{
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
+
: []),
|
|
607
753
|
],
|
|
608
754
|
};
|
|
609
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
|
+
});
|
|
610
768
|
return server;
|
|
611
769
|
}
|
|
612
770
|
//# sourceMappingURL=buildServer.js.map
|