fluxmail 0.9.0 → 0.11.0

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