fluxmail 0.2.1 → 0.3.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 +9 -5
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +106 -7
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +12 -5
- package/dist/http/app.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/buildServer.d.ts +4 -2
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +285 -229
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/permissions.d.ts +19 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +70 -0
- package/dist/permissions.js.map +1 -0
- package/dist/service/emailService.d.ts +1 -1
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +2 -2
- package/dist/service/emailService.js.map +1 -1
- package/dist/storage/apiKeys.d.ts +6 -1
- package/dist/storage/apiKeys.d.ts.map +1 -1
- package/dist/storage/apiKeys.js +56 -5
- package/dist/storage/apiKeys.js.map +1 -1
- package/dist/storage/db.d.ts +38 -0
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +14 -1
- package/dist/storage/db.js.map +1 -1
- package/dist/telemetry.d.ts +9 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +117 -54
- package/dist/telemetry.js.map +1 -1
- package/package.json +5 -5
package/dist/mcp/buildServer.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { existsSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import { VERSION } from '../version.js';
|
|
4
2
|
import { z } from 'zod';
|
|
5
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
4
|
import { EmailError, isEmailError, parseSingleAddress, } from '@fluxmail/core';
|
|
5
|
+
import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
|
|
6
|
+
import { FULL_PERMISSION_POLICY, hasCapability, normalizePermissionPolicy, } from '../permissions.js';
|
|
7
7
|
const MAX_BODY_CHARS = 50_000;
|
|
8
|
-
const MAX_INLINE_ATTACHMENT_BYTES = 2 * 1024 * 1024;
|
|
9
8
|
const accountIdParam = z
|
|
10
9
|
.string()
|
|
11
10
|
.min(1)
|
|
@@ -127,12 +126,9 @@ function truncateBody(message) {
|
|
|
127
126
|
}
|
|
128
127
|
return { ...message, body };
|
|
129
128
|
}
|
|
130
|
-
function ok(data
|
|
129
|
+
function ok(data) {
|
|
131
130
|
return {
|
|
132
|
-
content: [
|
|
133
|
-
{ type: 'text', text: JSON.stringify(data, null, 2) },
|
|
134
|
-
...(warning ? [{ type: 'text', text: `Note: ${warning}` }] : []),
|
|
135
|
-
],
|
|
131
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
136
132
|
};
|
|
137
133
|
}
|
|
138
134
|
function toolError(err) {
|
|
@@ -159,7 +155,7 @@ function toolFeatureProperties(tool, args) {
|
|
|
159
155
|
case 'modify_emails':
|
|
160
156
|
return typeof input.action === 'string' ? { action: input.action } : {};
|
|
161
157
|
case 'download_attachment':
|
|
162
|
-
return { destination:
|
|
158
|
+
return { destination: 'inline' };
|
|
163
159
|
default:
|
|
164
160
|
return {};
|
|
165
161
|
}
|
|
@@ -172,14 +168,17 @@ function captureToolTelemetry(options, properties) {
|
|
|
172
168
|
// An injected telemetry client must not affect MCP results.
|
|
173
169
|
}
|
|
174
170
|
}
|
|
175
|
-
function
|
|
171
|
+
function handleResult(tool, fn, gate, options = {}) {
|
|
176
172
|
return async (...args) => {
|
|
173
|
+
const finishActivity = options.telemetry?.beginActivity?.();
|
|
177
174
|
const startedAt = performance.now();
|
|
178
175
|
try {
|
|
179
176
|
// The gate throws when a lapsed license leaves the instance over quota,
|
|
180
177
|
// and yields a renewal warning to attach while the license is in grace.
|
|
181
178
|
const warning = gate?.();
|
|
182
|
-
const result =
|
|
179
|
+
const result = await fn(...args);
|
|
180
|
+
if (warning)
|
|
181
|
+
result.content.push({ type: 'text', text: `Note: ${warning}` });
|
|
183
182
|
captureToolTelemetry(options, {
|
|
184
183
|
tool,
|
|
185
184
|
transport: options.transport ?? 'unknown',
|
|
@@ -200,44 +199,20 @@ function handle(tool, fn, gate, options = {}) {
|
|
|
200
199
|
});
|
|
201
200
|
return toolError(err);
|
|
202
201
|
}
|
|
202
|
+
finally {
|
|
203
|
+
finishActivity?.();
|
|
204
|
+
}
|
|
203
205
|
};
|
|
204
206
|
}
|
|
207
|
+
function handle(tool, fn, gate, options = {}) {
|
|
208
|
+
return handleResult(tool, async (...args) => ok(await fn(...args)), gate, options);
|
|
209
|
+
}
|
|
205
210
|
function pageOpts(args) {
|
|
206
211
|
return {
|
|
207
212
|
...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
|
|
208
213
|
...(args.pageToken !== undefined ? { pageToken: args.pageToken } : {}),
|
|
209
214
|
};
|
|
210
215
|
}
|
|
211
|
-
export function resolveAttachmentSavePath(savePath, filename) {
|
|
212
|
-
const isDir = savePath.endsWith(path.sep) || (existsSync(savePath) && statSync(savePath).isDirectory());
|
|
213
|
-
if (!isDir)
|
|
214
|
-
return savePath;
|
|
215
|
-
const safeFilename = path.posix.basename(filename.replace(/\\/g, '/'));
|
|
216
|
-
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
|
|
217
|
-
throw new Error('Attachment filename is not safe to use');
|
|
218
|
-
}
|
|
219
|
-
const directory = path.resolve(savePath);
|
|
220
|
-
const target = path.resolve(directory, safeFilename);
|
|
221
|
-
if (path.dirname(target) !== directory) {
|
|
222
|
-
throw new Error('Attachment filename escapes the destination directory');
|
|
223
|
-
}
|
|
224
|
-
return target;
|
|
225
|
-
}
|
|
226
|
-
export function saveAttachment(savePath, filename, content) {
|
|
227
|
-
if (!path.isAbsolute(savePath))
|
|
228
|
-
throw new EmailError('invalid_request', 'savePath must be absolute');
|
|
229
|
-
const target = resolveAttachmentSavePath(savePath, filename);
|
|
230
|
-
try {
|
|
231
|
-
writeFileSync(target, content, { flag: 'wx' });
|
|
232
|
-
}
|
|
233
|
-
catch (err) {
|
|
234
|
-
if (err.code === 'EEXIST') {
|
|
235
|
-
throw new EmailError('invalid_request', `Refusing to overwrite an existing attachment file: ${target}`);
|
|
236
|
-
}
|
|
237
|
-
throw err;
|
|
238
|
-
}
|
|
239
|
-
return target;
|
|
240
|
-
}
|
|
241
216
|
function emailQuery(args) {
|
|
242
217
|
const keys = [
|
|
243
218
|
'folder',
|
|
@@ -258,205 +233,286 @@ function emailQuery(args) {
|
|
|
258
233
|
q[key] = args[key];
|
|
259
234
|
return q;
|
|
260
235
|
}
|
|
236
|
+
const MODIFY_CAPABILITIES = {
|
|
237
|
+
markRead: 'mail.organize',
|
|
238
|
+
markUnread: 'mail.organize',
|
|
239
|
+
star: 'mail.organize',
|
|
240
|
+
unstar: 'mail.organize',
|
|
241
|
+
archive: 'mail.organize',
|
|
242
|
+
trash: 'mail.trash',
|
|
243
|
+
untrash: 'mail.trash',
|
|
244
|
+
delete: 'mail.delete',
|
|
245
|
+
move: 'mail.organize',
|
|
246
|
+
addLabels: 'mail.organize',
|
|
247
|
+
removeLabels: 'mail.organize',
|
|
248
|
+
};
|
|
249
|
+
const PROTECTED_MOVE_DESTINATIONS = new Set(['archive', 'trash']);
|
|
250
|
+
const SYSTEM_LABELS = new Set(['inbox', 'sent', 'draft', 'drafts', 'trash', 'spam', 'starred', 'unread', 'important']);
|
|
261
251
|
export function buildMcpServer(service, options = {}) {
|
|
252
|
+
const permissions = normalizePermissionPolicy(options.permissions ?? FULL_PERMISSION_POLICY);
|
|
253
|
+
const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
254
|
+
const can = (capability) => hasCapability(permissions, capability);
|
|
255
|
+
const canAll = (capabilities) => capabilities.every(can);
|
|
256
|
+
const canAny = (capabilities) => capabilities.some(can);
|
|
257
|
+
const requireCapabilities = (capabilities) => {
|
|
258
|
+
const missing = capabilities.filter((capability) => !can(capability));
|
|
259
|
+
if (missing.length) {
|
|
260
|
+
throw new EmailError('permission_denied', `This MCP connection does not allow: ${missing.join(', ')}.`);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
262
263
|
// Every tool except get_status (the diagnostic way out) enforces the plan quota.
|
|
263
|
-
const gated = (tool, fn) => handle(tool,
|
|
264
|
+
const gated = (tool, capability, fn) => handle(tool, async (...args) => {
|
|
265
|
+
requireCapabilities([capability]);
|
|
266
|
+
return fn(...args);
|
|
267
|
+
}, () => service.enforceQuota(), options);
|
|
268
|
+
const allowed = (tool, capability, fn) => handle(tool, async (...args) => {
|
|
269
|
+
requireCapabilities([capability]);
|
|
270
|
+
return fn(...args);
|
|
271
|
+
}, undefined, options);
|
|
272
|
+
const gatedResult = (tool, capability, fn) => handleResult(tool, async (...args) => {
|
|
273
|
+
requireCapabilities([capability]);
|
|
274
|
+
return fn(...args);
|
|
275
|
+
}, () => service.enforceQuota(), options);
|
|
264
276
|
const server = new McpServer({ name: 'fluxmail', title: 'Fluxmail Email', version: VERSION }, {
|
|
265
277
|
instructions: "Fluxmail is the user's email integration, already authenticated against their real mailboxes. " +
|
|
266
|
-
'Use
|
|
267
|
-
'
|
|
268
|
-
'user to send mail manually. ' +
|
|
269
|
-
'Tool results are JSON meant for you, not for display. Message ids, thread ids, draft ids, and ' +
|
|
278
|
+
'Use the available Fluxmail tools for email tasks instead of browser automation or another email connector. ' +
|
|
279
|
+
'Tool results are structured for you, not for display. Message ids, thread ids, draft ids, and ' +
|
|
270
280
|
'account ids are internal references: keep them for chaining calls (replying, forwarding, archiving), ' +
|
|
271
281
|
'but do not show them to the user unless asked. Report outcomes in plain language with details people ' +
|
|
272
282
|
"care about, e.g. 'Sent \"Quarterly report\" to ann@example.com' or 'Archived the thread', rather " +
|
|
273
283
|
'than echoing raw payloads, ids, or field names.',
|
|
274
284
|
});
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
285
|
+
if (can('mail.read'))
|
|
286
|
+
server.registerTool('list_accounts', {
|
|
287
|
+
description: 'List connected email accounts (id, provider, email, status, capabilities).',
|
|
288
|
+
inputSchema: {},
|
|
289
|
+
annotations: { readOnlyHint: true },
|
|
290
|
+
}, gated('list_accounts', 'mail.read', async () => service.listAccounts()));
|
|
291
|
+
if (can('mail.read'))
|
|
292
|
+
server.registerTool('get_status', {
|
|
293
|
+
description: 'Account connection and scheduled-send status. Administrators also see plan details. ' +
|
|
294
|
+
'Call this first if other tools fail; it reports accounts that need re-authentication.',
|
|
295
|
+
inputSchema: {},
|
|
296
|
+
annotations: { readOnlyHint: true },
|
|
297
|
+
}, allowed('get_status', 'mail.read', async () => service.status()));
|
|
298
|
+
if (can('mail.read'))
|
|
299
|
+
server.registerTool('list_folders', {
|
|
300
|
+
description: 'List folders/labels for an account, with roles (inbox, sent, drafts, trash, spam, starred).',
|
|
301
|
+
inputSchema: { accountId: accountIdParam },
|
|
302
|
+
annotations: { readOnlyHint: true },
|
|
303
|
+
}, gated('list_folders', 'mail.read', async (args) => service.listFolders(args.accountId)));
|
|
304
|
+
if (can('mail.read'))
|
|
305
|
+
server.registerTool('list_emails', {
|
|
306
|
+
description: "List emails from the user's connected mailbox (metadata + snippet, no bodies). Filter by folder, " +
|
|
307
|
+
'sender, unread, dates, etc. Paginate with pageToken. Use get_email for full bodies. ' +
|
|
308
|
+
"This is the way to check the user's email; no browser or other email integration is needed.",
|
|
309
|
+
inputSchema: { accountId: accountIdParam, ...queryShape },
|
|
310
|
+
annotations: { readOnlyHint: true },
|
|
311
|
+
}, gated('list_emails', 'mail.read', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
|
|
298
312
|
const { text: _text, ...searchFilterShape } = queryShape;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
'
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
},
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
313
|
+
if (can('mail.read'))
|
|
314
|
+
server.registerTool('search_emails', {
|
|
315
|
+
description: 'Full-text search across an account\'s email. Same filters as list_emails; "query" is the search text.',
|
|
316
|
+
inputSchema: {
|
|
317
|
+
accountId: accountIdParam,
|
|
318
|
+
query: z.string().describe('Search text'),
|
|
319
|
+
...searchFilterShape,
|
|
320
|
+
},
|
|
321
|
+
annotations: { readOnlyHint: true },
|
|
322
|
+
}, gated('search_emails', 'mail.read', async (args) => service.listMessages(args.accountId, { ...emailQuery(args), text: args.query }, pageOpts(args))));
|
|
323
|
+
if (can('mail.read'))
|
|
324
|
+
server.registerTool('get_email', {
|
|
325
|
+
description: 'Fetch one email in full: body (text and/or HTML), recipients, attachment metadata.',
|
|
326
|
+
inputSchema: { accountId: accountIdParam, messageId: idParam },
|
|
327
|
+
annotations: { readOnlyHint: true },
|
|
328
|
+
}, gated('get_email', 'mail.read', async (args) => truncateBody(await service.getMessage(args.accountId, args.messageId))));
|
|
329
|
+
if (can('mail.read'))
|
|
330
|
+
server.registerTool('get_thread', {
|
|
331
|
+
description: 'Fetch a full conversation thread with all message bodies.',
|
|
332
|
+
inputSchema: { accountId: accountIdParam, threadId: idParam },
|
|
333
|
+
annotations: { readOnlyHint: true },
|
|
334
|
+
}, gated('get_thread', 'mail.read', async (args) => {
|
|
335
|
+
const thread = await service.getThread(args.accountId, args.threadId);
|
|
336
|
+
return { ...thread, messages: thread.messages.map(truncateBody) };
|
|
337
|
+
}));
|
|
338
|
+
if (can('mail.drafts'))
|
|
339
|
+
server.registerTool('create_draft', {
|
|
340
|
+
description: 'Create a draft. For a reply draft, pass replyToMessageId (recipients/subject are derived; replyAll for reply-all).',
|
|
341
|
+
inputSchema: draftShape,
|
|
342
|
+
}, gated('create_draft', 'mail.drafts', async (args) => {
|
|
343
|
+
if (args.replyToMessageId)
|
|
344
|
+
requireCapabilities(['mail.read']);
|
|
345
|
+
return service.createDraft(args.accountId, toSendInput(args));
|
|
346
|
+
}));
|
|
347
|
+
if (can('mail.drafts'))
|
|
348
|
+
server.registerTool('update_draft', {
|
|
349
|
+
description: 'Replace the content of an existing draft (full replacement, not a patch).',
|
|
350
|
+
inputSchema: { draftId: idParam, ...draftShape },
|
|
351
|
+
}, gated('update_draft', 'mail.drafts', async (args) => {
|
|
352
|
+
if (args.replyToMessageId)
|
|
353
|
+
requireCapabilities(['mail.read']);
|
|
354
|
+
return service.updateDraft(args.accountId, args.draftId, toSendInput(args));
|
|
355
|
+
}));
|
|
356
|
+
if (can('mail.drafts'))
|
|
357
|
+
server.registerTool('delete_draft', {
|
|
358
|
+
description: 'Delete a draft.',
|
|
359
|
+
inputSchema: { accountId: accountIdParam, draftId: idParam },
|
|
360
|
+
annotations: { destructiveHint: true },
|
|
361
|
+
}, gated('delete_draft', 'mail.drafts', async (args) => {
|
|
362
|
+
await service.deleteDraft(args.accountId, args.draftId);
|
|
363
|
+
return { deleted: args.draftId };
|
|
364
|
+
}));
|
|
365
|
+
if (can('mail.send'))
|
|
366
|
+
server.registerTool('send_email', {
|
|
367
|
+
description: "Send an email from the user's connected account; this actually delivers mail, so prefer it over " +
|
|
368
|
+
'browser automation or leaving a draft when the user asked to send. Three modes: direct (to + subject ' +
|
|
369
|
+
'+ body), sending an existing draft (draftId), or replying (replyToMessageId, optionally replyAll) ' +
|
|
370
|
+
'where recipients, subject, and threading are derived from the original. Confirm with the user when ' +
|
|
371
|
+
'intent is ambiguous. Add sendAt to any mode to schedule instead of sending now.',
|
|
372
|
+
inputSchema: {
|
|
373
|
+
draftId: idParam.optional().describe('Send this existing draft'),
|
|
374
|
+
...draftShape,
|
|
375
|
+
sendAt: z
|
|
376
|
+
.string()
|
|
377
|
+
.datetime({ offset: true })
|
|
378
|
+
.optional()
|
|
379
|
+
.describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
|
|
380
|
+
'(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
|
|
381
|
+
'and sends it at this time; the server must be running then (anything missed while it was ' +
|
|
382
|
+
'down goes out at the next startup). Returns a scheduleId for list/cancel.'),
|
|
383
|
+
},
|
|
384
|
+
annotations: { destructiveHint: true },
|
|
385
|
+
}, handle('send_email', async (args) => {
|
|
386
|
+
requireCapabilities(['mail.send']);
|
|
387
|
+
if (args.replyToMessageId !== undefined)
|
|
388
|
+
requireCapabilities(['mail.read']);
|
|
389
|
+
const { sendAt, ...sendArgs } = args;
|
|
390
|
+
return sendAt !== undefined
|
|
391
|
+
? service.scheduleSend(args.accountId, toSendRequest(sendArgs), sendAt)
|
|
392
|
+
: service.send(args.accountId, toSendRequest(sendArgs));
|
|
393
|
+
}, () => service.enforceQuota(), options));
|
|
394
|
+
if (can('mail.read'))
|
|
395
|
+
server.registerTool('list_scheduled_emails', {
|
|
396
|
+
description: 'List scheduled sends: pending ones first (with sendAt), then past ones (sent, failed, canceled). ' +
|
|
397
|
+
'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
|
|
398
|
+
'Fluxmail server is running.',
|
|
399
|
+
inputSchema: { accountId: accountIdParam },
|
|
400
|
+
annotations: { readOnlyHint: true },
|
|
401
|
+
}, gated('list_scheduled_emails', 'mail.read', async (args) => service.listScheduled(args.accountId)));
|
|
402
|
+
if (can('mail.drafts'))
|
|
403
|
+
server.registerTool('cancel_scheduled_email', {
|
|
404
|
+
description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
|
|
405
|
+
'The draft stays in the Drafts folder, so the content is not lost.',
|
|
406
|
+
inputSchema: { scheduleId: idParam },
|
|
407
|
+
}, gated('cancel_scheduled_email', 'mail.drafts', async (args) => service.cancelScheduled(args.scheduleId)));
|
|
408
|
+
if (canAll(['mail.send', 'mail.read']))
|
|
409
|
+
server.registerTool('forward_email', {
|
|
410
|
+
description: 'Forward an email to new recipients: quoted original body, "Fwd:" subject, original attachments included ' +
|
|
411
|
+
'unless includeAttachments=false. Optional comment appears above the forwarded content.',
|
|
412
|
+
inputSchema: {
|
|
413
|
+
accountId: accountIdParam,
|
|
414
|
+
messageId: idParam,
|
|
415
|
+
to: addressList.min(1),
|
|
416
|
+
cc: addressList.optional(),
|
|
417
|
+
comment: z.string().optional(),
|
|
418
|
+
includeAttachments: z.boolean().optional().describe('Default true'),
|
|
419
|
+
},
|
|
420
|
+
annotations: { destructiveHint: true },
|
|
421
|
+
}, handle('forward_email', async (args) => {
|
|
422
|
+
requireCapabilities(['mail.send', 'mail.read']);
|
|
423
|
+
const to = parseAddresses(args.to) ?? [];
|
|
424
|
+
const cc = parseAddresses(args.cc);
|
|
425
|
+
return service.forward(args.accountId, {
|
|
426
|
+
messageId: args.messageId,
|
|
427
|
+
to,
|
|
428
|
+
...(cc?.length ? { cc } : {}),
|
|
429
|
+
...(args.comment !== undefined ? { comment: args.comment } : {}),
|
|
430
|
+
...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
|
|
431
|
+
});
|
|
432
|
+
}, () => service.enforceQuota(), options));
|
|
433
|
+
const modifyActions = Object.keys(MODIFY_CAPABILITIES).filter((action) => can(MODIFY_CAPABILITIES[action]));
|
|
434
|
+
if (modifyActions.length) {
|
|
435
|
+
const allowedModifyActions = new Set(modifyActions);
|
|
436
|
+
server.registerTool('modify_emails', {
|
|
437
|
+
description: 'Batch-modify emails using the actions allowed for this connection. Moving requires folder; labels require labels.',
|
|
438
|
+
inputSchema: {
|
|
439
|
+
accountId: accountIdParam,
|
|
440
|
+
messageIds: z.array(idParam).min(1),
|
|
441
|
+
action: z.enum(modifyActions),
|
|
442
|
+
folder: z.string().min(1).optional().describe('Target folder for action=move'),
|
|
443
|
+
labels: z.array(z.string().min(1)).max(100).optional().describe('Labels for addLabels/removeLabels'),
|
|
444
|
+
},
|
|
445
|
+
annotations: {
|
|
446
|
+
destructiveHint: canAny(['mail.trash', 'mail.delete']),
|
|
447
|
+
},
|
|
448
|
+
}, handle('modify_emails', async (args) => {
|
|
449
|
+
if (!allowedModifyActions.has(args.action)) {
|
|
450
|
+
throw new EmailError('permission_denied', `This MCP connection does not allow action=${args.action}.`);
|
|
430
451
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
452
|
+
requireCapabilities([MODIFY_CAPABILITIES[args.action]]);
|
|
453
|
+
let action;
|
|
454
|
+
if (args.action === 'move') {
|
|
455
|
+
if (!args.folder)
|
|
456
|
+
throw new EmailError('invalid_request', 'action=move requires "folder"');
|
|
457
|
+
if (PROTECTED_MOVE_DESTINATIONS.has(args.folder.trim().toLowerCase())) {
|
|
458
|
+
throw new EmailError('invalid_request', 'Use the dedicated archive or trash action for this folder');
|
|
459
|
+
}
|
|
460
|
+
action = { move: args.folder };
|
|
461
|
+
}
|
|
462
|
+
else if (args.action === 'addLabels' || args.action === 'removeLabels') {
|
|
463
|
+
if (!args.labels?.length) {
|
|
464
|
+
throw new EmailError('invalid_request', `action=${args.action} requires "labels"`);
|
|
465
|
+
}
|
|
466
|
+
if (args.labels.some((label) => SYSTEM_LABELS.has(label.trim().toLowerCase()))) {
|
|
467
|
+
throw new EmailError('invalid_request', 'System labels must be changed with their dedicated message action');
|
|
468
|
+
}
|
|
469
|
+
action = args.action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
action = args.action;
|
|
473
|
+
}
|
|
474
|
+
await service.modify(args.accountId, args.messageIds, action);
|
|
475
|
+
return { modified: args.messageIds.length, action: args.action };
|
|
476
|
+
}, () => service.enforceQuota(), options));
|
|
477
|
+
}
|
|
478
|
+
if (can('mail.read'))
|
|
479
|
+
server.registerTool('download_attachment', {
|
|
480
|
+
description: 'Download an email attachment as an embedded MCP resource.',
|
|
481
|
+
inputSchema: {
|
|
482
|
+
accountId: accountIdParam,
|
|
483
|
+
messageId: idParam,
|
|
484
|
+
attachmentId: idParam,
|
|
485
|
+
},
|
|
486
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
487
|
+
}, gatedResult('download_attachment', 'mail.read', async (args) => {
|
|
488
|
+
const { meta, content } = await service.getAttachment(args.accountId, args.messageId, args.attachmentId, maxAttachmentBytes);
|
|
489
|
+
if (content.length > maxAttachmentBytes) {
|
|
490
|
+
throw new EmailError('invalid_request', 'Attachment is too large to return through MCP.', {
|
|
491
|
+
sizeBytes: content.length,
|
|
492
|
+
maxBytes: maxAttachmentBytes,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const uri = [
|
|
496
|
+
'fluxmail://attachment',
|
|
497
|
+
encodeURIComponent(args.accountId ?? 'default'),
|
|
498
|
+
encodeURIComponent(args.messageId),
|
|
499
|
+
encodeURIComponent(args.attachmentId),
|
|
500
|
+
encodeURIComponent(meta.filename),
|
|
501
|
+
].join('/');
|
|
502
|
+
return {
|
|
503
|
+
content: [
|
|
504
|
+
{ type: 'text', text: JSON.stringify(meta, null, 2) },
|
|
505
|
+
{
|
|
506
|
+
type: 'resource',
|
|
507
|
+
resource: {
|
|
508
|
+
uri,
|
|
509
|
+
mimeType: meta.mimeType || 'application/octet-stream',
|
|
510
|
+
blob: content.toString('base64'),
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
};
|
|
515
|
+
}));
|
|
460
516
|
return server;
|
|
461
517
|
}
|
|
462
518
|
//# sourceMappingURL=buildServer.js.map
|