fluxmail 0.2.1 → 0.4.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.
Files changed (88) hide show
  1. package/README.md +15 -51
  2. package/dist/accounts/gmailConnection.d.ts +5 -1
  3. package/dist/accounts/gmailConnection.d.ts.map +1 -1
  4. package/dist/accounts/gmailConnection.js +14 -2
  5. package/dist/accounts/gmailConnection.js.map +1 -1
  6. package/dist/accounts/googleAuth.js +1 -1
  7. package/dist/accounts/googleAuth.js.map +1 -1
  8. package/dist/accounts/microsoftAuth.d.ts +21 -0
  9. package/dist/accounts/microsoftAuth.d.ts.map +1 -0
  10. package/dist/accounts/microsoftAuth.js +220 -0
  11. package/dist/accounts/microsoftAuth.js.map +1 -0
  12. package/dist/accounts/registry.d.ts +21 -8
  13. package/dist/accounts/registry.d.ts.map +1 -1
  14. package/dist/accounts/registry.js +190 -24
  15. package/dist/accounts/registry.js.map +1 -1
  16. package/dist/cli.d.ts +13 -0
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +881 -572
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config.d.ts +105 -1
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +128 -13
  23. package/dist/config.js.map +1 -1
  24. package/dist/context.d.ts +2 -0
  25. package/dist/context.d.ts.map +1 -1
  26. package/dist/context.js +7 -1
  27. package/dist/context.js.map +1 -1
  28. package/dist/http/admin.d.ts +21 -0
  29. package/dist/http/admin.d.ts.map +1 -0
  30. package/dist/http/admin.js +618 -0
  31. package/dist/http/admin.js.map +1 -0
  32. package/dist/http/app.d.ts +2 -0
  33. package/dist/http/app.d.ts.map +1 -1
  34. package/dist/http/app.js +332 -45
  35. package/dist/http/app.js.map +1 -1
  36. package/dist/http/rest.d.ts +28 -0
  37. package/dist/http/rest.d.ts.map +1 -0
  38. package/dist/http/rest.js +1130 -0
  39. package/dist/http/rest.js.map +1 -0
  40. package/dist/index.d.ts +4 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +4 -2
  43. package/dist/index.js.map +1 -1
  44. package/dist/licensing/refresher.d.ts +23 -7
  45. package/dist/licensing/refresher.d.ts.map +1 -1
  46. package/dist/licensing/refresher.js +93 -37
  47. package/dist/licensing/refresher.js.map +1 -1
  48. package/dist/mcp/buildServer.d.ts +4 -2
  49. package/dist/mcp/buildServer.d.ts.map +1 -1
  50. package/dist/mcp/buildServer.js +286 -230
  51. package/dist/mcp/buildServer.js.map +1 -1
  52. package/dist/permissions.d.ts +30 -0
  53. package/dist/permissions.d.ts.map +1 -0
  54. package/dist/permissions.js +119 -0
  55. package/dist/permissions.js.map +1 -0
  56. package/dist/service/emailService.d.ts +16 -9
  57. package/dist/service/emailService.d.ts.map +1 -1
  58. package/dist/service/emailService.js +44 -19
  59. package/dist/service/emailService.js.map +1 -1
  60. package/dist/storage/adminAudit.d.ts +14 -0
  61. package/dist/storage/adminAudit.d.ts.map +1 -0
  62. package/dist/storage/adminAudit.js +32 -0
  63. package/dist/storage/adminAudit.js.map +1 -0
  64. package/dist/storage/apiKeys.d.ts +28 -5
  65. package/dist/storage/apiKeys.d.ts.map +1 -1
  66. package/dist/storage/apiKeys.js +153 -10
  67. package/dist/storage/apiKeys.js.map +1 -1
  68. package/dist/storage/db.d.ts +524 -0
  69. package/dist/storage/db.d.ts.map +1 -1
  70. package/dist/storage/db.js +184 -8
  71. package/dist/storage/db.js.map +1 -1
  72. package/dist/storage/gmailConnectionGrants.d.ts +9 -0
  73. package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
  74. package/dist/storage/gmailConnectionGrants.js +30 -7
  75. package/dist/storage/gmailConnectionGrants.js.map +1 -1
  76. package/dist/storage/members.d.ts +7 -4
  77. package/dist/storage/members.d.ts.map +1 -1
  78. package/dist/storage/members.js +34 -8
  79. package/dist/storage/members.js.map +1 -1
  80. package/dist/storage/restIdempotency.d.ts +28 -0
  81. package/dist/storage/restIdempotency.d.ts.map +1 -0
  82. package/dist/storage/restIdempotency.js +47 -0
  83. package/dist/storage/restIdempotency.js.map +1 -0
  84. package/dist/telemetry.d.ts +9 -2
  85. package/dist/telemetry.d.ts.map +1 -1
  86. package/dist/telemetry.js +117 -54
  87. package/dist/telemetry.js.map +1 -1
  88. package/package.json +7 -5
@@ -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)
@@ -18,7 +17,7 @@ const queryShape = {
18
17
  .string()
19
18
  .min(1)
20
19
  .optional()
21
- .describe('Folder role (inbox, sent, drafts, trash, spam, starred, archive, all) or a label/folder name'),
20
+ .describe("Folder role (inbox, sent, drafts, trash, spam, starred, archive, all) or a label/folder name. Use all or omit this field to search all mail except Spam and Trash. An IMAP server's \\All mailbox may use different rules."),
22
21
  text: z.string().optional().describe('Full-text search terms'),
23
22
  from: z.string().optional(),
24
23
  to: z.string().optional(),
@@ -127,12 +126,9 @@ function truncateBody(message) {
127
126
  }
128
127
  return { ...message, body };
129
128
  }
130
- function ok(data, warning) {
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: input.savePath !== undefined ? 'disk' : 'inline' };
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 handle(tool, fn, gate, options = {}) {
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 = ok(await fn(...args), warning);
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, fn, () => service.enforceQuota(), options);
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 these tools for any email task: reading, searching, drafting, sending, replying, forwarding, ' +
267
- 'or organizing mail. Prefer them over browser automation, other email connectors, or asking the ' +
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
- server.registerTool('list_accounts', {
276
- description: 'List connected email accounts (id, provider, email, status, capabilities).',
277
- inputSchema: {},
278
- annotations: { readOnlyHint: true },
279
- }, gated('list_accounts', async () => service.listAccounts()));
280
- server.registerTool('get_status', {
281
- description: 'Account connection and scheduled-send status. Administrators also see plan details. ' +
282
- 'Call this first if other tools fail; it reports accounts that need re-authentication.',
283
- inputSchema: {},
284
- annotations: { readOnlyHint: true },
285
- }, handle('get_status', async () => service.status(), undefined, options));
286
- server.registerTool('list_folders', {
287
- description: 'List folders/labels for an account, with roles (inbox, sent, drafts, trash, spam, starred).',
288
- inputSchema: { accountId: accountIdParam },
289
- annotations: { readOnlyHint: true },
290
- }, gated('list_folders', async (args) => service.listFolders(args.accountId)));
291
- server.registerTool('list_emails', {
292
- description: "List emails from the user's connected mailbox (metadata + snippet, no bodies). Filter by folder, " +
293
- 'sender, unread, dates, etc. Paginate with pageToken. Use get_email for full bodies. ' +
294
- "This is the way to check the user's email; no browser or other email integration is needed.",
295
- inputSchema: { accountId: accountIdParam, ...queryShape },
296
- annotations: { readOnlyHint: true },
297
- }, gated('list_emails', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
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
- server.registerTool('search_emails', {
300
- description: 'Full-text search across an account\'s email. Same filters as list_emails; "query" is the search text.',
301
- inputSchema: {
302
- accountId: accountIdParam,
303
- query: z.string().describe('Search text'),
304
- ...searchFilterShape,
305
- },
306
- annotations: { readOnlyHint: true },
307
- }, gated('search_emails', async (args) => service.listMessages(args.accountId, { ...emailQuery(args), text: args.query }, pageOpts(args))));
308
- server.registerTool('get_email', {
309
- description: 'Fetch one email in full: body (text and/or HTML), recipients, attachment metadata.',
310
- inputSchema: { accountId: accountIdParam, messageId: idParam },
311
- annotations: { readOnlyHint: true },
312
- }, gated('get_email', async (args) => truncateBody(await service.getMessage(args.accountId, args.messageId))));
313
- server.registerTool('get_thread', {
314
- description: 'Fetch a full conversation thread with all message bodies.',
315
- inputSchema: { accountId: accountIdParam, threadId: idParam },
316
- annotations: { readOnlyHint: true },
317
- }, gated('get_thread', async (args) => {
318
- const thread = await service.getThread(args.accountId, args.threadId);
319
- return { ...thread, messages: thread.messages.map(truncateBody) };
320
- }));
321
- server.registerTool('create_draft', {
322
- description: 'Create a draft. For a reply draft, pass replyToMessageId (recipients/subject are derived; replyAll for reply-all).',
323
- inputSchema: draftShape,
324
- }, gated('create_draft', async (args) => service.createDraft(args.accountId, toSendInput(args))));
325
- server.registerTool('update_draft', {
326
- description: 'Replace the content of an existing draft (full replacement, not a patch).',
327
- inputSchema: { draftId: idParam, ...draftShape },
328
- }, gated('update_draft', async (args) => service.updateDraft(args.accountId, args.draftId, toSendInput(args))));
329
- server.registerTool('delete_draft', {
330
- description: 'Delete a draft.',
331
- inputSchema: { accountId: accountIdParam, draftId: idParam },
332
- annotations: { destructiveHint: true },
333
- }, gated('delete_draft', async (args) => {
334
- await service.deleteDraft(args.accountId, args.draftId);
335
- return { deleted: args.draftId };
336
- }));
337
- server.registerTool('send_email', {
338
- description: "Send an email from the user's connected account; this actually delivers mail, so prefer it over " +
339
- 'browser automation or leaving a draft when the user asked to send. Three modes: direct (to + subject ' +
340
- '+ body), sending an existing draft (draftId), or replying (replyToMessageId, optionally replyAll) ' +
341
- 'where recipients, subject, and threading are derived from the original. Confirm with the user when ' +
342
- 'intent is ambiguous. Add sendAt to any mode to schedule instead of sending now.',
343
- inputSchema: {
344
- draftId: idParam.optional().describe('Send this existing draft'),
345
- ...draftShape,
346
- sendAt: z
347
- .string()
348
- .datetime({ offset: true })
349
- .optional()
350
- .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
351
- '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
352
- 'and sends it at this time; the server must be running then (anything missed while it was ' +
353
- 'down goes out at the next startup). Returns a scheduleId for list/cancel.'),
354
- },
355
- annotations: { destructiveHint: true },
356
- }, gated('send_email', async (args) => {
357
- const { sendAt, ...sendArgs } = args;
358
- return sendAt !== undefined
359
- ? service.scheduleSend(args.accountId, toSendRequest(sendArgs), sendAt)
360
- : service.send(args.accountId, toSendRequest(sendArgs));
361
- }));
362
- server.registerTool('list_scheduled_emails', {
363
- description: 'List scheduled sends: pending ones first (with sendAt), then past ones (sent, failed, canceled). ' +
364
- 'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
365
- 'Fluxmail server is running.',
366
- inputSchema: { accountId: accountIdParam },
367
- annotations: { readOnlyHint: true },
368
- }, gated('list_scheduled_emails', async (args) => service.listScheduled(args.accountId)));
369
- server.registerTool('cancel_scheduled_email', {
370
- description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
371
- 'The draft stays in the Drafts folder, so the content is not lost.',
372
- inputSchema: { scheduleId: idParam },
373
- }, gated('cancel_scheduled_email', async (args) => service.cancelScheduled(args.scheduleId)));
374
- server.registerTool('forward_email', {
375
- description: 'Forward an email to new recipients: quoted original body, "Fwd:" subject, original attachments included ' +
376
- 'unless includeAttachments=false. Optional comment appears above the forwarded content.',
377
- inputSchema: {
378
- accountId: accountIdParam,
379
- messageId: idParam,
380
- to: addressList.min(1),
381
- cc: addressList.optional(),
382
- comment: z.string().optional(),
383
- includeAttachments: z.boolean().optional().describe('Default true'),
384
- },
385
- annotations: { destructiveHint: true },
386
- }, gated('forward_email', async (args) => {
387
- const to = parseAddresses(args.to) ?? [];
388
- const cc = parseAddresses(args.cc);
389
- return service.forward(args.accountId, {
390
- messageId: args.messageId,
391
- to,
392
- ...(cc?.length ? { cc } : {}),
393
- ...(args.comment !== undefined ? { comment: args.comment } : {}),
394
- ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
395
- });
396
- }));
397
- server.registerTool('modify_emails', {
398
- description: 'Batch-modify emails: markRead, markUnread, star, unstar, archive, trash, untrash, delete (permanent!), ' +
399
- 'move (requires folder), addLabels/removeLabels (requires labels; Gmail only).',
400
- inputSchema: {
401
- accountId: accountIdParam,
402
- messageIds: z.array(idParam).min(1),
403
- action: z.enum([
404
- 'markRead',
405
- 'markUnread',
406
- 'star',
407
- 'unstar',
408
- 'archive',
409
- 'trash',
410
- 'untrash',
411
- 'delete',
412
- 'move',
413
- 'addLabels',
414
- 'removeLabels',
415
- ]),
416
- folder: z.string().min(1).optional().describe('Target folder for action=move'),
417
- labels: z.array(z.string().min(1)).max(100).optional().describe('Labels for addLabels/removeLabels'),
418
- },
419
- annotations: { destructiveHint: true },
420
- }, gated('modify_emails', async (args) => {
421
- let action;
422
- if (args.action === 'move') {
423
- if (!args.folder)
424
- throw new EmailError('invalid_request', 'action=move requires "folder"');
425
- action = { move: args.folder };
426
- }
427
- else if (args.action === 'addLabels' || args.action === 'removeLabels') {
428
- if (!args.labels?.length) {
429
- throw new EmailError('invalid_request', `action=${args.action} requires "labels"`);
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
- action = args.action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
432
- }
433
- else {
434
- action = args.action;
435
- }
436
- await service.modify(args.accountId, args.messageIds, action);
437
- return { modified: args.messageIds.length, action: args.action };
438
- }));
439
- server.registerTool('download_attachment', {
440
- description: 'Download an email attachment. With savePath, writes the file to disk and returns the path; ' +
441
- 'otherwise returns base64 content (up to 2 MB).',
442
- inputSchema: {
443
- accountId: accountIdParam,
444
- messageId: idParam,
445
- attachmentId: idParam,
446
- savePath: z.string().min(1).optional().describe('Absolute file path or directory to write the attachment to'),
447
- },
448
- annotations: { readOnlyHint: false, destructiveHint: true },
449
- }, gated('download_attachment', async (args) => {
450
- const { meta, content } = await service.getAttachment(args.accountId, args.messageId, args.attachmentId);
451
- if (args.savePath) {
452
- const target = saveAttachment(args.savePath, meta.filename, content);
453
- return { saved: target, ...meta };
454
- }
455
- if (content.length > MAX_INLINE_ATTACHMENT_BYTES) {
456
- throw new EmailError('invalid_request', `Attachment is ${content.length} bytes (limit ${MAX_INLINE_ATTACHMENT_BYTES} inline). Pass savePath to write it to disk.`);
457
- }
458
- return { ...meta, contentBase64: content.toString('base64') };
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