fluxmail 0.10.0 → 0.11.1

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