fluxmail 0.17.0 → 0.18.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 (56) hide show
  1. package/dist/accounts/defaultMicrosoftOAuth.d.ts +8 -0
  2. package/dist/accounts/defaultMicrosoftOAuth.d.ts.map +1 -0
  3. package/dist/accounts/defaultMicrosoftOAuth.js +8 -0
  4. package/dist/accounts/defaultMicrosoftOAuth.js.map +1 -0
  5. package/dist/accounts/gmailConnection.d.ts.map +1 -1
  6. package/dist/accounts/gmailConnection.js +1 -5
  7. package/dist/accounts/gmailConnection.js.map +1 -1
  8. package/dist/accounts/microsoftAuth.d.ts.map +1 -1
  9. package/dist/accounts/microsoftAuth.js +10 -3
  10. package/dist/accounts/microsoftAuth.js.map +1 -1
  11. package/dist/accounts/registry.d.ts.map +1 -1
  12. package/dist/accounts/registry.js +5 -2
  13. package/dist/accounts/registry.js.map +1 -1
  14. package/dist/accounts/telemetry.d.ts +0 -4
  15. package/dist/accounts/telemetry.d.ts.map +1 -1
  16. package/dist/accounts/telemetry.js +3 -5
  17. package/dist/accounts/telemetry.js.map +1 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +1 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cliMail.d.ts.map +1 -1
  22. package/dist/cliMail.js +4 -2
  23. package/dist/cliMail.js.map +1 -1
  24. package/dist/config.d.ts +10 -4
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +26 -15
  27. package/dist/config.js.map +1 -1
  28. package/dist/http/admin.js +3 -3
  29. package/dist/http/admin.js.map +1 -1
  30. package/dist/http/rest.d.ts.map +1 -1
  31. package/dist/http/rest.js +15 -16
  32. package/dist/http/rest.js.map +1 -1
  33. package/dist/mcp/buildServer.d.ts +5 -13
  34. package/dist/mcp/buildServer.d.ts.map +1 -1
  35. package/dist/mcp/buildServer.js +324 -486
  36. package/dist/mcp/buildServer.js.map +1 -1
  37. package/dist/mcp/outputSchemas.d.ts +6 -0
  38. package/dist/mcp/outputSchemas.d.ts.map +1 -1
  39. package/dist/mcp/outputSchemas.js +1 -1
  40. package/dist/mcp/outputSchemas.js.map +1 -1
  41. package/dist/permissions.d.ts +2 -1
  42. package/dist/permissions.d.ts.map +1 -1
  43. package/dist/permissions.js +4 -15
  44. package/dist/permissions.js.map +1 -1
  45. package/dist/service/emailService.d.ts +2 -2
  46. package/dist/service/emailService.d.ts.map +1 -1
  47. package/dist/service/emailService.js +2 -2
  48. package/dist/service/emailService.js.map +1 -1
  49. package/dist/service/publicErrors.d.ts.map +1 -1
  50. package/dist/service/publicErrors.js +10 -2
  51. package/dist/service/publicErrors.js.map +1 -1
  52. package/dist/storage/imapState.d.ts +3 -0
  53. package/dist/storage/imapState.d.ts.map +1 -1
  54. package/dist/storage/imapState.js +21 -2
  55. package/dist/storage/imapState.js.map +1 -1
  56. package/package.json +5 -5
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { zodToJsonSchema } from 'zod-to-json-schema';
5
5
  import { CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, McpServer, ResourceTemplate, } from '@modelcontextprotocol/server';
6
6
  import { isEmailError, mergeEmailQueries, parseEmailSearch, parseSingleAddress, } from '@fluxmail/core';
7
+ import { sharedTool } from '@fluxmail/core/mcp';
7
8
  import { batchFailureCause } from '../service/emailService.js';
8
9
  import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
9
10
  import { DEFAULT_PERMISSION_POLICY, MCP_CAPABILITIES, MCP_CAPABILITY_DESCRIPTIONS, enableCapabilitiesInstructions, hasCapability, normalizePermissionPolicy, } from '../permissions.js';
@@ -27,7 +28,8 @@ const EMPTY_INPUT_SCHEMA = {
27
28
  $schema: 'http://json-schema.org/draft-07/schema#',
28
29
  };
29
30
  function toolJsonSchema(shape, pipeStrategy) {
30
- const schema = zodToJsonSchema(z.object(shape), { strictUnions: true, pipeStrategy });
31
+ // Inline every schema: sibling $ref pointers break when a product reshapes a property.
32
+ const schema = zodToJsonSchema(z.object(shape), { strictUnions: true, pipeStrategy, $refStrategy: 'none' });
31
33
  // MCP clients use JSON Schema 2020-12 when no dialect is declared.
32
34
  if (pipeStrategy === 'output')
33
35
  delete schema.$schema;
@@ -121,6 +123,12 @@ const draftShape = {
121
123
  filename: z.string().min(1),
122
124
  mimeType: z.string().min(1),
123
125
  content: z.string().describe('base64'),
126
+ contentId: z
127
+ .string()
128
+ .min(1)
129
+ .optional()
130
+ .describe('Content-ID for an inline image, referenced from bodyHtml as cid:<contentId>'),
131
+ disposition: z.enum(['inline', 'attachment']).optional().describe('Defaults to attachment'),
124
132
  }))
125
133
  .optional(),
126
134
  };
@@ -347,89 +355,40 @@ function emailQuery(args) {
347
355
  }
348
356
  /** One MCP tool per modify action, all served by the same implementation. */
349
357
  export const MODIFY_TOOLS = {
350
- markRead: {
351
- name: 'mark_emails_read',
352
- title: 'Mark emails read',
353
- description: 'Mark messages as read.',
354
- capability: 'mail.organize',
355
- destructive: false,
356
- },
357
- markUnread: {
358
- name: 'mark_emails_unread',
359
- title: 'Mark emails unread',
360
- description: 'Mark messages as unread.',
361
- capability: 'mail.organize',
362
- destructive: false,
363
- },
364
- star: {
365
- name: 'star_emails',
366
- title: 'Star emails',
367
- description: 'Star messages.',
368
- capability: 'mail.organize',
369
- destructive: false,
370
- },
371
- unstar: {
372
- name: 'unstar_emails',
373
- title: 'Unstar emails',
374
- description: 'Remove the star from messages.',
375
- capability: 'mail.organize',
376
- destructive: false,
377
- },
378
- archive: {
379
- name: 'archive_emails',
380
- title: 'Archive emails',
381
- description: 'Move messages out of the inbox into the archive.',
382
- capability: 'mail.organize',
383
- destructive: false,
384
- },
385
- // Spam and Junk folders delete their mail after a while, so a move can lose it.
386
- move: {
387
- name: 'move_emails',
388
- title: 'Move emails',
389
- description: 'Move messages to a folder other than the archive or trash.',
390
- capability: 'mail.organize',
391
- destructive: true,
392
- input: 'folder',
393
- },
394
- addLabels: {
395
- name: 'add_labels',
396
- title: 'Add labels',
397
- description: 'Add labels, or Outlook categories, to messages.',
398
- capability: 'mail.organize',
399
- destructive: false,
400
- input: 'labels',
401
- },
402
- removeLabels: {
403
- name: 'remove_labels',
404
- title: 'Remove labels',
405
- description: 'Remove labels, or Outlook categories, from messages.',
406
- capability: 'mail.organize',
407
- destructive: false,
408
- input: 'labels',
409
- },
410
- // Providers empty the trash automatically, so trashed mail is eventually gone.
411
- trash: {
412
- name: 'trash_emails',
413
- title: 'Move emails to trash',
414
- description: 'Move messages to the trash, where they can be restored until the provider empties it.',
415
- capability: 'mail.trash',
416
- destructive: true,
417
- },
418
- untrash: {
419
- name: 'restore_emails',
420
- title: 'Restore emails from trash',
421
- description: 'Move messages out of the trash.',
422
- capability: 'mail.trash',
423
- destructive: false,
424
- },
425
- delete: {
426
- name: 'delete_emails',
427
- title: 'Delete emails permanently',
428
- description: "Permanently delete messages. This cannot be undone. Gmail accounts connected through Fluxmail's built-in " +
429
- 'Google app cannot delete permanently; move their messages to the trash instead.',
430
- capability: 'mail.delete',
431
- destructive: true,
432
- },
358
+ markRead: { name: 'mark_emails_read' },
359
+ markUnread: { name: 'mark_emails_unread' },
360
+ star: { name: 'star_emails' },
361
+ unstar: { name: 'unstar_emails' },
362
+ archive: { name: 'archive_emails' },
363
+ move: { name: 'move_emails', input: 'folder' },
364
+ addLabels: { name: 'add_labels', input: 'labels' },
365
+ removeLabels: { name: 'remove_labels', input: 'labels' },
366
+ trash: { name: 'trash_emails' },
367
+ untrash: { name: 'restore_emails' },
368
+ delete: { name: 'delete_emails' },
369
+ };
370
+ /** Scopes for the tools only this product offers. Shared tools take theirs from the contract. */
371
+ const PRODUCT_TOOL_SCOPES = {
372
+ get_status: 'mail.read',
373
+ list_scheduled_emails: 'mail.read',
374
+ cancel_scheduled_email: 'mail.drafts',
375
+ };
376
+ function toolScopes(name) {
377
+ const product = PRODUCT_TOOL_SCOPES[name];
378
+ if (product)
379
+ return [product];
380
+ const tool = sharedTool(name);
381
+ return tool.alsoRequires ? [tool.scope, tool.alsoRequires] : [tool.scope];
382
+ }
383
+ /** Sentences this product appends to the contract's base descriptions. */
384
+ const PRODUCT_DESCRIPTIONS = {
385
+ list_accounts: 'Each entry includes the account ID, provider, email address, status and capabilities.',
386
+ list_emails: 'Use get_email for full bodies.',
387
+ get_email: 'Use get_email_body to read the rest.',
388
+ send_email: 'Confirm with the user when the intent is ambiguous. Add sendAt to any mode to schedule the message instead ' +
389
+ 'of sending it now.',
390
+ delete_emails: "Gmail accounts connected through Fluxmail's built-in Google app cannot delete permanently; move their " +
391
+ 'messages to the trash instead.',
433
392
  };
434
393
  const MODIFY_TOOL_ENTRIES = Object.entries(MODIFY_TOOLS);
435
394
  const MODIFY_ACTION_BY_TOOL = new Map(MODIFY_TOOL_ENTRIES.map(([action, tool]) => [tool.name, action]));
@@ -484,17 +443,18 @@ export function buildMcpServer(service, options = {}) {
484
443
  return mcpClientProperties(envelope?.[CLIENT_INFO_META_KEY] ?? server.server.getClientVersion(), envelope?.[PROTOCOL_VERSION_META_KEY] ?? (context ? protocolVersionOf(context) : undefined));
485
444
  },
486
445
  };
446
+ const requireToolScopes = (tool) => requireCapabilities(toolScopes(tool));
487
447
  // Every tool except get_status (the diagnostic way out) enforces the plan quota.
488
- const gated = (tool, capability, fn) => handle(tool, async (...args) => {
489
- requireCapabilities([capability]);
448
+ const gated = (tool, fn) => handle(tool, async (...args) => {
449
+ requireToolScopes(tool);
490
450
  return fn(...args);
491
451
  }, () => service.enforceQuota(), toolOptions);
492
- const allowed = (tool, capability, fn) => handle(tool, async (...args) => {
493
- requireCapabilities([capability]);
452
+ const allowed = (tool, fn) => handle(tool, async (...args) => {
453
+ requireToolScopes(tool);
494
454
  return fn(...args);
495
455
  }, undefined, toolOptions);
496
- const gatedResult = (tool, capability, fn) => handleResult(tool, async (...args) => {
497
- requireCapabilities([capability]);
456
+ const gatedResult = (tool, fn) => handleResult(tool, async (...args) => {
457
+ requireToolScopes(tool);
498
458
  return fn(...args);
499
459
  }, () => service.enforceQuota(), toolOptions);
500
460
  const server = new McpServer({ name: 'fluxmail', title: 'Fluxmail Email', version: VERSION }, {
@@ -537,307 +497,222 @@ export function buildMcpServer(service, options = {}) {
537
497
  });
538
498
  return registered;
539
499
  };
540
- if (can('mail.read'))
541
- registerTool('list_accounts', {
542
- title: 'List accounts',
543
- description: 'List connected email accounts (id, provider, email, status, capabilities).',
544
- inputSchema: {},
545
- annotations: { readOnlyHint: true },
546
- }, gated('list_accounts', 'mail.read', async () => service.listAccounts()));
547
- if (can('mail.read'))
500
+ /** Registers a shared tool with its contract metadata when the connection has the tool's scopes. */
501
+ const registerSharedTool = (name, inputSchema, callback, productDescription = PRODUCT_DESCRIPTIONS[name]) => {
502
+ if (!canAll(toolScopes(name)))
503
+ return;
504
+ const tool = sharedTool(name);
505
+ registerTool(name, {
506
+ title: tool.title,
507
+ description: productDescription ? `${tool.description} ${productDescription}` : tool.description,
508
+ inputSchema,
509
+ annotations: { ...tool.annotations },
510
+ }, callback);
511
+ };
512
+ registerSharedTool('list_accounts', {}, gated('list_accounts', async () => service.listAccounts()));
513
+ if (canAll(toolScopes('get_status')))
548
514
  registerTool('get_status', {
549
515
  title: 'Get status',
550
516
  description: 'Account connection and scheduled-send status, including accounts that need re-authentication. ' +
551
517
  'Administrators also see plan details.',
552
518
  inputSchema: {},
553
519
  annotations: { readOnlyHint: true },
554
- }, allowed('get_status', 'mail.read', async () => service.status()));
555
- if (can('mail.read'))
556
- registerTool('list_folders', {
557
- title: 'List folders',
558
- description: 'List navigable folders for an account, with roles (inbox, sent, drafts, trash, spam, starred).',
559
- inputSchema: { accountId: accountIdParam },
560
- annotations: { readOnlyHint: true },
561
- }, gated('list_folders', 'mail.read', async (args) => service.listFolders(args.accountId)));
562
- if (can('mail.read'))
563
- registerTool('list_labels', {
564
- title: 'List labels',
565
- description: 'List Gmail user labels or Outlook categories for an account.',
566
- inputSchema: { accountId: accountIdParam },
567
- annotations: { readOnlyHint: true },
568
- }, gated('list_labels', 'mail.read', async (args) => service.listLabels(args.accountId)));
569
- if (can('mail.read'))
570
- registerTool('list_send_as', {
571
- title: 'List sender addresses',
572
- description: 'List sender addresses available for an account.',
573
- inputSchema: { accountId: accountIdParam },
574
- annotations: { readOnlyHint: true },
575
- }, gated('list_send_as', 'mail.read', async (args) => service.listSendAs(args.accountId)));
576
- if (can('mail.read'))
577
- registerTool('list_emails', {
578
- title: 'List emails',
579
- description: "List emails from the user's connected mailbox with metadata and optional previews. Filter by folder, " +
580
- 'sender, unread, dates, etc. Paginate with pageToken. Use get_email for full bodies.',
581
- inputSchema: { accountId: accountIdParam, ...queryShape },
582
- annotations: { readOnlyHint: true },
583
- }, gated('list_emails', 'mail.read', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
520
+ }, allowed('get_status', async () => service.status()));
521
+ registerSharedTool('list_folders', { accountId: accountIdParam }, gated('list_folders', async (args) => service.listFolders(args.accountId)));
522
+ registerSharedTool('list_labels', { accountId: accountIdParam }, gated('list_labels', async (args) => service.listLabels(args.accountId)));
523
+ registerSharedTool('list_send_as', { accountId: accountIdParam }, gated('list_send_as', async (args) => service.listSendAs(args.accountId)));
524
+ registerSharedTool('list_emails', { accountId: accountIdParam, ...queryShape }, gated('list_emails', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
584
525
  const { text: _text, ...searchFilterShape } = queryShape;
585
- if (can('mail.read'))
586
- registerTool('search_emails', {
587
- title: 'Search emails',
588
- description: 'Search one account with typed portable syntax. The query supports text, from:, to:, subject:, in:, ' +
589
- 'read and starred states, attachments, and date filters.',
590
- inputSchema: {
591
- accountId: accountIdParam,
592
- query: z.string().min(1).describe('Typed portable search syntax'),
593
- ...searchFilterShape,
594
- },
595
- annotations: { readOnlyHint: true },
596
- }, gated('search_emails', 'mail.read', async (args) => {
597
- const parsed = parseEmailSearch(args.query);
598
- if (!parsed.valid) {
599
- throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
600
- diagnostics: parsed.diagnostics,
601
- });
602
- }
603
- const merged = mergeEmailQueries(parsed.query, emailQuery(args));
604
- if (!merged.success) {
605
- throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
606
- diagnostics: merged.diagnostics,
607
- });
608
- }
609
- const result = await service.listMessages(args.accountId, merged.query, pageOpts(args));
610
- return {
611
- ...result,
612
- ...([...parsed.diagnostics, ...(result.diagnostics ?? [])].length
613
- ? { diagnostics: [...parsed.diagnostics, ...(result.diagnostics ?? [])] }
614
- : {}),
615
- };
616
- }));
526
+ registerSharedTool('search_emails', {
527
+ accountId: accountIdParam,
528
+ query: z.string().min(1).describe('Typed portable search syntax'),
529
+ ...searchFilterShape,
530
+ }, gated('search_emails', async (args) => {
531
+ const parsed = parseEmailSearch(args.query);
532
+ if (!parsed.valid) {
533
+ throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
534
+ diagnostics: parsed.diagnostics,
535
+ });
536
+ }
537
+ const merged = mergeEmailQueries(parsed.query, emailQuery(args));
538
+ if (!merged.success) {
539
+ throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
540
+ diagnostics: merged.diagnostics,
541
+ });
542
+ }
543
+ const result = await service.listMessages(args.accountId, merged.query, pageOpts(args));
544
+ return {
545
+ ...result,
546
+ ...([...parsed.diagnostics, ...(result.diagnostics ?? [])].length
547
+ ? { diagnostics: [...parsed.diagnostics, ...(result.diagnostics ?? [])] }
548
+ : {}),
549
+ };
550
+ }));
617
551
  const { pageToken: _batchPageToken, rawProviderQuery: _batchRawProviderQuery, ...batchSearchFilterShape } = searchFilterShape;
618
- if (can('mail.read'))
619
- registerTool('search_emails_batch', {
620
- title: 'Search multiple accounts',
621
- description: 'Search up to 20 accounts with one portable query and return one result group per account.',
622
- inputSchema: {
623
- accounts: z
624
- .array(z
625
- .object(nullableOptionalInputs({ accountId: z.string().min(1), pageToken: z.string().min(1).optional() }))
626
- .strict())
627
- .min(1)
628
- .max(20),
629
- query: z.string().min(1).describe('Typed portable search syntax'),
630
- ...batchSearchFilterShape,
631
- folder: z.enum(['inbox', 'sent', 'drafts', 'archive', 'spam', 'trash', 'all']).optional(),
632
- },
633
- annotations: { readOnlyHint: true },
634
- }, gatedResult('search_emails_batch', 'mail.read', async (args) => {
635
- const parsed = parseEmailSearch(args.query);
636
- if (!parsed.valid) {
637
- throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
638
- diagnostics: parsed.diagnostics,
639
- });
640
- }
641
- const merged = mergeEmailQueries(parsed.query, emailQuery(args));
642
- if (!merged.success) {
643
- throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
644
- diagnostics: merged.diagnostics,
645
- });
646
- }
647
- const result = await service.searchMessagesBatch({
648
- accounts: args.accounts,
649
- query: merged.query,
650
- ...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
651
- ...(args.includeSnippet !== undefined ? { includeSnippet: args.includeSnippet } : {}),
652
- ...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
552
+ registerSharedTool('search_emails_batch', {
553
+ accounts: z
554
+ .array(z
555
+ .object(nullableOptionalInputs({ accountId: z.string().min(1), pageToken: z.string().min(1).optional() }))
556
+ .strict())
557
+ .min(1)
558
+ .max(20),
559
+ query: z.string().min(1).describe('Typed portable search syntax'),
560
+ ...batchSearchFilterShape,
561
+ folder: z.enum(['inbox', 'sent', 'drafts', 'archive', 'spam', 'trash', 'all']).optional(),
562
+ }, gatedResult('search_emails_batch', async (args) => {
563
+ const parsed = parseEmailSearch(args.query);
564
+ if (!parsed.valid) {
565
+ throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
566
+ diagnostics: parsed.diagnostics,
653
567
  });
654
- const response = ok({
655
- ...result,
656
- groups: result.groups.map((group) => {
657
- if (!group.page)
658
- return group;
659
- const diagnostics = [...parsed.diagnostics, ...(group.page.diagnostics ?? [])];
660
- return {
661
- ...group,
662
- page: { ...group.page, ...(diagnostics.length ? { diagnostics } : {}) },
663
- };
664
- }),
568
+ }
569
+ const merged = mergeEmailQueries(parsed.query, emailQuery(args));
570
+ if (!merged.success) {
571
+ throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
572
+ diagnostics: merged.diagnostics,
665
573
  });
666
- if (result.groups.every((group) => group.error))
667
- response.isError = true;
668
- if (result.groups.some((group) => group.error)) {
669
- response[TELEMETRY_ERROR] = true;
670
- response[TELEMETRY_FAILURE] = batchFailureCause(result.groups);
574
+ }
575
+ const result = await service.searchMessagesBatch({
576
+ accounts: args.accounts,
577
+ query: merged.query,
578
+ ...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
579
+ ...(args.includeSnippet !== undefined ? { includeSnippet: args.includeSnippet } : {}),
580
+ ...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
581
+ });
582
+ const response = ok({
583
+ ...result,
584
+ groups: result.groups.map((group) => {
585
+ if (!group.page)
586
+ return group;
587
+ const diagnostics = [...parsed.diagnostics, ...(group.page.diagnostics ?? [])];
588
+ return {
589
+ ...group,
590
+ page: { ...group.page, ...(diagnostics.length ? { diagnostics } : {}) },
591
+ };
592
+ }),
593
+ });
594
+ if (result.groups.every((group) => group.error))
595
+ response.isError = true;
596
+ if (result.groups.some((group) => group.error)) {
597
+ response[TELEMETRY_ERROR] = true;
598
+ response[TELEMETRY_FAILURE] = batchFailureCause(result.groups);
599
+ }
600
+ return response;
601
+ }));
602
+ registerSharedTool('get_email', {
603
+ accountId: accountIdParam,
604
+ messageId: idParam,
605
+ includeHeaders: z
606
+ .boolean()
607
+ .optional()
608
+ .describe('Include Message-ID, References, In-Reply-To and other threading headers'),
609
+ bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
610
+ }, gated('get_email', async (args) => selectBody(await service.getMessage(args.accountId, args.messageId, args.includeHeaders !== undefined ? { includeHeaders: args.includeHeaders } : undefined), args.bodyFormat ?? 'both', MAX_BODY_CHARS)));
611
+ registerSharedTool('get_email_body', {
612
+ accountId: accountIdParam,
613
+ messageId: idParam,
614
+ format: z.enum(['text', 'html']),
615
+ offset: z.number().int().min(0).optional(),
616
+ maxChars: z.number().int().min(1).max(MAX_BODY_CHARS).optional(),
617
+ }, gated('get_email_body', async (args) => {
618
+ const message = await service.getMessage(args.accountId, args.messageId);
619
+ const value = message.body?.[args.format] ?? '';
620
+ const offset = args.offset ?? 0;
621
+ const text = value.slice(offset, offset + (args.maxChars ?? MAX_BODY_CHARS));
622
+ return {
623
+ format: args.format,
624
+ text,
625
+ offset,
626
+ totalChars: value.length,
627
+ ...(offset + text.length < value.length ? { nextOffset: offset + text.length } : {}),
628
+ };
629
+ }));
630
+ registerSharedTool('get_thread', {
631
+ accountId: accountIdParam,
632
+ threadId: idParam,
633
+ pageSize: z.number().int().min(1).max(25).optional(),
634
+ pageToken: z.string().min(1).optional(),
635
+ bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
636
+ }, gated('get_thread', async (args) => {
637
+ const thread = await service.getThread(args.accountId, args.threadId);
638
+ let offset = 0;
639
+ if (args.pageToken) {
640
+ try {
641
+ const parsed = JSON.parse(Buffer.from(args.pageToken, 'base64url').toString('utf8'));
642
+ if (parsed.threadId !== args.threadId || !Number.isInteger(parsed.offset) || parsed.offset < 0)
643
+ throw new Error('token');
644
+ offset = parsed.offset;
671
645
  }
672
- return response;
673
- }));
674
- if (can('mail.read'))
675
- registerTool('get_email', {
676
- title: 'Get email',
677
- description: 'Fetch one email in full: body (text and/or HTML), recipients, attachment metadata.',
678
- inputSchema: {
679
- accountId: accountIdParam,
680
- messageId: idParam,
681
- bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
682
- },
683
- annotations: { readOnlyHint: true },
684
- }, gated('get_email', 'mail.read', async (args) => selectBody(await service.getMessage(args.accountId, args.messageId), args.bodyFormat ?? 'both', MAX_BODY_CHARS)));
685
- if (can('mail.read'))
686
- registerTool('get_email_body', {
687
- title: 'Read email body',
688
- description: 'Read a bounded portion of one email body. Use nextOffset to continue.',
689
- inputSchema: {
690
- accountId: accountIdParam,
691
- messageId: idParam,
692
- format: z.enum(['text', 'html']),
693
- offset: z.number().int().min(0).optional(),
694
- maxChars: z.number().int().min(1).max(MAX_BODY_CHARS).optional(),
695
- },
696
- annotations: { readOnlyHint: true },
697
- }, gated('get_email_body', 'mail.read', async (args) => {
698
- const message = await service.getMessage(args.accountId, args.messageId);
699
- const value = message.body?.[args.format] ?? '';
700
- const offset = args.offset ?? 0;
701
- const text = value.slice(offset, offset + (args.maxChars ?? MAX_BODY_CHARS));
702
- return {
703
- format: args.format,
704
- text,
705
- offset,
706
- totalChars: value.length,
707
- ...(offset + text.length < value.length ? { nextOffset: offset + text.length } : {}),
708
- };
709
- }));
710
- if (can('mail.read'))
711
- registerTool('get_thread', {
712
- title: 'Get thread',
713
- description: 'Fetch a page of conversation messages with bounded body content.',
714
- inputSchema: {
715
- accountId: accountIdParam,
716
- threadId: idParam,
717
- pageSize: z.number().int().min(1).max(25).optional(),
718
- pageToken: z.string().min(1).optional(),
719
- bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
720
- },
721
- annotations: { readOnlyHint: true },
722
- }, gated('get_thread', 'mail.read', async (args) => {
723
- const thread = await service.getThread(args.accountId, args.threadId);
724
- let offset = 0;
725
- if (args.pageToken) {
726
- try {
727
- const parsed = JSON.parse(Buffer.from(args.pageToken, 'base64url').toString('utf8'));
728
- if (parsed.threadId !== args.threadId || !Number.isInteger(parsed.offset) || parsed.offset < 0)
729
- throw new Error('token');
730
- offset = parsed.offset;
731
- }
732
- catch {
733
- throw new ClientInputError('invalid_request', 'Invalid thread page token.');
734
- }
646
+ catch {
647
+ throw new ClientInputError('invalid_request', 'Invalid thread page token.');
735
648
  }
736
- const pageSize = args.pageSize ?? 10;
737
- let budget = MAX_BODY_CHARS;
738
- const messages = thread.messages.slice(offset, offset + pageSize).map((message) => {
739
- const selected = selectBody(message, args.bodyFormat ?? 'both', budget);
740
- budget -= (selected.body?.text?.length ?? 0) + (selected.body?.html?.length ?? 0);
741
- return selected;
742
- });
743
- const nextOffset = offset + messages.length;
744
- return {
745
- id: thread.id,
746
- subject: thread.subject,
747
- messages,
748
- ...(nextOffset < thread.messages.length
749
- ? {
750
- nextPageToken: Buffer.from(JSON.stringify({ threadId: args.threadId, offset: nextOffset })).toString('base64url'),
751
- }
752
- : {}),
753
- };
754
- }));
755
- if (can('mail.drafts'))
756
- registerTool('get_draft', {
757
- title: 'Get draft',
758
- description: 'Read an existing draft by its draft ID.',
759
- inputSchema: { accountId: accountIdParam, draftId: idParam },
760
- annotations: { readOnlyHint: true },
761
- }, gated('get_draft', 'mail.drafts', async (args) => service.getDraft(args.accountId, args.draftId)));
762
- if (can('mail.drafts'))
763
- registerTool('create_draft', {
764
- title: 'Create draft',
765
- description: 'Create a draft. For a reply draft, pass replyToMessageId (recipients/subject are derived; replyAll for reply-all).',
766
- inputSchema: draftShape,
767
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
768
- }, gated('create_draft', 'mail.drafts', async (args) => {
769
- if (args.replyToMessageId)
770
- requireCapabilities(['mail.read']);
771
- return service.createDraft(args.accountId, toSendInput(args));
772
- }));
773
- if (can('mail.drafts'))
774
- registerTool('update_draft', {
775
- title: 'Update draft',
776
- description: 'Replace the content of an existing draft (full replacement, not a patch).',
777
- inputSchema: { draftId: idParam, ...draftShape },
778
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
779
- }, gated('update_draft', 'mail.drafts', async (args) => {
780
- if (args.replyToMessageId)
781
- requireCapabilities(['mail.read']);
782
- return service.updateDraft(args.accountId, args.draftId, toSendInput(args));
783
- }));
784
- if (can('mail.drafts'))
785
- registerTool('delete_draft', {
786
- title: 'Delete draft',
787
- description: 'Delete a draft.',
788
- inputSchema: { accountId: accountIdParam, draftId: idParam },
789
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
790
- }, gated('delete_draft', 'mail.drafts', async (args) => {
791
- await service.deleteDraft(args.accountId, args.draftId);
792
- return { deleted: args.draftId };
793
- }));
794
- if (can('mail.send'))
795
- registerTool('preview_send', {
796
- title: 'Preview send',
797
- description: 'Show the resolved sender, recipients, subject, and attachments without sending.',
798
- inputSchema: { draftId: idParam.optional(), ...draftShape },
799
- annotations: { readOnlyHint: true },
800
- }, gated('preview_send', 'mail.send', async (args) => {
801
- if (args.draftId)
802
- requireCapabilities(['mail.drafts']);
803
- if (args.replyToMessageId)
804
- requireCapabilities(['mail.read']);
805
- return service.previewSend(args.accountId, toSendRequest(args));
806
- }));
807
- if (can('mail.send'))
808
- registerTool('send_email', {
809
- title: 'Send email',
810
- description: "Send an email from the user's connected account. Three modes: direct (to + subject + body), sending " +
811
- 'an existing draft (draftId), or replying (replyToMessageId, optionally replyAll) ' +
812
- 'where recipients, subject, and threading are derived from the original. Confirm with the user when ' +
813
- 'intent is ambiguous. Add sendAt to any mode to schedule instead of sending now.',
814
- inputSchema: {
815
- idempotencyKey: z
816
- .string()
817
- .regex(/^[\x21-\x7e]{1,255}$/)
818
- .describe('Reuse this key when retrying the same delivery'),
819
- draftId: idParam.optional().describe('Send this existing draft'),
820
- ...draftShape,
821
- sendAt: z
822
- .string()
823
- .datetime({ offset: true })
824
- .optional()
825
- .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
826
- '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
827
- 'for delivery by fluxmail serve or fluxmail scheduled run using the same store. Stdio does not ' +
828
- 'deliver schedules. Overdue mail may send when a worker starts. Returns a scheduleId for list/cancel.'),
829
- },
830
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
831
- }, handleResult('send_email', async (args) => {
832
- requireCapabilities(['mail.send']);
833
- if (args.replyToMessageId !== undefined)
834
- requireCapabilities(['mail.read']);
835
- const { sendAt, idempotencyKey, ...sendArgs } = args;
836
- return deliveryResult(await (sendAt !== undefined
837
- ? service.scheduleDelivery(args.accountId, toSendRequest(sendArgs), sendAt, idempotencyKey)
838
- : service.deliver(args.accountId, toSendRequest(sendArgs), idempotencyKey)));
839
- }, () => service.enforceQuota(), toolOptions));
840
- if (can('mail.read'))
649
+ }
650
+ const pageSize = args.pageSize ?? 10;
651
+ let budget = MAX_BODY_CHARS;
652
+ const messages = thread.messages.slice(offset, offset + pageSize).map((message) => {
653
+ const selected = selectBody(message, args.bodyFormat ?? 'both', budget);
654
+ budget -= (selected.body?.text?.length ?? 0) + (selected.body?.html?.length ?? 0);
655
+ return selected;
656
+ });
657
+ const nextOffset = offset + messages.length;
658
+ return {
659
+ id: thread.id,
660
+ subject: thread.subject,
661
+ messages,
662
+ ...(nextOffset < thread.messages.length
663
+ ? {
664
+ nextPageToken: Buffer.from(JSON.stringify({ threadId: args.threadId, offset: nextOffset })).toString('base64url'),
665
+ }
666
+ : {}),
667
+ };
668
+ }));
669
+ registerSharedTool('get_draft', { accountId: accountIdParam, draftId: idParam }, gated('get_draft', async (args) => service.getDraft(args.accountId, args.draftId)));
670
+ registerSharedTool('create_draft', draftShape, gated('create_draft', async (args) => {
671
+ if (args.replyToMessageId)
672
+ requireCapabilities(['mail.read']);
673
+ return service.createDraft(args.accountId, toSendInput(args));
674
+ }));
675
+ registerSharedTool('update_draft', { draftId: idParam, ...draftShape }, gated('update_draft', async (args) => {
676
+ if (args.replyToMessageId)
677
+ requireCapabilities(['mail.read']);
678
+ return service.updateDraft(args.accountId, args.draftId, toSendInput(args));
679
+ }));
680
+ registerSharedTool('delete_draft', { accountId: accountIdParam, draftId: idParam }, gated('delete_draft', async (args) => {
681
+ await service.deleteDraft(args.accountId, args.draftId);
682
+ return { deleted: args.draftId };
683
+ }));
684
+ registerSharedTool('preview_send', { draftId: idParam.optional(), ...draftShape }, gated('preview_send', async (args) => {
685
+ if (args.draftId)
686
+ requireCapabilities(['mail.drafts']);
687
+ if (args.replyToMessageId)
688
+ requireCapabilities(['mail.read']);
689
+ return service.previewSend(args.accountId, toSendRequest(args));
690
+ }));
691
+ registerSharedTool('send_email', {
692
+ idempotencyKey: z
693
+ .string()
694
+ .regex(/^[\x21-\x7e]{1,255}$/)
695
+ .describe('Reuse this key when retrying the same delivery'),
696
+ draftId: idParam.optional().describe('Send this existing draft'),
697
+ ...draftShape,
698
+ sendAt: z
699
+ .string()
700
+ .datetime({ offset: true })
701
+ .optional()
702
+ .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
703
+ '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
704
+ 'for delivery by fluxmail serve or fluxmail scheduled run using the same store. Stdio does not ' +
705
+ 'deliver schedules. Overdue mail may send when a worker starts. Returns a scheduleId for list/cancel.'),
706
+ }, handleResult('send_email', async (args) => {
707
+ requireToolScopes('send_email');
708
+ if (args.replyToMessageId !== undefined)
709
+ requireCapabilities(['mail.read']);
710
+ const { sendAt, idempotencyKey, ...sendArgs } = args;
711
+ return deliveryResult(await (sendAt !== undefined
712
+ ? service.scheduleDelivery(args.accountId, toSendRequest(sendArgs), sendAt, idempotencyKey)
713
+ : service.deliver(args.accountId, toSendRequest(sendArgs), idempotencyKey)));
714
+ }, () => service.enforceQuota(), toolOptions));
715
+ if (canAll(toolScopes('list_scheduled_emails')))
841
716
  registerTool('list_scheduled_emails', {
842
717
  title: 'List scheduled emails',
843
718
  description: 'List scheduled sends: pending ones first (with sendAt), then past ones (sent, failed, canceled). ' +
@@ -845,59 +720,46 @@ export function buildMcpServer(service, options = {}) {
845
720
  'Fluxmail server is running.',
846
721
  inputSchema: { accountId: accountIdParam },
847
722
  annotations: { readOnlyHint: true },
848
- }, gated('list_scheduled_emails', 'mail.read', async (args) => service.listScheduled(args.accountId)));
849
- if (can('mail.send'))
850
- registerTool('get_delivery_operation', {
851
- title: 'Get delivery status',
852
- description: 'Check whether a send or forward succeeded, failed, or has an uncertain outcome.',
853
- inputSchema: { accountId: idParam, operationId: idParam },
854
- annotations: { readOnlyHint: true },
855
- }, allowed('get_delivery_operation', 'mail.send', async (args) => service.getDelivery(args.accountId, args.operationId)));
856
- if (can('mail.drafts'))
723
+ }, gated('list_scheduled_emails', async (args) => service.listScheduled(args.accountId)));
724
+ registerSharedTool('get_delivery_operation', { accountId: idParam, operationId: idParam }, allowed('get_delivery_operation', async (args) => service.getDelivery(args.accountId, args.operationId)));
725
+ if (canAll(toolScopes('cancel_scheduled_email')))
857
726
  registerTool('cancel_scheduled_email', {
858
727
  title: 'Cancel scheduled email',
859
728
  description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
860
729
  'The draft stays in the Drafts folder, so the content is not lost.',
861
730
  inputSchema: { scheduleId: idParam },
862
731
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
863
- }, gated('cancel_scheduled_email', 'mail.drafts', async (args) => service.cancelScheduled(args.scheduleId)));
864
- if (canAll(['mail.send', 'mail.read']))
865
- registerTool('forward_email', {
866
- title: 'Forward email',
867
- description: 'Forward an email to new recipients: quoted original body, "Fwd:" subject, original attachments included ' +
868
- 'unless includeAttachments=false. Optional comment appears above the forwarded content.',
869
- inputSchema: {
870
- idempotencyKey: z
871
- .string()
872
- .regex(/^[\x21-\x7e]{1,255}$/)
873
- .describe('Reuse this key when retrying the same forward'),
874
- accountId: accountIdParam,
875
- messageId: idParam,
876
- from: z.string().email().optional().describe('Connected address or an available send-as address'),
877
- to: addressList.min(1),
878
- cc: addressList.optional(),
879
- comment: z
880
- .string()
881
- .optional()
882
- .describe('Comment above the forwarded message. Keep prose paragraphs on one continuous line and separate paragraphs with blank lines.'),
883
- includeAttachments: z.boolean().optional().describe('Default true'),
884
- },
885
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
886
- }, handleResult('forward_email', async (args) => {
887
- requireCapabilities(['mail.send', 'mail.read']);
888
- const to = parseAddresses(args.to) ?? [];
889
- const cc = parseAddresses(args.cc);
890
- return deliveryResult(await service.deliverForward(args.accountId, {
891
- messageId: args.messageId,
892
- to,
893
- ...(cc?.length ? { cc } : {}),
894
- ...(args.comment !== undefined ? { comment: args.comment } : {}),
895
- ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
896
- ...(args.from !== undefined ? { from: args.from } : {}),
897
- }, args.idempotencyKey));
898
- }, () => service.enforceQuota(), toolOptions));
732
+ }, gated('cancel_scheduled_email', async (args) => service.cancelScheduled(args.scheduleId)));
733
+ registerSharedTool('forward_email', {
734
+ idempotencyKey: z
735
+ .string()
736
+ .regex(/^[\x21-\x7e]{1,255}$/)
737
+ .describe('Reuse this key when retrying the same forward'),
738
+ accountId: accountIdParam,
739
+ messageId: idParam,
740
+ from: z.string().email().optional().describe('Connected address or an available send-as address'),
741
+ to: addressList.min(1),
742
+ cc: addressList.optional(),
743
+ comment: z
744
+ .string()
745
+ .optional()
746
+ .describe('Comment above the forwarded message. Keep prose paragraphs on one continuous line and separate paragraphs with blank lines.'),
747
+ includeAttachments: z.boolean().optional().describe('Default true'),
748
+ }, handleResult('forward_email', async (args) => {
749
+ requireToolScopes('forward_email');
750
+ const to = parseAddresses(args.to) ?? [];
751
+ const cc = parseAddresses(args.cc);
752
+ return deliveryResult(await service.deliverForward(args.accountId, {
753
+ messageId: args.messageId,
754
+ to,
755
+ ...(cc?.length ? { cc } : {}),
756
+ ...(args.comment !== undefined ? { comment: args.comment } : {}),
757
+ ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
758
+ ...(args.from !== undefined ? { from: args.from } : {}),
759
+ }, args.idempotencyKey));
760
+ }, () => service.enforceQuota(), toolOptions));
899
761
  const modifyEmails = async (action, args) => {
900
- requireCapabilities([MODIFY_TOOLS[action].capability]);
762
+ requireToolScopes(MODIFY_TOOLS[action].name);
901
763
  let modifyAction;
902
764
  if (action === 'move') {
903
765
  if (!args.folder)
@@ -933,68 +795,44 @@ export function buildMcpServer(service, options = {}) {
933
795
  labels: { labels: z.array(z.string().min(1)).min(1).max(100).describe('Gmail labels or Outlook categories') },
934
796
  };
935
797
  for (const [action, tool] of MODIFY_TOOL_ENTRIES) {
936
- if (!can(tool.capability))
937
- continue;
938
- registerTool(tool.name, {
939
- title: tool.title,
940
- description: tool.description,
941
- inputSchema: {
942
- accountId: accountIdParam,
943
- messageIds: messageIdsParam,
944
- ...(tool.input ? modifyInputs[tool.input] : {}),
945
- },
946
- annotations: {
947
- readOnlyHint: false,
948
- destructiveHint: tool.destructive,
949
- idempotentHint: true,
950
- openWorldHint: false,
951
- },
952
- }, handleResult(tool.name, async (args) => modifyEmails(action, args), () => service.enforceQuota(), toolOptions));
798
+ registerSharedTool(tool.name, { accountId: accountIdParam, messageIds: messageIdsParam, ...(tool.input ? modifyInputs[tool.input] : {}) }, handleResult(tool.name, async (args) => modifyEmails(action, args), () => service.enforceQuota(), toolOptions));
953
799
  }
954
- if (can('mail.read'))
955
- registerTool('download_attachment', {
956
- title: 'Download attachment',
957
- description: `Get metadata and a resource link for one attachment, up to ${sizeLabel(maxAttachmentBytes)}. ` +
958
- 'With inline, the result also includes the content. For a PDF, inline adds extracted text, and pages ' +
959
- 'returns the text of a page range such as 201-400.',
960
- inputSchema: {
961
- accountId: accountIdParam,
962
- messageId: idParam,
963
- attachmentId: attachmentIdParam,
964
- inline: z.boolean().optional(),
965
- pages: pageRange.optional(),
966
- },
967
- annotations: { readOnlyHint: true, destructiveHint: false },
968
- }, gatedResult('download_attachment', 'mail.read', async (args, extra) => {
969
- const { meta, content, accountId } = await downloadAttachment(args.accountId, args.messageId, args.attachmentId);
970
- const mimeType = meta.mimeType || 'application/octet-stream';
971
- const pdf = mediaType(mimeType) === 'application/pdf';
972
- if (args.pages && !pdf) {
973
- throw new ClientInputError('invalid_request', 'Pages apply only to PDF attachments.');
974
- }
975
- const extracted = pdf && (args.inline || args.pages)
976
- ? await pdfText(content, extra.mcpReq.signal, args.pages, (error) => options.logger?.warn('mcp.pdf_text_failed', 'PDF text extraction failed.', error, {
977
- productSurface: 'mcp',
978
- operation: 'download_attachment',
979
- }))
980
- : undefined;
981
- const uri = [
982
- 'fluxmail://attachment',
983
- ...[accountId, args.messageId, args.attachmentId, meta.filename].map(encodeURIComponent),
984
- ].join('/');
985
- const resourceLinks = supportsResourceLinks(extra);
986
- return {
987
- structuredContent: { data: meta },
988
- content: [
989
- { type: 'text', text: JSON.stringify(resourceLinks ? { data: meta } : { data: meta, uri }, null, 2) },
990
- ...(resourceLinks
991
- ? [{ type: 'resource_link', name: meta.filename, uri, mimeType: meta.mimeType }]
992
- : []),
993
- ...(args.inline ? [inlineContent(uri, mimeType, content)] : []),
994
- ...pdfTextContent(meta.filename, extracted),
995
- ],
996
- };
997
- }));
800
+ registerSharedTool('download_attachment', {
801
+ accountId: accountIdParam,
802
+ messageId: idParam,
803
+ attachmentId: attachmentIdParam,
804
+ inline: z.boolean().optional(),
805
+ pages: pageRange.optional(),
806
+ }, gatedResult('download_attachment', async (args, extra) => {
807
+ const { meta, content, accountId } = await downloadAttachment(args.accountId, args.messageId, args.attachmentId);
808
+ const mimeType = meta.mimeType || 'application/octet-stream';
809
+ const pdf = mediaType(mimeType) === 'application/pdf';
810
+ if (args.pages && !pdf) {
811
+ throw new ClientInputError('invalid_request', 'Pages apply only to PDF attachments.');
812
+ }
813
+ const extracted = pdf && (args.inline || args.pages)
814
+ ? await pdfText(content, extra.mcpReq.signal, args.pages, (error) => options.logger?.warn('mcp.pdf_text_failed', 'PDF text extraction failed.', error, {
815
+ productSurface: 'mcp',
816
+ operation: 'download_attachment',
817
+ }))
818
+ : undefined;
819
+ const uri = [
820
+ 'fluxmail://attachment',
821
+ ...[accountId, args.messageId, args.attachmentId, meta.filename].map(encodeURIComponent),
822
+ ].join('/');
823
+ const resourceLinks = supportsResourceLinks(extra);
824
+ return {
825
+ structuredContent: { data: meta },
826
+ content: [
827
+ { type: 'text', text: JSON.stringify(resourceLinks ? { data: meta } : { data: meta, uri }, null, 2) },
828
+ ...(resourceLinks
829
+ ? [{ type: 'resource_link', name: meta.filename, uri, mimeType: meta.mimeType }]
830
+ : []),
831
+ ...(args.inline ? [inlineContent(uri, mimeType, content)] : []),
832
+ ...pdfTextContent(meta.filename, extracted),
833
+ ],
834
+ };
835
+ }), `The size limit is ${sizeLabel(maxAttachmentBytes)}.`);
998
836
  if (can('mail.read'))
999
837
  server.registerResource('attachment', new ResourceTemplate('fluxmail://attachment/{accountId}/{messageId}/{attachmentId}/{filename}', {
1000
838
  list: undefined,