fluxmail 0.10.0 → 0.11.0

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