fluxmail 0.2.0 → 0.3.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 (55) hide show
  1. package/LICENSE.md +111 -0
  2. package/README.md +20 -4
  3. package/dist/accounts/gmailConnection.d.ts +17 -0
  4. package/dist/accounts/gmailConnection.d.ts.map +1 -0
  5. package/dist/accounts/gmailConnection.js +32 -0
  6. package/dist/accounts/gmailConnection.js.map +1 -0
  7. package/dist/cli.d.ts +6 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +175 -8
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config.d.ts +8 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +22 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/context.d.ts +2 -0
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +3 -1
  18. package/dist/context.js.map +1 -1
  19. package/dist/http/app.d.ts +2 -0
  20. package/dist/http/app.d.ts.map +1 -1
  21. package/dist/http/app.js +96 -19
  22. package/dist/http/app.js.map +1 -1
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp/buildServer.d.ts +11 -3
  28. package/dist/mcp/buildServer.d.ts.map +1 -1
  29. package/dist/mcp/buildServer.js +333 -229
  30. package/dist/mcp/buildServer.js.map +1 -1
  31. package/dist/permissions.d.ts +19 -0
  32. package/dist/permissions.d.ts.map +1 -0
  33. package/dist/permissions.js +70 -0
  34. package/dist/permissions.js.map +1 -0
  35. package/dist/service/emailService.d.ts +1 -1
  36. package/dist/service/emailService.d.ts.map +1 -1
  37. package/dist/service/emailService.js +2 -2
  38. package/dist/service/emailService.js.map +1 -1
  39. package/dist/storage/apiKeys.d.ts +6 -1
  40. package/dist/storage/apiKeys.d.ts.map +1 -1
  41. package/dist/storage/apiKeys.js +56 -5
  42. package/dist/storage/apiKeys.js.map +1 -1
  43. package/dist/storage/db.d.ts +172 -0
  44. package/dist/storage/db.d.ts.map +1 -1
  45. package/dist/storage/db.js +35 -1
  46. package/dist/storage/db.js.map +1 -1
  47. package/dist/storage/gmailConnectionGrants.d.ts +28 -0
  48. package/dist/storage/gmailConnectionGrants.d.ts.map +1 -0
  49. package/dist/storage/gmailConnectionGrants.js +70 -0
  50. package/dist/storage/gmailConnectionGrants.js.map +1 -0
  51. package/dist/telemetry.d.ts +35 -0
  52. package/dist/telemetry.d.ts.map +1 -0
  53. package/dist/telemetry.js +283 -0
  54. package/dist/telemetry.js.map +1 -0
  55. package/package.json +11 -7
@@ -1,11 +1,10 @@
1
- import { existsSync, statSync, writeFileSync } from 'node:fs';
2
- import path from 'node:path';
3
1
  import { VERSION } from '../version.js';
4
2
  import { z } from 'zod';
5
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
4
  import { EmailError, isEmailError, parseSingleAddress, } from '@fluxmail/core';
5
+ import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
6
+ import { FULL_PERMISSION_POLICY, hasCapability, normalizePermissionPolicy, } from '../permissions.js';
7
7
  const MAX_BODY_CHARS = 50_000;
8
- const MAX_INLINE_ATTACHMENT_BYTES = 2 * 1024 * 1024;
9
8
  const accountIdParam = z
10
9
  .string()
11
10
  .min(1)
@@ -127,12 +126,9 @@ function truncateBody(message) {
127
126
  }
128
127
  return { ...message, body };
129
128
  }
130
- function ok(data, warning) {
129
+ function ok(data) {
131
130
  return {
132
- content: [
133
- { type: 'text', text: JSON.stringify(data, null, 2) },
134
- ...(warning ? [{ type: 'text', text: `Note: ${warning}` }] : []),
135
- ],
131
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
136
132
  };
137
133
  }
138
134
  function toolError(err) {
@@ -141,55 +137,82 @@ function toolError(err) {
141
137
  : { error: 'internal', message: err instanceof Error ? err.message : String(err) };
142
138
  return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
143
139
  }
144
- function handle(fn, gate) {
140
+ function toolFeatureProperties(tool, args) {
141
+ if (!args || typeof args !== 'object')
142
+ return {};
143
+ const input = args;
144
+ switch (tool) {
145
+ case 'create_draft':
146
+ return { reply: input.replyToMessageId !== undefined, reply_all: input.replyAll === true };
147
+ case 'send_email':
148
+ return {
149
+ mode: input.draftId !== undefined ? 'draft' : input.replyToMessageId !== undefined ? 'reply' : 'direct',
150
+ scheduled: input.sendAt !== undefined,
151
+ reply_all: input.replyAll === true,
152
+ };
153
+ case 'forward_email':
154
+ return { include_attachments: input.includeAttachments !== false };
155
+ case 'modify_emails':
156
+ return typeof input.action === 'string' ? { action: input.action } : {};
157
+ case 'download_attachment':
158
+ return { destination: 'inline' };
159
+ default:
160
+ return {};
161
+ }
162
+ }
163
+ function captureToolTelemetry(options, properties) {
164
+ try {
165
+ options.telemetry?.capture('mcp tool called', { ...properties, product_surface: 'mcp' });
166
+ }
167
+ catch {
168
+ // An injected telemetry client must not affect MCP results.
169
+ }
170
+ }
171
+ function handleResult(tool, fn, gate, options = {}) {
145
172
  return async (...args) => {
173
+ const finishActivity = options.telemetry?.beginActivity?.();
174
+ const startedAt = performance.now();
146
175
  try {
147
176
  // The gate throws when a lapsed license leaves the instance over quota,
148
177
  // and yields a renewal warning to attach while the license is in grace.
149
178
  const warning = gate?.();
150
- return ok(await fn(...args), warning);
179
+ const result = await fn(...args);
180
+ if (warning)
181
+ result.content.push({ type: 'text', text: `Note: ${warning}` });
182
+ captureToolTelemetry(options, {
183
+ tool,
184
+ transport: options.transport ?? 'unknown',
185
+ outcome: 'success',
186
+ duration_ms: Math.round(performance.now() - startedAt),
187
+ ...toolFeatureProperties(tool, args[0]),
188
+ });
189
+ return result;
151
190
  }
152
191
  catch (err) {
192
+ captureToolTelemetry(options, {
193
+ tool,
194
+ transport: options.transport ?? 'unknown',
195
+ outcome: 'error',
196
+ error_code: isEmailError(err) ? err.code : 'internal',
197
+ duration_ms: Math.round(performance.now() - startedAt),
198
+ ...toolFeatureProperties(tool, args[0]),
199
+ });
153
200
  return toolError(err);
154
201
  }
202
+ finally {
203
+ finishActivity?.();
204
+ }
155
205
  };
156
206
  }
207
+ function handle(tool, fn, gate, options = {}) {
208
+ return handleResult(tool, async (...args) => ok(await fn(...args)), gate, options);
209
+ }
157
210
  function pageOpts(args) {
158
211
  return {
159
212
  ...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
160
213
  ...(args.pageToken !== undefined ? { pageToken: args.pageToken } : {}),
161
214
  };
162
215
  }
163
- export function resolveAttachmentSavePath(savePath, filename) {
164
- const isDir = savePath.endsWith(path.sep) || (existsSync(savePath) && statSync(savePath).isDirectory());
165
- if (!isDir)
166
- return savePath;
167
- const safeFilename = path.posix.basename(filename.replace(/\\/g, '/'));
168
- if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
169
- throw new Error('Attachment filename is not safe to use');
170
- }
171
- const directory = path.resolve(savePath);
172
- const target = path.resolve(directory, safeFilename);
173
- if (path.dirname(target) !== directory) {
174
- throw new Error('Attachment filename escapes the destination directory');
175
- }
176
- return target;
177
- }
178
- export function saveAttachment(savePath, filename, content) {
179
- if (!path.isAbsolute(savePath))
180
- throw new EmailError('invalid_request', 'savePath must be absolute');
181
- const target = resolveAttachmentSavePath(savePath, filename);
182
- try {
183
- writeFileSync(target, content, { flag: 'wx' });
184
- }
185
- catch (err) {
186
- if (err.code === 'EEXIST') {
187
- throw new EmailError('invalid_request', `Refusing to overwrite an existing attachment file: ${target}`);
188
- }
189
- throw err;
190
- }
191
- return target;
192
- }
193
216
  function emailQuery(args) {
194
217
  const keys = [
195
218
  'folder',
@@ -210,205 +233,286 @@ function emailQuery(args) {
210
233
  q[key] = args[key];
211
234
  return q;
212
235
  }
213
- export function buildMcpServer(service) {
236
+ const MODIFY_CAPABILITIES = {
237
+ markRead: 'mail.organize',
238
+ markUnread: 'mail.organize',
239
+ star: 'mail.organize',
240
+ unstar: 'mail.organize',
241
+ archive: 'mail.organize',
242
+ trash: 'mail.trash',
243
+ untrash: 'mail.trash',
244
+ delete: 'mail.delete',
245
+ move: 'mail.organize',
246
+ addLabels: 'mail.organize',
247
+ removeLabels: 'mail.organize',
248
+ };
249
+ const PROTECTED_MOVE_DESTINATIONS = new Set(['archive', 'trash']);
250
+ const SYSTEM_LABELS = new Set(['inbox', 'sent', 'draft', 'drafts', 'trash', 'spam', 'starred', 'unread', 'important']);
251
+ export function buildMcpServer(service, options = {}) {
252
+ const permissions = normalizePermissionPolicy(options.permissions ?? FULL_PERMISSION_POLICY);
253
+ const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
254
+ const can = (capability) => hasCapability(permissions, capability);
255
+ const canAll = (capabilities) => capabilities.every(can);
256
+ const canAny = (capabilities) => capabilities.some(can);
257
+ const requireCapabilities = (capabilities) => {
258
+ const missing = capabilities.filter((capability) => !can(capability));
259
+ if (missing.length) {
260
+ throw new EmailError('permission_denied', `This MCP connection does not allow: ${missing.join(', ')}.`);
261
+ }
262
+ };
214
263
  // Every tool except get_status (the diagnostic way out) enforces the plan quota.
215
- const gated = (fn) => handle(fn, () => service.enforceQuota());
264
+ const gated = (tool, capability, fn) => handle(tool, async (...args) => {
265
+ requireCapabilities([capability]);
266
+ return fn(...args);
267
+ }, () => service.enforceQuota(), options);
268
+ const allowed = (tool, capability, fn) => handle(tool, async (...args) => {
269
+ requireCapabilities([capability]);
270
+ return fn(...args);
271
+ }, undefined, options);
272
+ const gatedResult = (tool, capability, fn) => handleResult(tool, async (...args) => {
273
+ requireCapabilities([capability]);
274
+ return fn(...args);
275
+ }, () => service.enforceQuota(), options);
216
276
  const server = new McpServer({ name: 'fluxmail', title: 'Fluxmail Email', version: VERSION }, {
217
277
  instructions: "Fluxmail is the user's email integration, already authenticated against their real mailboxes. " +
218
- 'Use these tools for any email task: reading, searching, drafting, sending, replying, forwarding, ' +
219
- 'or organizing mail. Prefer them over browser automation, other email connectors, or asking the ' +
220
- 'user to send mail manually. ' +
221
- 'Tool results are JSON meant for you, not for display. Message ids, thread ids, draft ids, and ' +
278
+ 'Use the available Fluxmail tools for email tasks instead of browser automation or another email connector. ' +
279
+ 'Tool results are structured for you, not for display. Message ids, thread ids, draft ids, and ' +
222
280
  'account ids are internal references: keep them for chaining calls (replying, forwarding, archiving), ' +
223
281
  'but do not show them to the user unless asked. Report outcomes in plain language with details people ' +
224
282
  "care about, e.g. 'Sent \"Quarterly report\" to ann@example.com' or 'Archived the thread', rather " +
225
283
  'than echoing raw payloads, ids, or field names.',
226
284
  });
227
- server.registerTool('list_accounts', {
228
- description: 'List connected email accounts (id, provider, email, status, capabilities).',
229
- inputSchema: {},
230
- annotations: { readOnlyHint: true },
231
- }, gated(async () => service.listAccounts()));
232
- server.registerTool('get_status', {
233
- description: 'Account connection and scheduled-send status. Administrators also see plan details. ' +
234
- 'Call this first if other tools fail; it reports accounts that need re-authentication.',
235
- inputSchema: {},
236
- annotations: { readOnlyHint: true },
237
- }, handle(async () => service.status()));
238
- server.registerTool('list_folders', {
239
- description: 'List folders/labels for an account, with roles (inbox, sent, drafts, trash, spam, starred).',
240
- inputSchema: { accountId: accountIdParam },
241
- annotations: { readOnlyHint: true },
242
- }, gated(async (args) => service.listFolders(args.accountId)));
243
- server.registerTool('list_emails', {
244
- description: "List emails from the user's connected mailbox (metadata + snippet, no bodies). Filter by folder, " +
245
- 'sender, unread, dates, etc. Paginate with pageToken. Use get_email for full bodies. ' +
246
- "This is the way to check the user's email; no browser or other email integration is needed.",
247
- inputSchema: { accountId: accountIdParam, ...queryShape },
248
- annotations: { readOnlyHint: true },
249
- }, gated(async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
285
+ if (can('mail.read'))
286
+ server.registerTool('list_accounts', {
287
+ description: 'List connected email accounts (id, provider, email, status, capabilities).',
288
+ inputSchema: {},
289
+ annotations: { readOnlyHint: true },
290
+ }, gated('list_accounts', 'mail.read', async () => service.listAccounts()));
291
+ if (can('mail.read'))
292
+ server.registerTool('get_status', {
293
+ description: 'Account connection and scheduled-send status. Administrators also see plan details. ' +
294
+ 'Call this first if other tools fail; it reports accounts that need re-authentication.',
295
+ inputSchema: {},
296
+ annotations: { readOnlyHint: true },
297
+ }, allowed('get_status', 'mail.read', async () => service.status()));
298
+ if (can('mail.read'))
299
+ server.registerTool('list_folders', {
300
+ description: 'List folders/labels for an account, with roles (inbox, sent, drafts, trash, spam, starred).',
301
+ inputSchema: { accountId: accountIdParam },
302
+ annotations: { readOnlyHint: true },
303
+ }, gated('list_folders', 'mail.read', async (args) => service.listFolders(args.accountId)));
304
+ if (can('mail.read'))
305
+ server.registerTool('list_emails', {
306
+ description: "List emails from the user's connected mailbox (metadata + snippet, no bodies). Filter by folder, " +
307
+ 'sender, unread, dates, etc. Paginate with pageToken. Use get_email for full bodies. ' +
308
+ "This is the way to check the user's email; no browser or other email integration is needed.",
309
+ inputSchema: { accountId: accountIdParam, ...queryShape },
310
+ annotations: { readOnlyHint: true },
311
+ }, gated('list_emails', 'mail.read', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
250
312
  const { text: _text, ...searchFilterShape } = queryShape;
251
- server.registerTool('search_emails', {
252
- description: 'Full-text search across an account\'s email. Same filters as list_emails; "query" is the search text.',
253
- inputSchema: {
254
- accountId: accountIdParam,
255
- query: z.string().describe('Search text'),
256
- ...searchFilterShape,
257
- },
258
- annotations: { readOnlyHint: true },
259
- }, gated(async (args) => service.listMessages(args.accountId, { ...emailQuery(args), text: args.query }, pageOpts(args))));
260
- server.registerTool('get_email', {
261
- description: 'Fetch one email in full: body (text and/or HTML), recipients, attachment metadata.',
262
- inputSchema: { accountId: accountIdParam, messageId: idParam },
263
- annotations: { readOnlyHint: true },
264
- }, gated(async (args) => truncateBody(await service.getMessage(args.accountId, args.messageId))));
265
- server.registerTool('get_thread', {
266
- description: 'Fetch a full conversation thread with all message bodies.',
267
- inputSchema: { accountId: accountIdParam, threadId: idParam },
268
- annotations: { readOnlyHint: true },
269
- }, gated(async (args) => {
270
- const thread = await service.getThread(args.accountId, args.threadId);
271
- return { ...thread, messages: thread.messages.map(truncateBody) };
272
- }));
273
- server.registerTool('create_draft', {
274
- description: 'Create a draft. For a reply draft, pass replyToMessageId (recipients/subject are derived; replyAll for reply-all).',
275
- inputSchema: draftShape,
276
- }, gated(async (args) => service.createDraft(args.accountId, toSendInput(args))));
277
- server.registerTool('update_draft', {
278
- description: 'Replace the content of an existing draft (full replacement, not a patch).',
279
- inputSchema: { draftId: idParam, ...draftShape },
280
- }, gated(async (args) => service.updateDraft(args.accountId, args.draftId, toSendInput(args))));
281
- server.registerTool('delete_draft', {
282
- description: 'Delete a draft.',
283
- inputSchema: { accountId: accountIdParam, draftId: idParam },
284
- annotations: { destructiveHint: true },
285
- }, gated(async (args) => {
286
- await service.deleteDraft(args.accountId, args.draftId);
287
- return { deleted: args.draftId };
288
- }));
289
- server.registerTool('send_email', {
290
- description: "Send an email from the user's connected account; this actually delivers mail, so prefer it over " +
291
- 'browser automation or leaving a draft when the user asked to send. Three modes: direct (to + subject ' +
292
- '+ body), sending an existing draft (draftId), or replying (replyToMessageId, optionally replyAll) ' +
293
- 'where recipients, subject, and threading are derived from the original. Confirm with the user when ' +
294
- 'intent is ambiguous. Add sendAt to any mode to schedule instead of sending now.',
295
- inputSchema: {
296
- draftId: idParam.optional().describe('Send this existing draft'),
297
- ...draftShape,
298
- sendAt: z
299
- .string()
300
- .datetime({ offset: true })
301
- .optional()
302
- .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
303
- '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
304
- 'and sends it at this time; the server must be running then (anything missed while it was ' +
305
- 'down goes out at the next startup). Returns a scheduleId for list/cancel.'),
306
- },
307
- annotations: { destructiveHint: true },
308
- }, gated(async (args) => {
309
- const { sendAt, ...sendArgs } = args;
310
- return sendAt !== undefined
311
- ? service.scheduleSend(args.accountId, toSendRequest(sendArgs), sendAt)
312
- : service.send(args.accountId, toSendRequest(sendArgs));
313
- }));
314
- server.registerTool('list_scheduled_emails', {
315
- description: 'List scheduled sends: pending ones first (with sendAt), then past ones (sent, failed, canceled). ' +
316
- 'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
317
- 'Fluxmail server is running.',
318
- inputSchema: { accountId: accountIdParam },
319
- annotations: { readOnlyHint: true },
320
- }, gated(async (args) => service.listScheduled(args.accountId)));
321
- server.registerTool('cancel_scheduled_email', {
322
- description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
323
- 'The draft stays in the Drafts folder, so the content is not lost.',
324
- inputSchema: { scheduleId: idParam },
325
- }, gated(async (args) => service.cancelScheduled(args.scheduleId)));
326
- server.registerTool('forward_email', {
327
- description: 'Forward an email to new recipients: quoted original body, "Fwd:" subject, original attachments included ' +
328
- 'unless includeAttachments=false. Optional comment appears above the forwarded content.',
329
- inputSchema: {
330
- accountId: accountIdParam,
331
- messageId: idParam,
332
- to: addressList.min(1),
333
- cc: addressList.optional(),
334
- comment: z.string().optional(),
335
- includeAttachments: z.boolean().optional().describe('Default true'),
336
- },
337
- annotations: { destructiveHint: true },
338
- }, gated(async (args) => {
339
- const to = parseAddresses(args.to) ?? [];
340
- const cc = parseAddresses(args.cc);
341
- return service.forward(args.accountId, {
342
- messageId: args.messageId,
343
- to,
344
- ...(cc?.length ? { cc } : {}),
345
- ...(args.comment !== undefined ? { comment: args.comment } : {}),
346
- ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
347
- });
348
- }));
349
- server.registerTool('modify_emails', {
350
- description: 'Batch-modify emails: markRead, markUnread, star, unstar, archive, trash, untrash, delete (permanent!), ' +
351
- 'move (requires folder), addLabels/removeLabels (requires labels; Gmail only).',
352
- inputSchema: {
353
- accountId: accountIdParam,
354
- messageIds: z.array(idParam).min(1),
355
- action: z.enum([
356
- 'markRead',
357
- 'markUnread',
358
- 'star',
359
- 'unstar',
360
- 'archive',
361
- 'trash',
362
- 'untrash',
363
- 'delete',
364
- 'move',
365
- 'addLabels',
366
- 'removeLabels',
367
- ]),
368
- folder: z.string().min(1).optional().describe('Target folder for action=move'),
369
- labels: z.array(z.string().min(1)).max(100).optional().describe('Labels for addLabels/removeLabels'),
370
- },
371
- annotations: { destructiveHint: true },
372
- }, gated(async (args) => {
373
- let action;
374
- if (args.action === 'move') {
375
- if (!args.folder)
376
- throw new EmailError('invalid_request', 'action=move requires "folder"');
377
- action = { move: args.folder };
378
- }
379
- else if (args.action === 'addLabels' || args.action === 'removeLabels') {
380
- if (!args.labels?.length) {
381
- throw new EmailError('invalid_request', `action=${args.action} requires "labels"`);
313
+ if (can('mail.read'))
314
+ server.registerTool('search_emails', {
315
+ description: 'Full-text search across an account\'s email. Same filters as list_emails; "query" is the search text.',
316
+ inputSchema: {
317
+ accountId: accountIdParam,
318
+ query: z.string().describe('Search text'),
319
+ ...searchFilterShape,
320
+ },
321
+ annotations: { readOnlyHint: true },
322
+ }, gated('search_emails', 'mail.read', async (args) => service.listMessages(args.accountId, { ...emailQuery(args), text: args.query }, pageOpts(args))));
323
+ if (can('mail.read'))
324
+ server.registerTool('get_email', {
325
+ description: 'Fetch one email in full: body (text and/or HTML), recipients, attachment metadata.',
326
+ inputSchema: { accountId: accountIdParam, messageId: idParam },
327
+ annotations: { readOnlyHint: true },
328
+ }, gated('get_email', 'mail.read', async (args) => truncateBody(await service.getMessage(args.accountId, args.messageId))));
329
+ if (can('mail.read'))
330
+ server.registerTool('get_thread', {
331
+ description: 'Fetch a full conversation thread with all message bodies.',
332
+ inputSchema: { accountId: accountIdParam, threadId: idParam },
333
+ annotations: { readOnlyHint: true },
334
+ }, gated('get_thread', 'mail.read', async (args) => {
335
+ const thread = await service.getThread(args.accountId, args.threadId);
336
+ return { ...thread, messages: thread.messages.map(truncateBody) };
337
+ }));
338
+ if (can('mail.drafts'))
339
+ server.registerTool('create_draft', {
340
+ description: 'Create a draft. For a reply draft, pass replyToMessageId (recipients/subject are derived; replyAll for reply-all).',
341
+ inputSchema: draftShape,
342
+ }, gated('create_draft', 'mail.drafts', async (args) => {
343
+ if (args.replyToMessageId)
344
+ requireCapabilities(['mail.read']);
345
+ return service.createDraft(args.accountId, toSendInput(args));
346
+ }));
347
+ if (can('mail.drafts'))
348
+ server.registerTool('update_draft', {
349
+ description: 'Replace the content of an existing draft (full replacement, not a patch).',
350
+ inputSchema: { draftId: idParam, ...draftShape },
351
+ }, gated('update_draft', 'mail.drafts', async (args) => {
352
+ if (args.replyToMessageId)
353
+ requireCapabilities(['mail.read']);
354
+ return service.updateDraft(args.accountId, args.draftId, toSendInput(args));
355
+ }));
356
+ if (can('mail.drafts'))
357
+ server.registerTool('delete_draft', {
358
+ description: 'Delete a draft.',
359
+ inputSchema: { accountId: accountIdParam, draftId: idParam },
360
+ annotations: { destructiveHint: true },
361
+ }, gated('delete_draft', 'mail.drafts', async (args) => {
362
+ await service.deleteDraft(args.accountId, args.draftId);
363
+ return { deleted: args.draftId };
364
+ }));
365
+ if (can('mail.send'))
366
+ server.registerTool('send_email', {
367
+ description: "Send an email from the user's connected account; this actually delivers mail, so prefer it over " +
368
+ 'browser automation or leaving a draft when the user asked to send. Three modes: direct (to + subject ' +
369
+ '+ body), sending an existing draft (draftId), or replying (replyToMessageId, optionally replyAll) ' +
370
+ 'where recipients, subject, and threading are derived from the original. Confirm with the user when ' +
371
+ 'intent is ambiguous. Add sendAt to any mode to schedule instead of sending now.',
372
+ inputSchema: {
373
+ draftId: idParam.optional().describe('Send this existing draft'),
374
+ ...draftShape,
375
+ sendAt: z
376
+ .string()
377
+ .datetime({ offset: true })
378
+ .optional()
379
+ .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
380
+ '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
381
+ 'and sends it at this time; the server must be running then (anything missed while it was ' +
382
+ 'down goes out at the next startup). Returns a scheduleId for list/cancel.'),
383
+ },
384
+ annotations: { destructiveHint: true },
385
+ }, handle('send_email', async (args) => {
386
+ requireCapabilities(['mail.send']);
387
+ if (args.replyToMessageId !== undefined)
388
+ requireCapabilities(['mail.read']);
389
+ const { sendAt, ...sendArgs } = args;
390
+ return sendAt !== undefined
391
+ ? service.scheduleSend(args.accountId, toSendRequest(sendArgs), sendAt)
392
+ : service.send(args.accountId, toSendRequest(sendArgs));
393
+ }, () => service.enforceQuota(), options));
394
+ if (can('mail.read'))
395
+ server.registerTool('list_scheduled_emails', {
396
+ description: 'List scheduled sends: pending ones first (with sendAt), then past ones (sent, failed, canceled). ' +
397
+ 'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
398
+ 'Fluxmail server is running.',
399
+ inputSchema: { accountId: accountIdParam },
400
+ annotations: { readOnlyHint: true },
401
+ }, gated('list_scheduled_emails', 'mail.read', async (args) => service.listScheduled(args.accountId)));
402
+ if (can('mail.drafts'))
403
+ server.registerTool('cancel_scheduled_email', {
404
+ description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
405
+ 'The draft stays in the Drafts folder, so the content is not lost.',
406
+ inputSchema: { scheduleId: idParam },
407
+ }, gated('cancel_scheduled_email', 'mail.drafts', async (args) => service.cancelScheduled(args.scheduleId)));
408
+ if (canAll(['mail.send', 'mail.read']))
409
+ server.registerTool('forward_email', {
410
+ description: 'Forward an email to new recipients: quoted original body, "Fwd:" subject, original attachments included ' +
411
+ 'unless includeAttachments=false. Optional comment appears above the forwarded content.',
412
+ inputSchema: {
413
+ accountId: accountIdParam,
414
+ messageId: idParam,
415
+ to: addressList.min(1),
416
+ cc: addressList.optional(),
417
+ comment: z.string().optional(),
418
+ includeAttachments: z.boolean().optional().describe('Default true'),
419
+ },
420
+ annotations: { destructiveHint: true },
421
+ }, handle('forward_email', async (args) => {
422
+ requireCapabilities(['mail.send', 'mail.read']);
423
+ const to = parseAddresses(args.to) ?? [];
424
+ const cc = parseAddresses(args.cc);
425
+ return service.forward(args.accountId, {
426
+ messageId: args.messageId,
427
+ to,
428
+ ...(cc?.length ? { cc } : {}),
429
+ ...(args.comment !== undefined ? { comment: args.comment } : {}),
430
+ ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
431
+ });
432
+ }, () => service.enforceQuota(), options));
433
+ const modifyActions = Object.keys(MODIFY_CAPABILITIES).filter((action) => can(MODIFY_CAPABILITIES[action]));
434
+ if (modifyActions.length) {
435
+ const allowedModifyActions = new Set(modifyActions);
436
+ server.registerTool('modify_emails', {
437
+ description: 'Batch-modify emails using the actions allowed for this connection. Moving requires folder; labels require labels.',
438
+ inputSchema: {
439
+ accountId: accountIdParam,
440
+ messageIds: z.array(idParam).min(1),
441
+ action: z.enum(modifyActions),
442
+ folder: z.string().min(1).optional().describe('Target folder for action=move'),
443
+ labels: z.array(z.string().min(1)).max(100).optional().describe('Labels for addLabels/removeLabels'),
444
+ },
445
+ annotations: {
446
+ destructiveHint: canAny(['mail.trash', 'mail.delete']),
447
+ },
448
+ }, handle('modify_emails', async (args) => {
449
+ if (!allowedModifyActions.has(args.action)) {
450
+ throw new EmailError('permission_denied', `This MCP connection does not allow action=${args.action}.`);
382
451
  }
383
- action = args.action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
384
- }
385
- else {
386
- action = args.action;
387
- }
388
- await service.modify(args.accountId, args.messageIds, action);
389
- return { modified: args.messageIds.length, action: args.action };
390
- }));
391
- server.registerTool('download_attachment', {
392
- description: 'Download an email attachment. With savePath, writes the file to disk and returns the path; ' +
393
- 'otherwise returns base64 content (up to 2 MB).',
394
- inputSchema: {
395
- accountId: accountIdParam,
396
- messageId: idParam,
397
- attachmentId: idParam,
398
- savePath: z.string().min(1).optional().describe('Absolute file path or directory to write the attachment to'),
399
- },
400
- annotations: { readOnlyHint: false, destructiveHint: true },
401
- }, gated(async (args) => {
402
- const { meta, content } = await service.getAttachment(args.accountId, args.messageId, args.attachmentId);
403
- if (args.savePath) {
404
- const target = saveAttachment(args.savePath, meta.filename, content);
405
- return { saved: target, ...meta };
406
- }
407
- if (content.length > MAX_INLINE_ATTACHMENT_BYTES) {
408
- throw new EmailError('invalid_request', `Attachment is ${content.length} bytes (limit ${MAX_INLINE_ATTACHMENT_BYTES} inline). Pass savePath to write it to disk.`);
409
- }
410
- return { ...meta, contentBase64: content.toString('base64') };
411
- }));
452
+ requireCapabilities([MODIFY_CAPABILITIES[args.action]]);
453
+ let action;
454
+ if (args.action === 'move') {
455
+ if (!args.folder)
456
+ throw new EmailError('invalid_request', 'action=move requires "folder"');
457
+ if (PROTECTED_MOVE_DESTINATIONS.has(args.folder.trim().toLowerCase())) {
458
+ throw new EmailError('invalid_request', 'Use the dedicated archive or trash action for this folder');
459
+ }
460
+ action = { move: args.folder };
461
+ }
462
+ else if (args.action === 'addLabels' || args.action === 'removeLabels') {
463
+ if (!args.labels?.length) {
464
+ throw new EmailError('invalid_request', `action=${args.action} requires "labels"`);
465
+ }
466
+ if (args.labels.some((label) => SYSTEM_LABELS.has(label.trim().toLowerCase()))) {
467
+ throw new EmailError('invalid_request', 'System labels must be changed with their dedicated message action');
468
+ }
469
+ action = args.action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
470
+ }
471
+ else {
472
+ action = args.action;
473
+ }
474
+ await service.modify(args.accountId, args.messageIds, action);
475
+ return { modified: args.messageIds.length, action: args.action };
476
+ }, () => service.enforceQuota(), options));
477
+ }
478
+ if (can('mail.read'))
479
+ server.registerTool('download_attachment', {
480
+ description: 'Download an email attachment as an embedded MCP resource.',
481
+ inputSchema: {
482
+ accountId: accountIdParam,
483
+ messageId: idParam,
484
+ attachmentId: idParam,
485
+ },
486
+ annotations: { readOnlyHint: true, destructiveHint: false },
487
+ }, gatedResult('download_attachment', 'mail.read', async (args) => {
488
+ const { meta, content } = await service.getAttachment(args.accountId, args.messageId, args.attachmentId, maxAttachmentBytes);
489
+ if (content.length > maxAttachmentBytes) {
490
+ throw new EmailError('invalid_request', 'Attachment is too large to return through MCP.', {
491
+ sizeBytes: content.length,
492
+ maxBytes: maxAttachmentBytes,
493
+ });
494
+ }
495
+ const uri = [
496
+ 'fluxmail://attachment',
497
+ encodeURIComponent(args.accountId ?? 'default'),
498
+ encodeURIComponent(args.messageId),
499
+ encodeURIComponent(args.attachmentId),
500
+ encodeURIComponent(meta.filename),
501
+ ].join('/');
502
+ return {
503
+ content: [
504
+ { type: 'text', text: JSON.stringify(meta, null, 2) },
505
+ {
506
+ type: 'resource',
507
+ resource: {
508
+ uri,
509
+ mimeType: meta.mimeType || 'application/octet-stream',
510
+ blob: content.toString('base64'),
511
+ },
512
+ },
513
+ ],
514
+ };
515
+ }));
412
516
  return server;
413
517
  }
414
518
  //# sourceMappingURL=buildServer.js.map