proton-mail-bridge-client 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +445 -0
  3. package/dist/cli.d.ts +12 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +1627 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/index.d.ts +56 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +3173 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/scripts/bridge-smoke.d.ts +2 -0
  12. package/dist/scripts/bridge-smoke.d.ts.map +1 -0
  13. package/dist/scripts/bridge-smoke.js +415 -0
  14. package/dist/scripts/bridge-smoke.js.map +1 -0
  15. package/dist/scripts/check-claude-desktop.d.ts +17 -0
  16. package/dist/scripts/check-claude-desktop.d.ts.map +1 -0
  17. package/dist/scripts/check-claude-desktop.js +138 -0
  18. package/dist/scripts/check-claude-desktop.js.map +1 -0
  19. package/dist/scripts/install-claude-desktop.d.ts +39 -0
  20. package/dist/scripts/install-claude-desktop.d.ts.map +1 -0
  21. package/dist/scripts/install-claude-desktop.js +257 -0
  22. package/dist/scripts/install-claude-desktop.js.map +1 -0
  23. package/dist/scripts/setup-claude-desktop.d.ts +15 -0
  24. package/dist/scripts/setup-claude-desktop.d.ts.map +1 -0
  25. package/dist/scripts/setup-claude-desktop.js +187 -0
  26. package/dist/scripts/setup-claude-desktop.js.map +1 -0
  27. package/dist/services/analytics-service.d.ts +45 -0
  28. package/dist/services/analytics-service.d.ts.map +1 -0
  29. package/dist/services/analytics-service.js +189 -0
  30. package/dist/services/analytics-service.js.map +1 -0
  31. package/dist/services/audit-service.d.ts +13 -0
  32. package/dist/services/audit-service.d.ts.map +1 -0
  33. package/dist/services/audit-service.js +67 -0
  34. package/dist/services/audit-service.js.map +1 -0
  35. package/dist/services/background-sync-service.d.ts +28 -0
  36. package/dist/services/background-sync-service.d.ts.map +1 -0
  37. package/dist/services/background-sync-service.js +216 -0
  38. package/dist/services/background-sync-service.js.map +1 -0
  39. package/dist/services/draft-store-service.d.ts +59 -0
  40. package/dist/services/draft-store-service.d.ts.map +1 -0
  41. package/dist/services/draft-store-service.js +251 -0
  42. package/dist/services/draft-store-service.js.map +1 -0
  43. package/dist/services/local-index-service.d.ts +90 -0
  44. package/dist/services/local-index-service.d.ts.map +1 -0
  45. package/dist/services/local-index-service.js +1462 -0
  46. package/dist/services/local-index-service.js.map +1 -0
  47. package/dist/services/simple-imap-service.d.ts +209 -0
  48. package/dist/services/simple-imap-service.d.ts.map +1 -0
  49. package/dist/services/simple-imap-service.js +1100 -0
  50. package/dist/services/simple-imap-service.js.map +1 -0
  51. package/dist/services/smtp-service.d.ts +15 -0
  52. package/dist/services/smtp-service.d.ts.map +1 -0
  53. package/dist/services/smtp-service.js +100 -0
  54. package/dist/services/smtp-service.js.map +1 -0
  55. package/dist/types/index.d.ts +355 -0
  56. package/dist/types/index.d.ts.map +1 -0
  57. package/dist/types/index.js +2 -0
  58. package/dist/types/index.js.map +1 -0
  59. package/dist/utils/helpers.d.ts +45 -0
  60. package/dist/utils/helpers.d.ts.map +1 -0
  61. package/dist/utils/helpers.js +393 -0
  62. package/dist/utils/helpers.js.map +1 -0
  63. package/dist/utils/logger.d.ts +22 -0
  64. package/dist/utils/logger.d.ts.map +1 -0
  65. package/dist/utils/logger.js +91 -0
  66. package/dist/utils/logger.js.map +1 -0
  67. package/dist/utils/runtime-policy.d.ts +12 -0
  68. package/dist/utils/runtime-policy.d.ts.map +1 -0
  69. package/dist/utils/runtime-policy.js +62 -0
  70. package/dist/utils/runtime-policy.js.map +1 -0
  71. package/glama.json +6 -0
  72. package/package.json +91 -0
package/dist/index.js ADDED
@@ -0,0 +1,3173 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
10
+ import { AnalyticsService } from "./services/analytics-service.js";
11
+ import { AuditService } from "./services/audit-service.js";
12
+ import { BackgroundSyncService } from "./services/background-sync-service.js";
13
+ import { DraftStoreService } from "./services/draft-store-service.js";
14
+ import { LocalIndexService } from "./services/local-index-service.js";
15
+ import { SimpleIMAPService } from "./services/simple-imap-service.js";
16
+ import { SMTPService } from "./services/smtp-service.js";
17
+ import { ensureValidEmails, isTextLikeMimeType, isValidEmail, lowerCaseAddress, normalizeBoolean, normalizeLimit, normalizeJsonValue, parseEmails, stringifyForJson, } from "./utils/helpers.js";
18
+ import { logger } from "./utils/logger.js";
19
+ import { ensureDestructiveConfirmed, ensureEmailActionAllowed, ensureMailboxWriteAllowed, ensureRemoteDraftSyncAllowed, ensureSendAllowed, resolveRemoteDraftSync, sanitizeRuntimeConfig, } from "./utils/runtime-policy.js";
20
+ const RESOURCE_SCHEME = "protonmail";
21
+ const ALL_EMAIL_ACTIONS = [
22
+ "mark_read",
23
+ "mark_unread",
24
+ "star",
25
+ "unstar",
26
+ "archive",
27
+ "trash",
28
+ "restore",
29
+ ];
30
+ const TOOLS = [
31
+ {
32
+ name: "send_email",
33
+ description: "Compose and immediately send a new outbound email through Proton Bridge SMTP. Use for one-shot messages that need no review. Prefer create_draft when you want to save and review before sending, or reply_to_email when responding to an existing message. Fails if PROTONMAIL_ALLOW_SEND is false or if Bridge SMTP is unreachable. Returns delivery confirmation.",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ to: { type: "string", description: "Recipient email addresses, comma-separated." },
38
+ cc: { type: "string", description: "CC recipient email addresses, comma-separated." },
39
+ bcc: { type: "string", description: "BCC recipient email addresses, comma-separated." },
40
+ subject: { type: "string", description: "Email subject." },
41
+ body: { type: "string", description: "Email body content." },
42
+ isHtml: { type: "boolean", description: "Whether body should be sent as HTML.", default: false },
43
+ priority: {
44
+ type: "string",
45
+ enum: ["high", "normal", "low"],
46
+ description: "SMTP priority header.",
47
+ },
48
+ replyTo: { type: "string", description: "Optional reply-to email address." },
49
+ attachments: {
50
+ type: "array",
51
+ description: "Attachments with base64 encoded content.",
52
+ items: {
53
+ type: "object",
54
+ properties: {
55
+ filename: { type: "string" },
56
+ content: { type: "string", description: "Base64 content." },
57
+ contentType: { type: "string" },
58
+ cid: { type: "string" },
59
+ contentDisposition: { type: "string" },
60
+ },
61
+ required: ["filename", "content"],
62
+ },
63
+ },
64
+ confirmed: { type: "boolean", description: "Set to true to confirm this irreversible send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
65
+ },
66
+ required: ["to", "subject", "body"],
67
+ },
68
+ },
69
+ {
70
+ name: "send_test_email",
71
+ description: "Send a minimal diagnostic email to confirm Proton Bridge SMTP credentials and connectivity. Use before relying on send_email in a new environment. Prefer get_connection_status for a connectivity check that does not actually send mail. Returns transport debug info and delivery status.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ to: { type: "string", description: "Recipient email address." },
76
+ customMessage: { type: "string", description: "Optional custom test body." },
77
+ },
78
+ required: ["to"],
79
+ },
80
+ },
81
+ {
82
+ name: "reply_to_email",
83
+ description: "Immediately send a reply to an existing email, threading it correctly via In-Reply-To and References headers. Use when you have an emailId and want to send the reply right away. Prefer create_reply_draft to save the reply for review first, or create_thread_reply_draft when replying from a threadId. Requires PROTONMAIL_ALLOW_SEND.",
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {
87
+ emailId: { type: "string", description: "Original email id." },
88
+ body: { type: "string", description: "Reply body to prepend." },
89
+ replyAll: { type: "boolean", description: "Reply to all original recipients.", default: false },
90
+ isHtml: { type: "boolean", description: "Send body as HTML.", default: false },
91
+ cc: { type: "string", description: "Additional CC recipients, comma-separated." },
92
+ bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
93
+ attachments: {
94
+ type: "array",
95
+ description: "Attachments with base64 encoded content.",
96
+ items: {
97
+ type: "object",
98
+ properties: {
99
+ filename: { type: "string" },
100
+ content: { type: "string" },
101
+ contentType: { type: "string" },
102
+ cid: { type: "string" },
103
+ contentDisposition: { type: "string" },
104
+ },
105
+ required: ["filename", "content"],
106
+ },
107
+ },
108
+ confirmed: { type: "boolean", description: "Set to true to confirm this irreversible send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
109
+ },
110
+ required: ["emailId", "body"],
111
+ },
112
+ },
113
+ {
114
+ name: "forward_email",
115
+ description: "Immediately forward an existing email to new recipients, preserving original attachments and prepending an optional note. Use when you have an emailId and want to re-route the message without review. Prefer create_forward_draft to stage a forward for review first. Requires PROTONMAIL_ALLOW_SEND.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ emailId: { type: "string", description: "Original email id." },
120
+ to: { type: "string", description: "Forward recipient list, comma-separated." },
121
+ body: { type: "string", description: "Optional message before the forwarded content." },
122
+ isHtml: { type: "boolean", description: "Send body as HTML.", default: false },
123
+ cc: { type: "string", description: "CC recipients, comma-separated." },
124
+ bcc: { type: "string", description: "BCC recipients, comma-separated." },
125
+ attachments: {
126
+ type: "array",
127
+ description: "Attachments with base64 encoded content.",
128
+ items: {
129
+ type: "object",
130
+ properties: {
131
+ filename: { type: "string" },
132
+ content: { type: "string" },
133
+ contentType: { type: "string" },
134
+ cid: { type: "string" },
135
+ contentDisposition: { type: "string" },
136
+ },
137
+ required: ["filename", "content"],
138
+ },
139
+ },
140
+ confirmed: { type: "boolean", description: "Set to true to confirm this irreversible send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
141
+ },
142
+ required: ["emailId", "to"],
143
+ },
144
+ },
145
+ {
146
+ name: "create_draft",
147
+ description: "Save a new outbound message as a local draft in SQLite, optionally syncing it to the Proton Drafts IMAP folder. Use to compose and review before sending. Prefer create_reply_draft when replying to a specific emailId, or create_forward_draft when forwarding. Returns a draftId for later update, sync, or send via send_draft.",
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {
151
+ to: { type: "string", description: "Recipient email addresses, comma-separated." },
152
+ cc: { type: "string", description: "CC recipient email addresses, comma-separated." },
153
+ bcc: { type: "string", description: "BCC recipient email addresses, comma-separated." },
154
+ subject: { type: "string", description: "Draft subject." },
155
+ body: { type: "string", description: "Draft body." },
156
+ isHtml: { type: "boolean", description: "Whether the body should be HTML.", default: false },
157
+ priority: { type: "string", enum: ["high", "normal", "low"] },
158
+ replyTo: { type: "string", description: "Optional reply-to email address." },
159
+ notes: { type: "string", description: "Optional local note for the draft." },
160
+ syncToRemote: {
161
+ type: "boolean",
162
+ description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
163
+ default: true,
164
+ },
165
+ attachments: {
166
+ type: "array",
167
+ description: "Attachments with base64 encoded content.",
168
+ items: {
169
+ type: "object",
170
+ properties: {
171
+ filename: { type: "string" },
172
+ content: { type: "string", description: "Base64 content." },
173
+ contentType: { type: "string" },
174
+ cid: { type: "string" },
175
+ contentDisposition: { type: "string" },
176
+ },
177
+ required: ["filename", "content"],
178
+ },
179
+ },
180
+ },
181
+ required: ["subject", "body"],
182
+ },
183
+ },
184
+ {
185
+ name: "create_reply_draft",
186
+ description: "Create a reply draft for a specific email, pre-filling To, Subject, and quoted body from the original message. Use when you have an emailId and want to stage the reply for review before sending. Prefer create_thread_reply_draft when you only have a threadId. Prefer reply_to_email to send immediately. Returns a draftId.",
187
+ inputSchema: {
188
+ type: "object",
189
+ properties: {
190
+ emailId: { type: "string", description: "Original email id." },
191
+ body: { type: "string", description: "Reply body to prepend." },
192
+ replyAll: { type: "boolean", description: "Reply to all original recipients.", default: false },
193
+ isHtml: { type: "boolean", description: "Store body as HTML.", default: false },
194
+ cc: { type: "string", description: "Additional CC recipients, comma-separated." },
195
+ bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
196
+ notes: { type: "string", description: "Optional local note for the draft." },
197
+ syncToRemote: {
198
+ type: "boolean",
199
+ description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
200
+ default: true,
201
+ },
202
+ attachments: {
203
+ type: "array",
204
+ description: "Attachments with base64 encoded content.",
205
+ items: {
206
+ type: "object",
207
+ properties: {
208
+ filename: { type: "string" },
209
+ content: { type: "string", description: "Base64 content." },
210
+ contentType: { type: "string" },
211
+ cid: { type: "string" },
212
+ contentDisposition: { type: "string" },
213
+ },
214
+ required: ["filename", "content"],
215
+ },
216
+ },
217
+ },
218
+ required: ["emailId", "body"],
219
+ },
220
+ },
221
+ {
222
+ name: "create_forward_draft",
223
+ description: "Create a forward draft for an existing email, pre-filling the original message as quoted body. Use when you have an emailId and want to stage a forward for review before sending. Prefer forward_email to send immediately without saving. Returns a draftId for later update or send via send_draft.",
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: {
227
+ emailId: { type: "string", description: "Original email id." },
228
+ to: { type: "string", description: "Forward recipient list, comma-separated." },
229
+ body: { type: "string", description: "Optional message before the forwarded content." },
230
+ isHtml: { type: "boolean", description: "Store body as HTML.", default: false },
231
+ cc: { type: "string", description: "CC recipients, comma-separated." },
232
+ bcc: { type: "string", description: "BCC recipients, comma-separated." },
233
+ notes: { type: "string", description: "Optional local note for the draft." },
234
+ syncToRemote: {
235
+ type: "boolean",
236
+ description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
237
+ default: true,
238
+ },
239
+ attachments: {
240
+ type: "array",
241
+ description: "Attachments with base64 encoded content.",
242
+ items: {
243
+ type: "object",
244
+ properties: {
245
+ filename: { type: "string" },
246
+ content: { type: "string", description: "Base64 content." },
247
+ contentType: { type: "string" },
248
+ cid: { type: "string" },
249
+ contentDisposition: { type: "string" },
250
+ },
251
+ required: ["filename", "content"],
252
+ },
253
+ },
254
+ },
255
+ required: ["emailId", "to"],
256
+ },
257
+ },
258
+ {
259
+ name: "list_drafts",
260
+ description: "List all locally saved drafts with their status, subject, and timestamps. Use to review in-progress or unsent messages. Does NOT list drafts stored only on the Proton server — use list_remote_drafts for those. Prefer get_draft when you already have a draftId and need the full content.",
261
+ inputSchema: {
262
+ type: "object",
263
+ properties: {
264
+ includeSent: { type: "boolean", description: "Include drafts already sent.", default: false },
265
+ },
266
+ },
267
+ },
268
+ {
269
+ name: "list_remote_drafts",
270
+ description: "List draft messages currently stored in the Proton Drafts IMAP folder on the server. Use to see drafts created via Proton webmail or mobile app that have not been synced locally. Prefer list_drafts to see drafts managed by this server. Requires an active IMAP connection.",
271
+ inputSchema: {
272
+ type: "object",
273
+ properties: {
274
+ limit: { type: "number", description: "Maximum drafts to return.", default: 50 },
275
+ offset: { type: "number", description: "Pagination offset.", default: 0 },
276
+ },
277
+ },
278
+ },
279
+ {
280
+ name: "get_draft",
281
+ description: "Fetch the full content of a single locally saved draft by its draftId. Use to read or verify a draft before sending or updating. Prefer list_drafts to discover draftIds first. Does NOT fetch drafts from the Proton server — use list_remote_drafts for those.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {
285
+ draftId: { type: "string", description: "Draft id returned by create_draft, list_drafts, or a create_*_draft call." },
286
+ },
287
+ required: ["draftId"],
288
+ },
289
+ },
290
+ {
291
+ name: "update_draft",
292
+ description: "Update an existing locally saved draft's recipients, subject, body, or other fields. Use to edit a draft before sending. Only provided fields are updated — omitted fields retain their current values. After updating, call send_draft to send or sync_draft_to_remote to push to Proton Drafts.",
293
+ inputSchema: {
294
+ type: "object",
295
+ properties: {
296
+ draftId: { type: "string", description: "Draft id returned by create_draft, list_drafts, or a create_*_draft call." },
297
+ to: { type: "string", description: "Recipient email addresses, comma-separated." },
298
+ cc: { type: "string", description: "CC recipient email addresses, comma-separated." },
299
+ bcc: { type: "string", description: "BCC recipient email addresses, comma-separated." },
300
+ subject: { type: "string", description: "Draft subject." },
301
+ body: { type: "string", description: "Draft body." },
302
+ isHtml: { type: "boolean", description: "Whether the body should be HTML." },
303
+ priority: { type: "string", enum: ["high", "normal", "low"] },
304
+ replyTo: { type: "string", description: "Optional reply-to email address." },
305
+ notes: { type: "string", description: "Optional local note for the draft." },
306
+ syncToRemote: {
307
+ type: "boolean",
308
+ description: "Whether to sync the updated draft to the Proton Drafts mailbox when IMAP is available.",
309
+ default: true,
310
+ },
311
+ attachments: {
312
+ type: "array",
313
+ description: "Attachments with base64 encoded content.",
314
+ items: {
315
+ type: "object",
316
+ properties: {
317
+ filename: { type: "string" },
318
+ content: { type: "string", description: "Base64 content." },
319
+ contentType: { type: "string" },
320
+ cid: { type: "string" },
321
+ contentDisposition: { type: "string" },
322
+ },
323
+ required: ["filename", "content"],
324
+ },
325
+ },
326
+ },
327
+ required: ["draftId"],
328
+ },
329
+ },
330
+ {
331
+ name: "sync_draft_to_remote",
332
+ description: "Force-push a locally saved draft to the Proton Drafts IMAP folder and return the remote UID. Use when a draft was created with syncToRemote:false or when the automatic sync failed. Do NOT use this if PROTONMAIL_ALLOW_REMOTE_DRAFT_SYNC is false — the call will be rejected.",
333
+ inputSchema: {
334
+ type: "object",
335
+ properties: {
336
+ draftId: { type: "string", description: "Draft id returned by create_draft, list_drafts, or a create_*_draft call." },
337
+ },
338
+ required: ["draftId"],
339
+ },
340
+ },
341
+ {
342
+ name: "send_draft",
343
+ description: "Send a previously saved local draft through Proton Bridge SMTP. Use as the final step in a draft-review-send workflow after create_draft and optional update_draft. Marks the draft as sent in the local store but does not delete it. Requires PROTONMAIL_ALLOW_SEND.",
344
+ inputSchema: {
345
+ type: "object",
346
+ properties: {
347
+ draftId: { type: "string", description: "Draft id returned by create_draft, list_drafts, or a create_*_draft call." },
348
+ confirmed: { type: "boolean", description: "Set to true to confirm this irreversible send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
349
+ },
350
+ required: ["draftId"],
351
+ },
352
+ },
353
+ {
354
+ name: "delete_draft",
355
+ description: "Permanently delete a locally saved draft from SQLite. Use to discard a draft you no longer need. Does NOT remove a matching draft from the Proton Drafts IMAP folder — that requires a separate mailbox action. Irreversible.",
356
+ inputSchema: {
357
+ type: "object",
358
+ properties: {
359
+ draftId: { type: "string", description: "Draft id returned by create_draft, list_drafts, or a create_*_draft call." },
360
+ },
361
+ required: ["draftId"],
362
+ },
363
+ },
364
+ {
365
+ name: "get_emails",
366
+ description: "Fetch emails from a mailbox folder via live IMAP, returned newest first. Use to browse or paginate recent messages in a specific folder. Prefer search_emails to filter by sender, subject, or date. Prefer search_indexed_emails for fast repeated queries when the local SQLite index is populated and Bridge availability is uncertain.",
367
+ inputSchema: {
368
+ type: "object",
369
+ properties: {
370
+ folder: { type: "string", description: "Folder name.", default: "INBOX" },
371
+ limit: { type: "number", description: "Number of emails to return.", default: 50 },
372
+ offset: { type: "number", description: "Pagination offset from newest first.", default: 0 },
373
+ },
374
+ },
375
+ },
376
+ {
377
+ name: "get_email_by_id",
378
+ description: "Fetch the full content of a single email using a composite emailId. Use after get_emails or search_emails to read a specific message in full. The emailId format is FOLDER::UID — always use the id returned by a prior tool call; do not construct it manually.",
379
+ inputSchema: {
380
+ type: "object",
381
+ properties: {
382
+ emailId: { type: "string", description: "Composite email id from previous tool output." },
383
+ },
384
+ required: ["emailId"],
385
+ },
386
+ },
387
+ {
388
+ name: "search_emails",
389
+ description: "Search emails via live IMAP filters with optional local post-processing for attachments and labels. Use when you need real-time results or must search messages not yet in the local index. Prefer search_indexed_emails when the index is populated — it is significantly faster and works even when Bridge IMAP is unavailable.",
390
+ inputSchema: {
391
+ type: "object",
392
+ properties: {
393
+ query: { type: "string", description: "Free-text query across headers and body." },
394
+ folder: { type: "string", description: "Folder to search. Defaults to all folders." },
395
+ label: { type: "string", description: "Folder or label filter applied locally after IMAP fetch." },
396
+ threadId: { type: "string", description: "Thread id filter applied locally after IMAP fetch." },
397
+ from: { type: "string", description: "Sender filter." },
398
+ to: { type: "string", description: "Recipient filter." },
399
+ subject: { type: "string", description: "Subject filter." },
400
+ hasAttachment: { type: "boolean", description: "Whether the message should have attachments." },
401
+ attachmentName: { type: "string", description: "Attachment filename filter applied locally." },
402
+ isRead: { type: "boolean", description: "Read status filter." },
403
+ isStarred: { type: "boolean", description: "Starred status filter." },
404
+ dateFrom: { type: "string", description: "Inclusive start date/time in ISO format." },
405
+ dateTo: { type: "string", description: "Inclusive end date/time in ISO format." },
406
+ limit: { type: "number", description: "Maximum results.", default: 100 },
407
+ },
408
+ },
409
+ },
410
+ {
411
+ name: "get_folders",
412
+ description: "Return all mailbox folders with message counts and unseen counts from the live IMAP session. Use to discover available folder names before targeting get_emails, move_email, or create_folder. Prefer sync_folders to force a fresh fetch when the folder list appears stale.",
413
+ inputSchema: { type: "object", properties: {} },
414
+ },
415
+ {
416
+ name: "sync_folders",
417
+ description: "Refresh the in-memory folder list from the IMAP server and return the updated list. Use when folders have been created, renamed, or deleted externally (e.g. via Proton webmail) and get_folders is returning stale data. Prefer get_folders for a read-only view that does not force a refresh.",
418
+ inputSchema: { type: "object", properties: {} },
419
+ },
420
+ {
421
+ name: "create_folder",
422
+ description: "Create a new mailbox folder via IMAP. Use 'Folders/' prefix for user folders and 'Labels/' for labels in Proton Bridge (e.g. 'Folders/Receipts'). Do NOT attempt to create system folders such as INBOX, Sent, Trash, Archive, or Spam. Returns the created path on success.",
423
+ inputSchema: {
424
+ type: "object",
425
+ properties: {
426
+ path: {
427
+ type: "string",
428
+ description: "Full mailbox path. In Proton Bridge, user folders live under 'Folders/' and labels under 'Labels/'.",
429
+ },
430
+ },
431
+ required: ["path"],
432
+ },
433
+ },
434
+ {
435
+ name: "rename_folder",
436
+ description: "Rename or move a mailbox folder to a new IMAP path. Existing messages are preserved in place. Do NOT rename system folders (INBOX, Sent, Trash, Archive, Spam). Refreshes the local folder cache after the operation.",
437
+ inputSchema: {
438
+ type: "object",
439
+ properties: {
440
+ path: { type: "string", description: "Existing folder path." },
441
+ newPath: { type: "string", description: "New folder path." },
442
+ },
443
+ required: ["path", "newPath"],
444
+ },
445
+ },
446
+ {
447
+ name: "delete_folder",
448
+ description: "Delete an empty mailbox folder via IMAP. The folder must contain no messages — move or trash all messages first. Do NOT delete system folders (INBOX, Sent, Trash, Archive, Spam). Irreversible; messages already removed cannot be recovered this way.",
449
+ inputSchema: {
450
+ type: "object",
451
+ properties: {
452
+ path: { type: "string", description: "Folder path to delete." },
453
+ },
454
+ required: ["path"],
455
+ },
456
+ },
457
+ {
458
+ name: "mark_email_read",
459
+ description: "Mark a single email as read or unread by setting the IMAP Seen flag. Use for individual triage or to reset read state. Prefer batch_email_action with action 'mark_read' or 'mark_unread' when updating multiple emails at once.",
460
+ inputSchema: {
461
+ type: "object",
462
+ properties: {
463
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
464
+ isRead: { type: "boolean", default: true },
465
+ },
466
+ required: ["emailId"],
467
+ },
468
+ },
469
+ {
470
+ name: "star_email",
471
+ description: "Star or unstar a single email using the IMAP Flagged flag. Use to bookmark an important message for later follow-up. Prefer batch_email_action with action 'star' or 'unstar' when flagging multiple emails at once.",
472
+ inputSchema: {
473
+ type: "object",
474
+ properties: {
475
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
476
+ isStarred: { type: "boolean", default: true },
477
+ },
478
+ required: ["emailId"],
479
+ },
480
+ },
481
+ {
482
+ name: "move_email",
483
+ description: "Move a single email to any specified mailbox folder. Use when routing a message to a custom folder. Prefer archive_email to move to the standard Archive folder, or trash_email to move to Trash. Use get_folders first to confirm the target folder path.",
484
+ inputSchema: {
485
+ type: "object",
486
+ properties: {
487
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
488
+ targetFolder: { type: "string" },
489
+ },
490
+ required: ["emailId", "targetFolder"],
491
+ },
492
+ },
493
+ {
494
+ name: "archive_email",
495
+ description: "Move a single email to the standard Archive folder. Use for messages that are resolved but worth keeping long-term. Prefer trash_email when the message is no longer needed. Prefer move_email to route to a custom folder. Prefer batch_email_action for archiving multiple emails at once.",
496
+ inputSchema: {
497
+ type: "object",
498
+ properties: {
499
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
500
+ },
501
+ required: ["emailId"],
502
+ },
503
+ },
504
+ {
505
+ name: "trash_email",
506
+ description: "Move a single email to the Trash folder. Messages in Trash can be recovered with restore_email. Use instead of delete_email when you may want to recover the message later. Prefer batch_email_action with action 'trash' for multiple emails at once.",
507
+ inputSchema: {
508
+ type: "object",
509
+ properties: {
510
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
511
+ },
512
+ required: ["emailId"],
513
+ },
514
+ },
515
+ {
516
+ name: "restore_email",
517
+ description: "Move an email from Trash back to INBOX or to a specified folder. Use to undo a trash_email operation. Does not work on permanently deleted messages — only messages currently in Trash can be restored.",
518
+ inputSchema: {
519
+ type: "object",
520
+ properties: {
521
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
522
+ targetFolder: { type: "string", description: "Optional restore destination. Defaults to INBOX." },
523
+ },
524
+ required: ["emailId"],
525
+ },
526
+ },
527
+ {
528
+ name: "delete_email",
529
+ description: "Permanently delete an email from its current folder via IMAP expunge. Use only when certain the message is no longer needed. Prefer trash_email if recovery may be required. Irreversible — the message cannot be recovered after deletion.",
530
+ inputSchema: {
531
+ type: "object",
532
+ properties: {
533
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
534
+ confirmed: { type: "boolean", description: "Set to true to confirm this permanent deletion when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled. Cannot be undone." },
535
+ },
536
+ required: ["emailId"],
537
+ },
538
+ },
539
+ {
540
+ name: "batch_email_action",
541
+ description: "Apply a reversible mailbox action to multiple emails in a single IMAP call. Use when you have a set of emailIds to act on at once (mark_read, mark_unread, star, unstar, archive, trash, restore). Supports dryRun to preview impact before mutating. Prefer apply_thread_action when acting by threadId rather than individual email ids.",
542
+ inputSchema: {
543
+ type: "object",
544
+ properties: {
545
+ emailIds: {
546
+ oneOf: [
547
+ {
548
+ type: "array",
549
+ items: { type: "string" },
550
+ },
551
+ {
552
+ type: "string",
553
+ },
554
+ ],
555
+ description: "Composite email ids as an array or a comma-separated string.",
556
+ },
557
+ action: {
558
+ type: "string",
559
+ enum: ["mark_read", "mark_unread", "star", "unstar", "archive", "trash", "restore"],
560
+ },
561
+ targetFolder: {
562
+ type: "string",
563
+ description: "Optional restore destination. Used only when action is restore.",
564
+ },
565
+ continueOnError: {
566
+ type: "boolean",
567
+ description: "Continue applying the action after an individual failure.",
568
+ default: true,
569
+ },
570
+ dryRun: {
571
+ type: "boolean",
572
+ description: "Preview the impact without mutating the mailbox.",
573
+ default: false,
574
+ },
575
+ },
576
+ required: ["emailIds", "action"],
577
+ },
578
+ },
579
+ {
580
+ name: "apply_thread_action",
581
+ description: "Apply a reversible mailbox action to every message in a normalized thread at once. Use when you want to act on a full thread identified by threadId (e.g. archive or mark-read an entire conversation). Supports dryRun, unreadOnly to scope impact, and syncBefore to refresh the index first. Prefer batch_email_action when you have explicit emailIds rather than a threadId.",
582
+ inputSchema: {
583
+ type: "object",
584
+ properties: {
585
+ threadId: { type: "string", description: "Thread id from get_threads or get_actionable_threads." },
586
+ action: {
587
+ type: "string",
588
+ enum: ["mark_read", "mark_unread", "star", "unstar", "archive", "trash", "restore"],
589
+ },
590
+ targetFolder: {
591
+ type: "string",
592
+ description: "Optional restore destination. Used only when action is restore.",
593
+ },
594
+ unreadOnly: {
595
+ type: "boolean",
596
+ description: "Only apply the action to unread messages in the thread.",
597
+ default: false,
598
+ },
599
+ continueOnError: {
600
+ type: "boolean",
601
+ description: "Continue applying the action after an individual failure.",
602
+ default: true,
603
+ },
604
+ dryRun: {
605
+ type: "boolean",
606
+ description: "Preview the impact without mutating the mailbox.",
607
+ default: false,
608
+ },
609
+ syncBefore: {
610
+ type: "boolean",
611
+ description: "Refresh the local mailbox index from IMAP before resolving the thread.",
612
+ default: false,
613
+ },
614
+ },
615
+ required: ["threadId", "action"],
616
+ },
617
+ },
618
+ {
619
+ name: "get_email_stats",
620
+ description: "Return aggregate mailbox statistics: folder message counts, total unread counts, and a brief analytics sample. Use for a quick mailbox health overview. Prefer get_email_analytics for richer breakdowns such as top senders and hourly patterns. Prefer get_volume_trends for time-series daily volume data.",
621
+ inputSchema: { type: "object", properties: {} },
622
+ },
623
+ {
624
+ name: "get_email_analytics",
625
+ description: "Generate sampled mailbox analytics including top senders, busiest hours of day, and volume breakdown by folder. Use for productivity insights and communication pattern analysis. Prefer get_email_stats for a fast aggregate count summary. Prefer get_volume_trends for per-day message volume history.",
626
+ inputSchema: { type: "object", properties: {} },
627
+ },
628
+ {
629
+ name: "get_contacts",
630
+ description: "Return the most frequently contacted email addresses ranked by interaction volume within the analytics sample window. Use to identify key correspondents or to pre-populate recipient lists. Requires the local mailbox index to be populated — call sync_emails first if the index is empty.",
631
+ inputSchema: {
632
+ type: "object",
633
+ properties: {
634
+ limit: { type: "number", description: "Maximum contacts to return.", default: 100 },
635
+ },
636
+ },
637
+ },
638
+ {
639
+ name: "get_volume_trends",
640
+ description: "Return daily inbound and outbound message counts for a trailing window. Use to spot volume spikes, identify quiet periods, or track communication trends over time. Prefer get_email_analytics for sender-level breakdowns and hourly patterns.",
641
+ inputSchema: {
642
+ type: "object",
643
+ properties: {
644
+ days: { type: "number", description: "Number of trailing days to include.", default: 30 },
645
+ },
646
+ },
647
+ },
648
+ {
649
+ name: "get_connection_status",
650
+ description: "Check whether Proton Bridge SMTP and IMAP are reachable and return authentication status for each. Use to diagnose connectivity before sending or syncing, or when tools return connection errors. Returns individual pass/fail for each protocol. Prefer run_doctor for a full end-to-end health check including index integrity.",
651
+ inputSchema: { type: "object", properties: {} },
652
+ },
653
+ {
654
+ name: "get_runtime_status",
655
+ description: "Return the server's current runtime state: policy flags (read-only, allow-send, allowed actions), background sync schedule and last-run time, IMAP IDLE watch state, draft store statistics, and local index freshness. Use to understand how the server is configured and whether sync is actively running. Prefer get_connection_status for protocol reachability only.",
656
+ inputSchema: { type: "object", properties: {} },
657
+ },
658
+ {
659
+ name: "run_doctor",
660
+ description: "Run a comprehensive production health check covering SMTP auth, IMAP auth, optional IMAP IDLE probe, SQLite index integrity, and runtime policy validation. Use to fully diagnose or validate the setup. Prefer get_connection_status for a quick protocol-only reachability check.",
661
+ inputSchema: {
662
+ type: "object",
663
+ properties: {
664
+ includeSmtp: { type: "boolean", description: "Verify SMTP connectivity.", default: true },
665
+ includeImap: { type: "boolean", description: "Verify IMAP connectivity.", default: true },
666
+ includeIdleProbe: {
667
+ type: "boolean",
668
+ description: "Run a short IMAP IDLE wait to confirm the watch path is operational.",
669
+ default: false,
670
+ },
671
+ idleTimeoutSeconds: {
672
+ type: "number",
673
+ description: "IDLE probe timeout in seconds when includeIdleProbe is true.",
674
+ default: 5,
675
+ },
676
+ },
677
+ },
678
+ },
679
+ {
680
+ name: "run_background_sync",
681
+ description: "Immediately trigger the configured background mailbox sync cycle outside its normal schedule and return its updated status. Use to force a sync when the index may be stale. Does nothing useful if PROTONMAIL_AUTO_SYNC is disabled. Prefer sync_emails for an on-demand, configurable sync with folder and depth options.",
682
+ inputSchema: { type: "object", properties: {} },
683
+ },
684
+ {
685
+ name: "wait_for_mailbox_changes",
686
+ description: "Open an IMAP IDLE session and block until a mailbox change event arrives or the timeout expires. Use to detect real-time inbox activity without polling. Returns whether a change was observed. Always has a hard timeout (default 15s) to avoid blocking indefinitely — do not use in fire-and-forget pipelines.",
687
+ inputSchema: {
688
+ type: "object",
689
+ properties: {
690
+ folder: { type: "string", description: "Mailbox to watch during IDLE.", default: "INBOX" },
691
+ timeoutSeconds: { type: "number", description: "Maximum watch duration in seconds.", default: 15 },
692
+ },
693
+ },
694
+ },
695
+ {
696
+ name: "sync_emails",
697
+ description: "Incrementally sync email metadata from IMAP into the local SQLite index, using stored checkpoints to avoid re-fetching already-indexed messages. Use before calling search_indexed_emails or get_threads when the index may be stale. Set full:true for a larger initial sample. Prefer run_background_sync to trigger the scheduled sync cycle.",
698
+ inputSchema: {
699
+ type: "object",
700
+ properties: {
701
+ folder: { type: "string", description: "Folder to sync. Defaults to all folders." },
702
+ full: { type: "boolean", description: "Fetch a larger per-folder sample.", default: false },
703
+ limitPerFolder: { type: "number", description: "Override the per-folder fetch limit." },
704
+ includeAttachmentText: {
705
+ type: "boolean",
706
+ description: "Extract searchable text from text-like attachments while syncing.",
707
+ default: true,
708
+ },
709
+ },
710
+ },
711
+ },
712
+ {
713
+ name: "get_index_status",
714
+ description: "Return metadata about the local SQLite email index: row count, last sync timestamp, index schema version, and per-folder coverage. Use to verify the index is fresh and complete before querying it with search_indexed_emails or get_threads. If the index is empty or stale, call sync_emails first.",
715
+ inputSchema: { type: "object", properties: {} },
716
+ },
717
+ {
718
+ name: "search_indexed_emails",
719
+ description: "Search the local SQLite mailbox index without making any IMAP connection. Supports free-text and field shortcuts inline: from:alice@example.com, to:bob, subject:invoice, label:Archive, domain:acme.com. Use for fast, offline-capable searches when the index is populated. Prefer search_emails when you need live IMAP results or when the index is stale or empty.",
720
+ inputSchema: {
721
+ type: "object",
722
+ properties: {
723
+ query: { type: "string", description: "Free-text query across indexed metadata." },
724
+ folder: { type: "string", description: "Folder filter." },
725
+ label: { type: "string", description: "Folder or label filter." },
726
+ threadId: { type: "string", description: "Thread id filter." },
727
+ from: { type: "string", description: "Sender filter." },
728
+ to: { type: "string", description: "Recipient filter." },
729
+ senderDomain: { type: "string", description: "Sender domain filter such as example.com." },
730
+ subject: { type: "string", description: "Subject filter." },
731
+ hasAttachment: { type: "boolean", description: "Attachment filter." },
732
+ attachmentName: { type: "string", description: "Attachment filename filter." },
733
+ isRead: { type: "boolean", description: "Read status filter." },
734
+ isStarred: { type: "boolean", description: "Starred status filter." },
735
+ mailboxRole: { type: "string", description: "Normalized mailbox role like Inbox, Sent, Archive, or Trash." },
736
+ dateFrom: { type: "string", description: "Inclusive start date/time in ISO format." },
737
+ dateTo: { type: "string", description: "Inclusive end date/time in ISO format." },
738
+ limit: { type: "number", description: "Maximum results.", default: 100 },
739
+ },
740
+ },
741
+ },
742
+ {
743
+ name: "get_labels",
744
+ description: "Return normalized Proton folders and labels from the local mailbox index, including message counts per label. Use to enumerate available labels before filtering with search_indexed_emails or get_threads. Prefer get_folders for live IMAP folder counts when the index may be stale.",
745
+ inputSchema: {
746
+ type: "object",
747
+ properties: {
748
+ limit: { type: "number", description: "Maximum labels to return.", default: 250 },
749
+ },
750
+ },
751
+ },
752
+ {
753
+ name: "get_threads",
754
+ description: "Return normalized email threads from the local mailbox index, grouping individual messages into conversations by subject and participants. Use to view mail as threads rather than individual messages. Prefer get_actionable_threads when you want threads prioritized by reply urgency. Prefer get_inbox_digest for an executive-summary view.",
755
+ inputSchema: {
756
+ type: "object",
757
+ properties: {
758
+ query: { type: "string", description: "Free-text filter across subject, participants, and labels." },
759
+ label: { type: "string", description: "Require a normalized label on the thread." },
760
+ limit: { type: "number", description: "Maximum threads to return.", default: 100 },
761
+ },
762
+ },
763
+ },
764
+ {
765
+ name: "get_actionable_threads",
766
+ description: "Return mailbox threads ranked by reply urgency, filtered to those requiring action. Use for daily triage to surface what needs a response from you. Supports pendingOn filter to distinguish threads waiting on you vs. them. Prefer get_inbox_digest for a broader summary including stale items. Prefer get_threads for an unranked thread list.",
767
+ inputSchema: {
768
+ type: "object",
769
+ properties: {
770
+ query: { type: "string", description: "Free-text filter across subject, latest preview, senders, and labels." },
771
+ label: { type: "string", description: "Require a normalized label on the thread." },
772
+ pendingOn: {
773
+ type: "string",
774
+ enum: ["you", "them", "any"],
775
+ description: "Filter by who the thread is currently waiting on.",
776
+ default: "any",
777
+ },
778
+ unreadOnly: {
779
+ type: "boolean",
780
+ description: "Prefer threads with unread messages only.",
781
+ default: true,
782
+ },
783
+ limit: { type: "number", description: "Maximum threads to return.", default: 50 },
784
+ syncBefore: {
785
+ type: "boolean",
786
+ description: "Refresh the local mailbox index from IMAP before ranking threads.",
787
+ default: false,
788
+ },
789
+ },
790
+ },
791
+ },
792
+ {
793
+ name: "get_inbox_digest",
794
+ description: "Return a structured inbox summary: unread counts, top actionable threads, and overdue threads where a reply is pending from you. Use as the starting point for an inbox review session to get an at-a-glance picture. Prefer get_actionable_threads for a deeper, filterable list of threads needing action.",
795
+ inputSchema: {
796
+ type: "object",
797
+ properties: {
798
+ limit: { type: "number", description: "Maximum threads per digest section.", default: 10 },
799
+ minAgeHours: {
800
+ type: "number",
801
+ description: "How old a thread must be before it is considered stale waiting on you.",
802
+ default: 24,
803
+ },
804
+ syncBefore: {
805
+ type: "boolean",
806
+ description: "Refresh the local mailbox index from IMAP before building the digest.",
807
+ default: false,
808
+ },
809
+ },
810
+ },
811
+ },
812
+ {
813
+ name: "get_follow_up_candidates",
814
+ description: "Return threads that appear overdue for follow-up based on age and pending-on state. Use when looking for outbound messages you sent that haven't received a reply, or to surface stale inbound threads. Prefer get_actionable_threads for threads where someone is currently waiting on you.",
815
+ inputSchema: {
816
+ type: "object",
817
+ properties: {
818
+ limit: { type: "number", description: "Maximum candidate threads to return.", default: 25 },
819
+ minAgeHours: { type: "number", description: "Minimum thread age in hours.", default: 24 },
820
+ pendingOn: {
821
+ type: "string",
822
+ enum: ["you", "them", "any"],
823
+ description: "Which side the candidate thread should be waiting on.",
824
+ default: "you",
825
+ },
826
+ syncBefore: {
827
+ type: "boolean",
828
+ description: "Refresh the local mailbox index from IMAP before selecting candidates.",
829
+ default: false,
830
+ },
831
+ },
832
+ },
833
+ },
834
+ {
835
+ name: "find_document_threads",
836
+ description: "Find email threads likely containing important document attachments such as invoices, contracts, travel confirmations, or calendar invites. Use to locate attachment-heavy threads by category without knowing the exact sender or subject. Prefer search_indexed_emails with hasAttachment:true for custom attachment queries beyond the built-in categories.",
837
+ inputSchema: {
838
+ type: "object",
839
+ properties: {
840
+ category: {
841
+ type: "string",
842
+ enum: ["document", "invoice", "contract", "travel", "calendar"],
843
+ description: "Document category to prioritize.",
844
+ default: "document",
845
+ },
846
+ query: { type: "string", description: "Optional filter across thread subjects and attachment names." },
847
+ limit: { type: "number", description: "Maximum threads to return.", default: 25 },
848
+ syncBefore: {
849
+ type: "boolean",
850
+ description: "Refresh the local mailbox index from IMAP before searching document threads.",
851
+ default: false,
852
+ },
853
+ },
854
+ },
855
+ },
856
+ {
857
+ name: "prepare_meeting_context",
858
+ description: "Fetch recent threads and communication history for a person or company domain to prepare for a meeting or call. Use before a scheduled meeting to surface relevant recent correspondence. Provide at least one of person (name or email fragment) or domain. Returns matched threads sorted by recency.",
859
+ inputSchema: {
860
+ type: "object",
861
+ properties: {
862
+ person: { type: "string", description: "Person name or email fragment to match." },
863
+ domain: { type: "string", description: "Domain to match, such as example.com." },
864
+ limit: { type: "number", description: "Maximum threads to include.", default: 10 },
865
+ syncBefore: {
866
+ type: "boolean",
867
+ description: "Refresh the local mailbox index from IMAP before building the meeting prep.",
868
+ default: false,
869
+ },
870
+ },
871
+ },
872
+ },
873
+ {
874
+ name: "get_thread_brief",
875
+ description: "Return a summarized view of a single thread: latest inbound message preview, latest outbound preview, attachment list, and a recommended next action. Use for a quick status check on a specific thread without reading every message. Prefer get_thread_by_id when you need the full raw thread data and all messages.",
876
+ inputSchema: {
877
+ type: "object",
878
+ properties: {
879
+ threadId: { type: "string", description: "Thread id from get_threads or get_actionable_threads." },
880
+ },
881
+ required: ["threadId"],
882
+ },
883
+ },
884
+ {
885
+ name: "get_thread_by_id",
886
+ description: "Fetch the complete normalized thread record from the local index, including all messages, participants, labels, and full metadata. Use when you need all messages in a thread. Prefer get_thread_brief for a summarized quick view that avoids returning the full message list.",
887
+ inputSchema: {
888
+ type: "object",
889
+ properties: {
890
+ threadId: { type: "string", description: "Thread id from get_threads." },
891
+ },
892
+ required: ["threadId"],
893
+ },
894
+ },
895
+ {
896
+ name: "create_thread_reply_draft",
897
+ description: "Create a reply draft from a threadId, automatically selecting the latest inbound message to reply to. Use when you have a threadId from get_threads or get_actionable_threads and want to stage a reply for review. Prefer create_reply_draft when you already have a specific emailId. Returns a draftId for later update or send.",
898
+ inputSchema: {
899
+ type: "object",
900
+ properties: {
901
+ threadId: { type: "string", description: "Thread id from get_threads or get_actionable_threads." },
902
+ body: { type: "string", description: "Reply body to prepend." },
903
+ replyAll: { type: "boolean", description: "Reply to all original recipients.", default: false },
904
+ preferLatestInbound: {
905
+ type: "boolean",
906
+ description: "Prefer replying to the latest inbound message in the thread.",
907
+ default: true,
908
+ },
909
+ isHtml: { type: "boolean", description: "Store body as HTML.", default: false },
910
+ cc: { type: "string", description: "Additional CC recipients, comma-separated." },
911
+ bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
912
+ notes: { type: "string", description: "Optional local note for the draft." },
913
+ syncBefore: {
914
+ type: "boolean",
915
+ description: "Refresh the local mailbox index from IMAP before resolving the thread.",
916
+ default: false,
917
+ },
918
+ syncToRemote: {
919
+ type: "boolean",
920
+ description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
921
+ default: true,
922
+ },
923
+ attachments: {
924
+ type: "array",
925
+ description: "Attachments with base64 encoded content.",
926
+ items: {
927
+ type: "object",
928
+ properties: {
929
+ filename: { type: "string" },
930
+ content: { type: "string", description: "Base64 content." },
931
+ contentType: { type: "string" },
932
+ cid: { type: "string" },
933
+ contentDisposition: { type: "string" },
934
+ },
935
+ required: ["filename", "content"],
936
+ },
937
+ },
938
+ },
939
+ required: ["threadId", "body"],
940
+ },
941
+ },
942
+ {
943
+ name: "list_attachments",
944
+ description: "List all attachments on a specific email with stable attachmentIds, filenames, content types, and sizes. Use before calling get_attachment_content or save_attachment to discover what attachments are available and get their IDs. Prefer save_attachments when you want to download all attachments at once.",
945
+ inputSchema: {
946
+ type: "object",
947
+ properties: {
948
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
949
+ includeInline: { type: "boolean", description: "Include inline attachments.", default: true },
950
+ filenameContains: { type: "string", description: "Optional filename substring filter." },
951
+ contentType: { type: "string", description: "Optional exact content type filter." },
952
+ },
953
+ required: ["emailId"],
954
+ },
955
+ },
956
+ {
957
+ name: "get_attachment_content",
958
+ description: "Fetch metadata for a specific email attachment and optionally return its base64-encoded content inline. Use when you need to read or process attachment data in-memory. Set includeBase64:false (default) to retrieve metadata only without loading the full payload. Prefer save_attachment to write the file to disk instead.",
959
+ inputSchema: {
960
+ type: "object",
961
+ properties: {
962
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
963
+ attachmentId: { type: "string", description: "Stable attachment id returned by list_attachments." },
964
+ includeBase64: { type: "boolean", description: "Include base64 payload in the response.", default: false },
965
+ },
966
+ required: ["emailId", "attachmentId"],
967
+ },
968
+ },
969
+ {
970
+ name: "save_attachments",
971
+ description: "Save all qualifying attachments from an email to a directory on disk, with optional filename substring or content-type filters. Use to batch-download attachments from a single email. Returns the list of written file paths. Prefer save_attachment when you need to save one specific attachment by its attachmentId.",
972
+ inputSchema: {
973
+ type: "object",
974
+ properties: {
975
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
976
+ outputPath: { type: "string", description: "Optional target directory or file path." },
977
+ includeInline: { type: "boolean", description: "Include inline attachments.", default: false },
978
+ filenameContains: { type: "string", description: "Optional filename substring filter." },
979
+ contentType: { type: "string", description: "Optional exact content type filter." },
980
+ },
981
+ required: ["emailId"],
982
+ },
983
+ },
984
+ {
985
+ name: "save_attachment",
986
+ description: "Save a single email attachment to disk by its attachmentId and return the written file path. Use when you have a specific attachmentId from list_attachments and want to write that file. Prefer save_attachments to save all or filtered attachments from an email without needing individual attachment IDs.",
987
+ inputSchema: {
988
+ type: "object",
989
+ properties: {
990
+ emailId: { type: "string", description: "Composite email id in FOLDER::UID format, as returned by get_emails or search_emails." },
991
+ attachmentId: { type: "string", description: "Stable attachment id returned by list_attachments." },
992
+ outputPath: { type: "string", description: "Optional file or directory path to write to." },
993
+ },
994
+ required: ["emailId", "attachmentId"],
995
+ },
996
+ },
997
+ {
998
+ name: "clear_cache",
999
+ description: "Evict all in-memory caches: folder list, message metadata, and analytics data. Use when cached data appears stale after external mailbox changes (e.g. folders modified via Proton webmail). Does NOT affect the persistent SQLite index — use clear_index for that.",
1000
+ inputSchema: { type: "object", properties: {} },
1001
+ },
1002
+ {
1003
+ name: "clear_index",
1004
+ description: "Delete the entire persistent SQLite mailbox index from disk. Use only to reset a corrupted or schema-incompatible index. After clearing, call sync_emails to rebuild. Irreversible — all indexed metadata and search history is lost. Does NOT clear in-memory caches — use clear_cache for that.",
1005
+ inputSchema: { type: "object", properties: {} },
1006
+ },
1007
+ {
1008
+ name: "get_logs",
1009
+ description: "Return recent in-memory server log entries, filterable by level (debug, info, warn, error). Use to diagnose unexpected tool behavior or connection errors during the current session. Logs are ephemeral and not persisted across server restarts — use get_audit_logs for a persistent audit trail of write operations.",
1010
+ inputSchema: {
1011
+ type: "object",
1012
+ properties: {
1013
+ level: { type: "string", enum: ["debug", "info", "warn", "error"] },
1014
+ limit: { type: "number", default: 100 },
1015
+ },
1016
+ },
1017
+ },
1018
+ {
1019
+ name: "get_audit_logs",
1020
+ description: "Return recent entries from the persistent on-disk audit log of all write operations performed by this server. Use to review what mutations (sends, moves, deletes, draft operations) were executed across sessions. Prefer get_logs for debugging in-session behavior and transient connection errors.",
1021
+ inputSchema: {
1022
+ type: "object",
1023
+ properties: {
1024
+ limit: { type: "number", default: 100 },
1025
+ },
1026
+ },
1027
+ },
1028
+ ];
1029
+ function citationToResourceLink(source) {
1030
+ return {
1031
+ type: "resource_link",
1032
+ uri: source.uri,
1033
+ name: source.name,
1034
+ title: source.title,
1035
+ description: source.description,
1036
+ mimeType: source.mimeType,
1037
+ };
1038
+ }
1039
+ function normalizeStructuredContent(value) {
1040
+ const normalized = normalizeJsonValue(value);
1041
+ if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
1042
+ return undefined;
1043
+ }
1044
+ return normalized;
1045
+ }
1046
+ function withSources(value, sources) {
1047
+ if (sources.length === 0 || !value || typeof value !== "object" || Array.isArray(value)) {
1048
+ return value;
1049
+ }
1050
+ return {
1051
+ ...value,
1052
+ sources,
1053
+ };
1054
+ }
1055
+ function createTextResult(value, isError = false, sources = []) {
1056
+ const payload = withSources(value, sources);
1057
+ return {
1058
+ content: [
1059
+ { type: "text", text: typeof payload === "string" ? payload : stringifyForJson(payload) },
1060
+ ...sources.map(citationToResourceLink),
1061
+ ],
1062
+ structuredContent: normalizeStructuredContent(payload),
1063
+ ...(isError ? { isError: true } : {}),
1064
+ };
1065
+ }
1066
+ function sanitizeAuditValue(value) {
1067
+ if (value === undefined || value === null) {
1068
+ return value;
1069
+ }
1070
+ if (Array.isArray(value)) {
1071
+ return value.map((item) => sanitizeAuditValue(item));
1072
+ }
1073
+ if (typeof value === "string") {
1074
+ return value.length > 300 ? `[redacted:${value.length} chars]` : value;
1075
+ }
1076
+ if (typeof value === "object") {
1077
+ return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => {
1078
+ if (key === "body" ||
1079
+ key === "html" ||
1080
+ key === "text" ||
1081
+ key === "base64" ||
1082
+ key === "customMessage" ||
1083
+ /password|secret|token/i.test(key)) {
1084
+ return [key, "[redacted]"];
1085
+ }
1086
+ if (key === "attachments" && Array.isArray(entryValue)) {
1087
+ return [
1088
+ key,
1089
+ entryValue.map((attachment) => {
1090
+ const object = asObject(attachment);
1091
+ return {
1092
+ filename: object.filename,
1093
+ contentType: object.contentType,
1094
+ cid: object.cid,
1095
+ };
1096
+ }),
1097
+ ];
1098
+ }
1099
+ return [key, sanitizeAuditValue(entryValue)];
1100
+ }));
1101
+ }
1102
+ return value;
1103
+ }
1104
+ async function withAudit(auditService, tool, input, operation) {
1105
+ const startedAt = Date.now();
1106
+ try {
1107
+ const result = await operation();
1108
+ await auditService.record({
1109
+ timestamp: new Date().toISOString(),
1110
+ tool,
1111
+ status: "success",
1112
+ durationMs: Date.now() - startedAt,
1113
+ input: sanitizeAuditValue(input),
1114
+ result: sanitizeAuditValue(result),
1115
+ });
1116
+ return result;
1117
+ }
1118
+ catch (error) {
1119
+ await auditService.record({
1120
+ timestamp: new Date().toISOString(),
1121
+ tool,
1122
+ status: "error",
1123
+ durationMs: Date.now() - startedAt,
1124
+ input: sanitizeAuditValue(input),
1125
+ error: error instanceof Error ? error.message : String(error),
1126
+ });
1127
+ throw error;
1128
+ }
1129
+ }
1130
+ function asObject(value) {
1131
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1132
+ return {};
1133
+ }
1134
+ return value;
1135
+ }
1136
+ function requireString(args, key) {
1137
+ const value = args[key];
1138
+ if (typeof value !== "string" || !value.trim()) {
1139
+ throw new McpError(ErrorCode.InvalidParams, `${key} must be a non-empty string.`);
1140
+ }
1141
+ return value.trim();
1142
+ }
1143
+ function optionalString(args, key) {
1144
+ const value = args[key];
1145
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1146
+ }
1147
+ function parseListValues(value) {
1148
+ if (!value) {
1149
+ return [];
1150
+ }
1151
+ return value
1152
+ .split(/[;,]/)
1153
+ .map((item) => item.trim())
1154
+ .filter(Boolean);
1155
+ }
1156
+ function parseStringListArg(args, key) {
1157
+ const value = args[key];
1158
+ if (Array.isArray(value)) {
1159
+ return value
1160
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
1161
+ .filter(Boolean);
1162
+ }
1163
+ if (typeof value === "string") {
1164
+ return parseListValues(value);
1165
+ }
1166
+ throw new McpError(ErrorCode.InvalidParams, `${key} must be a non-empty string or string array.`);
1167
+ }
1168
+ function requireEmailAction(args, key = "action") {
1169
+ const value = requireString(args, key);
1170
+ switch (value) {
1171
+ case "mark_read":
1172
+ case "mark_unread":
1173
+ case "star":
1174
+ case "unstar":
1175
+ case "archive":
1176
+ case "trash":
1177
+ case "restore":
1178
+ return value;
1179
+ default:
1180
+ throw new McpError(ErrorCode.InvalidParams, `${key} must be a supported email action.`);
1181
+ }
1182
+ }
1183
+ function optionalAttachmentList(value) {
1184
+ if (value === undefined) {
1185
+ return undefined;
1186
+ }
1187
+ if (!Array.isArray(value)) {
1188
+ throw new McpError(ErrorCode.InvalidParams, "attachments must be an array.");
1189
+ }
1190
+ return value.map((item, index) => {
1191
+ const attachment = asObject(item);
1192
+ const filename = requireString(attachment, "filename");
1193
+ const content = requireString(attachment, "content");
1194
+ const contentType = optionalString(attachment, "contentType");
1195
+ const cid = optionalString(attachment, "cid");
1196
+ const contentDisposition = optionalString(attachment, "contentDisposition");
1197
+ if (!filename || !content) {
1198
+ throw new McpError(ErrorCode.InvalidParams, `attachments[${index}] must contain filename and content.`);
1199
+ }
1200
+ return {
1201
+ filename,
1202
+ content,
1203
+ contentType,
1204
+ cid,
1205
+ contentDisposition,
1206
+ };
1207
+ });
1208
+ }
1209
+ function uniqueAddresses(addresses) {
1210
+ const seen = new Set();
1211
+ const result = [];
1212
+ for (const address of addresses) {
1213
+ const normalized = lowerCaseAddress(address);
1214
+ if (!normalized || seen.has(normalized)) {
1215
+ continue;
1216
+ }
1217
+ seen.add(normalized);
1218
+ result.push(address.trim());
1219
+ }
1220
+ return result;
1221
+ }
1222
+ function addressValues(addresses) {
1223
+ return uniqueAddresses(addresses
1224
+ .map((value) => value.address?.trim())
1225
+ .filter((value) => Boolean(value)));
1226
+ }
1227
+ function prefixedSubject(subject, prefix) {
1228
+ const trimmed = subject.trim();
1229
+ if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
1230
+ return trimmed;
1231
+ }
1232
+ return `${prefix} ${trimmed}`;
1233
+ }
1234
+ function formatAddressList(addresses) {
1235
+ return addresses
1236
+ .map((value) => {
1237
+ if (value.name && value.address) {
1238
+ return `${value.name} <${value.address}>`;
1239
+ }
1240
+ return value.address || value.name || "";
1241
+ })
1242
+ .filter(Boolean)
1243
+ .join(", ");
1244
+ }
1245
+ function quotePlainText(value) {
1246
+ return value
1247
+ .split(/\r?\n/)
1248
+ .map((line) => `> ${line}`)
1249
+ .join("\n");
1250
+ }
1251
+ function buildReplyText(detail, body) {
1252
+ const originalText = detail.text || detail.preview || "";
1253
+ const fromText = formatAddressList(detail.from);
1254
+ const dateText = detail.date || detail.internalDate || "an unknown date";
1255
+ return [
1256
+ body.trim(),
1257
+ "",
1258
+ `On ${dateText}, ${fromText || "the sender"} wrote:`,
1259
+ quotePlainText(originalText),
1260
+ ].join("\n");
1261
+ }
1262
+ function buildForwardText(detail, body) {
1263
+ const originalText = detail.text || detail.preview || "";
1264
+ return [
1265
+ body?.trim() || "",
1266
+ body?.trim() ? "" : "",
1267
+ "---------- Forwarded message ---------",
1268
+ `From: ${formatAddressList(detail.from)}`,
1269
+ `Date: ${detail.date || detail.internalDate || ""}`,
1270
+ `Subject: ${detail.subject}`,
1271
+ `To: ${formatAddressList(detail.to)}`,
1272
+ detail.cc.length > 0 ? `Cc: ${formatAddressList(detail.cc)}` : "",
1273
+ "",
1274
+ originalText,
1275
+ ]
1276
+ .filter((line, index, array) => line !== "" || (index > 0 && array[index - 1] !== ""))
1277
+ .join("\n");
1278
+ }
1279
+ function getReplyRecipients(detail, ownerEmail, replyAll) {
1280
+ const owner = lowerCaseAddress(ownerEmail);
1281
+ const primary = addressValues(detail.replyTo).length > 0 ? detail.replyTo : detail.from;
1282
+ const to = uniqueAddresses(addressValues(primary).filter((address) => lowerCaseAddress(address) !== owner));
1283
+ if (!replyAll) {
1284
+ return { to, cc: [] };
1285
+ }
1286
+ const ccPool = uniqueAddresses([
1287
+ ...addressValues(detail.to),
1288
+ ...addressValues(detail.cc),
1289
+ ]).filter((address) => {
1290
+ const normalized = lowerCaseAddress(address);
1291
+ return normalized !== owner && !to.some((recipient) => lowerCaseAddress(recipient) === normalized);
1292
+ });
1293
+ return { to, cc: ccPool };
1294
+ }
1295
+ function buildEmailResourceUri(emailId) {
1296
+ return `${RESOURCE_SCHEME}://email/${encodeURIComponent(emailId)}`;
1297
+ }
1298
+ function buildThreadResourceUri(threadId) {
1299
+ return `${RESOURCE_SCHEME}://thread/${encodeURIComponent(threadId)}`;
1300
+ }
1301
+ function buildDraftResourceUri(draftId) {
1302
+ return `${RESOURCE_SCHEME}://draft/${encodeURIComponent(draftId)}`;
1303
+ }
1304
+ function buildAttachmentResourceUri(emailId, attachmentId) {
1305
+ return `${RESOURCE_SCHEME}://attachment/${encodeURIComponent(emailId)}/${encodeURIComponent(attachmentId)}`;
1306
+ }
1307
+ function emailSource(email) {
1308
+ const fromText = email.from && email.from.length > 0 ? formatAddressList(email.from) : undefined;
1309
+ return {
1310
+ uri: buildEmailResourceUri(email.id),
1311
+ name: email.id,
1312
+ title: email.subject,
1313
+ description: [email.folder, email.internalDate || email.date || "undated", fromText].filter(Boolean).join(" · "),
1314
+ mimeType: "message/rfc822",
1315
+ provider: "proton-bridge-imap",
1316
+ snippet: email.preview,
1317
+ locator: {
1318
+ kind: "email",
1319
+ emailId: email.id,
1320
+ folder: email.folder,
1321
+ messageId: email.messageId,
1322
+ threadId: email.threadId,
1323
+ from: fromText,
1324
+ subject: email.subject,
1325
+ date: email.internalDate || email.date,
1326
+ },
1327
+ };
1328
+ }
1329
+ function threadSource(thread) {
1330
+ return {
1331
+ uri: buildThreadResourceUri(thread.id),
1332
+ name: thread.id,
1333
+ title: thread.subject,
1334
+ description: `${thread.messageCount} message(s) · ${thread.latestDate || "undated"}`,
1335
+ mimeType: "text/markdown",
1336
+ provider: "local-index",
1337
+ snippet: thread.participants && thread.participants.length > 0 ? formatAddressList(thread.participants) : undefined,
1338
+ locator: {
1339
+ kind: "thread",
1340
+ threadId: thread.id,
1341
+ normalizedLabels: thread.normalizedLabels,
1342
+ participants: thread.participants?.map((entry) => entry.address || entry.name).filter(Boolean),
1343
+ },
1344
+ };
1345
+ }
1346
+ function draftSource(draft) {
1347
+ return {
1348
+ uri: buildDraftResourceUri(draft.id),
1349
+ name: draft.id,
1350
+ title: draft.subject,
1351
+ description: `${draft.status} · updated ${draft.updatedAt}`,
1352
+ mimeType: "text/markdown",
1353
+ provider: "local-draft-store",
1354
+ locator: {
1355
+ kind: "draft",
1356
+ draftId: draft.id,
1357
+ remoteEmailId: draft.remoteDraft?.emailId,
1358
+ },
1359
+ };
1360
+ }
1361
+ function attachmentSource(emailId, attachment) {
1362
+ const attachmentId = attachment.id || attachment.filename || "attachment";
1363
+ return {
1364
+ uri: buildAttachmentResourceUri(emailId, attachmentId),
1365
+ name: attachmentId,
1366
+ title: attachment.filename || attachmentId,
1367
+ description: `${attachment.contentType || "application/octet-stream"} · ${attachment.size || 0} bytes`,
1368
+ mimeType: attachment.contentType || "application/octet-stream",
1369
+ provider: "proton-bridge-imap",
1370
+ locator: {
1371
+ kind: "attachment",
1372
+ emailId,
1373
+ attachmentId,
1374
+ filename: attachment.filename,
1375
+ },
1376
+ };
1377
+ }
1378
+ function formatEmailResource(detail) {
1379
+ return [
1380
+ `# ${detail.subject}`,
1381
+ "",
1382
+ `- Email ID: ${detail.id}`,
1383
+ detail.messageId ? `- Message-ID: ${detail.messageId}` : "",
1384
+ detail.threadId ? `- Thread ID: ${detail.threadId}` : "",
1385
+ detail.references && detail.references.length > 0 ? `- References: ${detail.references.join(", ")}` : "",
1386
+ `- Folder: ${detail.folder}`,
1387
+ `- Date: ${detail.internalDate || detail.date || "unknown"}`,
1388
+ `- From: ${formatAddressList(detail.from) || "unknown"}`,
1389
+ `- To: ${formatAddressList(detail.to) || "unknown"}`,
1390
+ detail.cc.length > 0 ? `- Cc: ${formatAddressList(detail.cc)}` : "",
1391
+ "",
1392
+ detail.text || detail.preview || "(no body text available)",
1393
+ detail.attachmentText ? "" : "",
1394
+ detail.attachmentText ? "## Attachment Text" : "",
1395
+ detail.attachmentText || "",
1396
+ ]
1397
+ .filter(Boolean)
1398
+ .join("\n");
1399
+ }
1400
+ function formatThreadResource(thread) {
1401
+ return [
1402
+ `# ${thread.subject}`,
1403
+ "",
1404
+ `- Thread ID: ${thread.id}`,
1405
+ `- Latest: ${thread.latestDate || "unknown"}`,
1406
+ `- Labels: ${thread.normalizedLabels.join(", ") || "(none)"}`,
1407
+ "",
1408
+ ...thread.messages.flatMap((message) => [
1409
+ `## ${message.subject}`,
1410
+ `- Email ID: ${message.id}`,
1411
+ `- From: ${formatAddressList(message.from) || "unknown"}`,
1412
+ `- Date: ${message.internalDate || message.date || "unknown"}`,
1413
+ "",
1414
+ message.preview || "(no preview)",
1415
+ "",
1416
+ ]),
1417
+ ].join("\n");
1418
+ }
1419
+ function formatDraftResource(draft) {
1420
+ return [
1421
+ `# ${draft.subject}`,
1422
+ "",
1423
+ `- Draft ID: ${draft.id}`,
1424
+ `- Status: ${draft.status}`,
1425
+ `- Mode: ${draft.mode}`,
1426
+ `- Updated: ${draft.updatedAt}`,
1427
+ `- Remote Sync: ${draft.remoteSyncState}`,
1428
+ draft.remoteDraft?.emailId ? `- Remote Email ID: ${draft.remoteDraft.emailId}` : "",
1429
+ `- To: ${draft.to.join(", ") || "(none)"}`,
1430
+ draft.cc.length > 0 ? `- Cc: ${draft.cc.join(", ")}` : "",
1431
+ draft.bcc.length > 0 ? `- Bcc: ${draft.bcc.join(", ")}` : "",
1432
+ "",
1433
+ draft.body,
1434
+ ]
1435
+ .filter(Boolean)
1436
+ .join("\n");
1437
+ }
1438
+ function parseResourceUri(uri) {
1439
+ const parsed = new URL(uri);
1440
+ if (parsed.protocol !== `${RESOURCE_SCHEME}:`) {
1441
+ throw new Error(`Unsupported resource URI: ${uri}`);
1442
+ }
1443
+ const segments = parsed.pathname
1444
+ .split("/")
1445
+ .filter(Boolean)
1446
+ .map((segment) => decodeURIComponent(segment));
1447
+ switch (parsed.hostname) {
1448
+ case "email":
1449
+ if (segments.length !== 1) {
1450
+ break;
1451
+ }
1452
+ return { kind: "email", emailId: segments[0] };
1453
+ case "thread":
1454
+ if (segments.length !== 1) {
1455
+ break;
1456
+ }
1457
+ return { kind: "thread", threadId: segments[0] };
1458
+ case "draft":
1459
+ if (segments.length !== 1) {
1460
+ break;
1461
+ }
1462
+ return { kind: "draft", draftId: segments[0] };
1463
+ case "attachment":
1464
+ if (segments.length !== 2) {
1465
+ break;
1466
+ }
1467
+ return { kind: "attachment", emailId: segments[0], attachmentId: segments[1] };
1468
+ default:
1469
+ break;
1470
+ }
1471
+ throw new Error(`Unsupported resource URI: ${uri}`);
1472
+ }
1473
+ async function syncDraftToRemote(draftStore, smtpService, imapService, draft) {
1474
+ try {
1475
+ const raw = await smtpService.buildRawMessage({
1476
+ to: draft.to,
1477
+ cc: draft.cc,
1478
+ bcc: draft.bcc,
1479
+ subject: draft.subject,
1480
+ body: draft.body,
1481
+ isHtml: draft.isHtml,
1482
+ priority: draft.priority,
1483
+ replyTo: draft.replyTo,
1484
+ inReplyTo: draft.inReplyTo,
1485
+ references: draft.references,
1486
+ messageId: draft.draftMessageId,
1487
+ attachments: draft.attachments,
1488
+ });
1489
+ const remoteDraft = await imapService.upsertRemoteDraft({
1490
+ raw,
1491
+ messageId: draft.draftMessageId,
1492
+ existingEmailId: draft.remoteDraft?.emailId,
1493
+ });
1494
+ return {
1495
+ draft: await draftStore.markRemoteSynced(draft.id, remoteDraft),
1496
+ remoteSync: {
1497
+ ok: true,
1498
+ emailId: remoteDraft.emailId,
1499
+ },
1500
+ };
1501
+ }
1502
+ catch (error) {
1503
+ const message = error instanceof Error ? error.message : String(error);
1504
+ logger.warn("Draft remote sync failed", "MCPServer", { draftId: draft.id, error });
1505
+ return {
1506
+ draft: await draftStore.markRemoteSyncError(draft.id, message),
1507
+ remoteSync: {
1508
+ ok: false,
1509
+ message,
1510
+ },
1511
+ };
1512
+ }
1513
+ }
1514
+ async function clearRemoteDraft(draftStore, imapService, draft) {
1515
+ if (!draft.remoteDraft?.emailId) {
1516
+ return {
1517
+ draft,
1518
+ };
1519
+ }
1520
+ try {
1521
+ await imapService.deleteRemoteDraft(draft.remoteDraft.emailId);
1522
+ return {
1523
+ draft: await draftStore.clearRemoteSync(draft.id),
1524
+ remoteDelete: {
1525
+ ok: true,
1526
+ },
1527
+ };
1528
+ }
1529
+ catch (error) {
1530
+ const message = error instanceof Error ? error.message : String(error);
1531
+ logger.warn("Remote draft cleanup failed", "MCPServer", { draftId: draft.id, error });
1532
+ return {
1533
+ draft,
1534
+ remoteDelete: {
1535
+ ok: false,
1536
+ message,
1537
+ },
1538
+ };
1539
+ }
1540
+ }
1541
+ async function ensureFreshLocalIndex(imapService, localIndexService, input = {}) {
1542
+ const checkpoints = await localIndexService.getSyncCheckpointMap();
1543
+ const snapshot = await imapService.collectEmailsForIndex({
1544
+ folder: input.folder,
1545
+ full: input.full ?? false,
1546
+ limitPerFolder: input.limitPerFolder,
1547
+ includeAttachmentText: true,
1548
+ checkpoints,
1549
+ });
1550
+ const indexStatus = await localIndexService.recordSnapshot({
1551
+ folders: snapshot.folders,
1552
+ emails: snapshot.emails,
1553
+ syncedAt: snapshot.syncedAt,
1554
+ folderStats: snapshot.folderStats,
1555
+ });
1556
+ return {
1557
+ snapshot,
1558
+ indexStatus,
1559
+ };
1560
+ }
1561
+ async function maybeRefreshLocalIndex(imapService, localIndexService, input = {}) {
1562
+ const status = await localIndexService.getStatus();
1563
+ if (!input.force && status.storedMessageCount > 0 && !status.isStale) {
1564
+ return undefined;
1565
+ }
1566
+ return ensureFreshLocalIndex(imapService, localIndexService, {
1567
+ folder: input.folder,
1568
+ full: input.full,
1569
+ limitPerFolder: input.limitPerFolder,
1570
+ });
1571
+ }
1572
+ async function runEmailAction(imapService, emailId, action, targetFolder) {
1573
+ switch (action) {
1574
+ case "mark_read":
1575
+ return imapService.markEmailRead(emailId, true);
1576
+ case "mark_unread":
1577
+ return imapService.markEmailRead(emailId, false);
1578
+ case "star":
1579
+ return imapService.starEmail(emailId, true);
1580
+ case "unstar":
1581
+ return imapService.starEmail(emailId, false);
1582
+ case "archive":
1583
+ return imapService.archiveEmail(emailId);
1584
+ case "trash":
1585
+ return imapService.trashEmail(emailId);
1586
+ case "restore":
1587
+ return imapService.restoreEmail(emailId, targetFolder);
1588
+ }
1589
+ }
1590
+ function emailSourceFromActionResult(result) {
1591
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
1592
+ return [];
1593
+ }
1594
+ const candidateKeys = ["targetEmailId", "emailId"];
1595
+ const emailIds = new Set();
1596
+ for (const key of candidateKeys) {
1597
+ const value = result[key];
1598
+ if (typeof value === "string" && value.trim()) {
1599
+ emailIds.add(value.trim());
1600
+ }
1601
+ }
1602
+ return [...emailIds].map((emailId) => ({
1603
+ uri: buildEmailResourceUri(emailId),
1604
+ name: emailId,
1605
+ title: `Email ${emailId}`,
1606
+ mimeType: "message/rfc822",
1607
+ }));
1608
+ }
1609
+ async function applyBatchEmailAction(imapService, entries, input) {
1610
+ for (const emailId of input.emailIds) {
1611
+ try {
1612
+ const result = input.dryRun
1613
+ ? await previewEmailAction(imapService, emailId, input.action, input.targetFolder)
1614
+ : await runEmailAction(imapService, emailId, input.action, input.targetFolder);
1615
+ entries.push({
1616
+ emailId,
1617
+ ok: true,
1618
+ action: input.action,
1619
+ result,
1620
+ });
1621
+ }
1622
+ catch (error) {
1623
+ entries.push({
1624
+ emailId,
1625
+ ok: false,
1626
+ action: input.action,
1627
+ error: error instanceof Error ? error.message : String(error),
1628
+ });
1629
+ if (!input.continueOnError) {
1630
+ break;
1631
+ }
1632
+ }
1633
+ }
1634
+ const succeeded = entries.filter((entry) => entry.ok).length;
1635
+ return {
1636
+ action: input.action,
1637
+ total: input.emailIds.length,
1638
+ succeeded,
1639
+ failed: entries.length - succeeded,
1640
+ results: entries,
1641
+ };
1642
+ }
1643
+ async function previewEmailAction(imapService, emailId, action, targetFolder) {
1644
+ const detail = await imapService.getEmailById(emailId);
1645
+ const target = action === "archive"
1646
+ ? "Archive"
1647
+ : action === "trash"
1648
+ ? "Trash"
1649
+ : action === "restore"
1650
+ ? targetFolder || "INBOX"
1651
+ : detail.folder;
1652
+ return {
1653
+ emailId,
1654
+ previewOnly: true,
1655
+ action,
1656
+ currentFolder: detail.folder,
1657
+ targetFolder: target,
1658
+ subject: detail.subject,
1659
+ from: detail.from,
1660
+ date: detail.internalDate || detail.date,
1661
+ hasAttachments: detail.hasAttachments,
1662
+ isRead: detail.isRead,
1663
+ isStarred: detail.isStarred,
1664
+ };
1665
+ }
1666
+ function pickReplyTargetFromThread(thread, ownerEmail, preferLatestInbound) {
1667
+ const messages = [...thread.messages];
1668
+ if (preferLatestInbound) {
1669
+ const inbound = [...messages]
1670
+ .reverse()
1671
+ .find((message) => !message.from.some((address) => lowerCaseAddress(address.address) === lowerCaseAddress(ownerEmail)));
1672
+ if (inbound) {
1673
+ return inbound;
1674
+ }
1675
+ }
1676
+ return messages[messages.length - 1];
1677
+ }
1678
+ function buildThreadBrief(thread, ownerEmail) {
1679
+ const messages = [...thread.messages];
1680
+ const latestMessage = messages[messages.length - 1];
1681
+ const latestInbound = [...messages]
1682
+ .reverse()
1683
+ .find((message) => !message.from.some((entry) => lowerCaseAddress(entry.address) === lowerCaseAddress(ownerEmail)));
1684
+ const latestOutbound = [...messages]
1685
+ .reverse()
1686
+ .find((message) => message.from.some((entry) => lowerCaseAddress(entry.address) === lowerCaseAddress(ownerEmail)));
1687
+ const pendingOn = latestMessage
1688
+ ? latestMessage.from.some((entry) => lowerCaseAddress(entry.address) === lowerCaseAddress(ownerEmail))
1689
+ ? "them"
1690
+ : "you"
1691
+ : "unknown";
1692
+ return {
1693
+ threadId: thread.id,
1694
+ subject: thread.subject,
1695
+ messageCount: thread.messageCount,
1696
+ unreadCount: thread.unreadCount,
1697
+ latestDate: thread.latestDate,
1698
+ normalizedLabels: thread.normalizedLabels,
1699
+ participants: thread.participants,
1700
+ pendingOn,
1701
+ likelyNextAction: pendingOn === "you" ? "reply" : pendingOn === "them" ? "wait_or_follow_up" : "review",
1702
+ latestInbound: latestInbound
1703
+ ? {
1704
+ emailId: latestInbound.primaryEmailId,
1705
+ from: latestInbound.from,
1706
+ date: latestInbound.internalDate || latestInbound.date,
1707
+ preview: latestInbound.preview,
1708
+ }
1709
+ : undefined,
1710
+ latestOutbound: latestOutbound
1711
+ ? {
1712
+ emailId: latestOutbound.primaryEmailId,
1713
+ to: latestOutbound.to,
1714
+ date: latestOutbound.internalDate || latestOutbound.date,
1715
+ preview: latestOutbound.preview,
1716
+ }
1717
+ : undefined,
1718
+ attachments: messages.flatMap((message) => message.attachments.map((attachment) => ({
1719
+ emailId: message.primaryEmailId,
1720
+ filename: attachment.filename,
1721
+ kind: attachment.kind,
1722
+ contentType: attachment.contentType,
1723
+ }))),
1724
+ };
1725
+ }
1726
+ function readEnvValue(name) {
1727
+ const direct = process.env[name]?.trim();
1728
+ if (direct) {
1729
+ return direct;
1730
+ }
1731
+ const command = process.env[`${name}_COMMAND`]?.trim();
1732
+ if (command) {
1733
+ return execSync(command, {
1734
+ encoding: "utf8",
1735
+ stdio: ["ignore", "pipe", "pipe"],
1736
+ shell: "/bin/sh",
1737
+ }).trim();
1738
+ }
1739
+ const filePath = process.env[`${name}_FILE`]?.trim();
1740
+ if (!filePath) {
1741
+ return undefined;
1742
+ }
1743
+ return readFileSync(filePath, "utf8").trim();
1744
+ }
1745
+ function parseBooleanEnv(name, defaultValue) {
1746
+ const raw = process.env[name]?.trim().toLowerCase();
1747
+ if (!raw) {
1748
+ return defaultValue;
1749
+ }
1750
+ if (["1", "true", "yes", "on"].includes(raw)) {
1751
+ return true;
1752
+ }
1753
+ if (["0", "false", "no", "off"].includes(raw)) {
1754
+ return false;
1755
+ }
1756
+ return defaultValue;
1757
+ }
1758
+ function parseIntegerEnv(name, defaultValue, min = 1, max = 10_000) {
1759
+ const raw = process.env[name]?.trim();
1760
+ if (!raw) {
1761
+ return defaultValue;
1762
+ }
1763
+ const parsed = Number.parseInt(raw, 10);
1764
+ if (Number.isNaN(parsed)) {
1765
+ return defaultValue;
1766
+ }
1767
+ return Math.min(max, Math.max(min, parsed));
1768
+ }
1769
+ function parseAllowedActionsEnv(name) {
1770
+ const configured = parseListValues(process.env[name]);
1771
+ if (configured.length === 0) {
1772
+ return [...ALL_EMAIL_ACTIONS];
1773
+ }
1774
+ const allowed = configured.filter((value) => ALL_EMAIL_ACTIONS.includes(value));
1775
+ return allowed.length > 0 ? allowed : [...ALL_EMAIL_ACTIONS];
1776
+ }
1777
+ export function buildConfigFromEnv() {
1778
+ const username = readEnvValue("PROTONMAIL_USERNAME");
1779
+ const password = readEnvValue("PROTONMAIL_PASSWORD");
1780
+ if (!username || !password) {
1781
+ throw new Error("Missing required environment variables or secret sources: PROTONMAIL_USERNAME and PROTONMAIL_PASSWORD.");
1782
+ }
1783
+ const smtpPort = parseIntegerEnv("PROTONMAIL_SMTP_PORT", 587, 1, 65_535);
1784
+ const imapPort = parseIntegerEnv("PROTONMAIL_IMAP_PORT", 1143, 1, 65_535);
1785
+ const debug = parseBooleanEnv("DEBUG", false);
1786
+ const readOnly = parseBooleanEnv("PROTONMAIL_READ_ONLY", false);
1787
+ const allowSend = parseBooleanEnv("PROTONMAIL_ALLOW_SEND", !readOnly);
1788
+ const allowRemoteDraftSync = parseBooleanEnv("PROTONMAIL_ALLOW_REMOTE_DRAFT_SYNC", !readOnly);
1789
+ const autoSync = parseBooleanEnv("PROTONMAIL_AUTO_SYNC", true);
1790
+ const syncInterval = parseIntegerEnv("PROTONMAIL_SYNC_INTERVAL_MINUTES", 5, 1, 24 * 60);
1791
+ const idleWatchEnabled = parseBooleanEnv("PROTONMAIL_IDLE_WATCH", autoSync);
1792
+ const idleMaxSeconds = parseIntegerEnv("PROTONMAIL_IDLE_MAX_SECONDS", 30, 5, 300);
1793
+ const confirmDestructive = parseBooleanEnv("PROTONMAIL_CONFIRM_DESTRUCTIVE", false);
1794
+ logger.setDebugMode(debug);
1795
+ return {
1796
+ smtp: {
1797
+ host: process.env.PROTONMAIL_SMTP_HOST || "smtp.protonmail.ch",
1798
+ port: smtpPort,
1799
+ secure: smtpPort === 465,
1800
+ username,
1801
+ password,
1802
+ },
1803
+ imap: {
1804
+ host: process.env.PROTONMAIL_IMAP_HOST || "localhost",
1805
+ port: imapPort,
1806
+ secure: parseBooleanEnv("PROTONMAIL_IMAP_SECURE", false),
1807
+ username,
1808
+ password,
1809
+ },
1810
+ dataDir: process.env.PROTONMAIL_DATA_DIR || join(homedir(), ".proton-mail-bridge-client"),
1811
+ debug,
1812
+ cacheEnabled: true,
1813
+ analyticsEnabled: true,
1814
+ autoSync,
1815
+ syncInterval,
1816
+ runtime: {
1817
+ readOnly,
1818
+ allowSend,
1819
+ allowRemoteDraftSync,
1820
+ allowedActions: parseAllowedActionsEnv("PROTONMAIL_ALLOWED_ACTIONS"),
1821
+ startupSync: parseBooleanEnv("PROTONMAIL_STARTUP_SYNC", autoSync),
1822
+ autoSyncFolder: process.env.PROTONMAIL_AUTO_SYNC_FOLDER?.trim() || "INBOX",
1823
+ autoSyncFull: parseBooleanEnv("PROTONMAIL_AUTO_SYNC_FULL", false),
1824
+ autoSyncLimitPerFolder: parseIntegerEnv("PROTONMAIL_AUTO_SYNC_LIMIT_PER_FOLDER", 100, 1, 500),
1825
+ idleWatchEnabled,
1826
+ idleMaxSeconds,
1827
+ confirmDestructive,
1828
+ },
1829
+ };
1830
+ }
1831
+ export function createServer(config, options = {}) {
1832
+ const smtpService = new SMTPService(config);
1833
+ const imapService = new SimpleIMAPService(config, logger);
1834
+ const analyticsService = new AnalyticsService();
1835
+ const auditService = new AuditService(config);
1836
+ const localIndexService = new LocalIndexService(config, logger);
1837
+ const draftStore = new DraftStoreService(config, logger);
1838
+ const backgroundSyncService = new BackgroundSyncService(config, imapService, localIndexService, logger);
1839
+ if (options.startBackgroundSync) {
1840
+ backgroundSyncService.start();
1841
+ }
1842
+ const server = new Server({
1843
+ name: "proton-mail-bridge-client",
1844
+ version: "1.10.0",
1845
+ }, {
1846
+ capabilities: {
1847
+ tools: {},
1848
+ resources: {},
1849
+ },
1850
+ });
1851
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
1852
+ server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
1853
+ const cursor = request.params?.cursor ? Number.parseInt(request.params.cursor, 10) : 0;
1854
+ const [drafts, threadsResult, messages] = await Promise.all([
1855
+ draftStore.listDrafts(false),
1856
+ localIndexService.getThreads({ limit: 25 }),
1857
+ localIndexService.listRecentMessages(25),
1858
+ ]);
1859
+ const resources = [
1860
+ ...drafts.map((draft) => ({
1861
+ uri: buildDraftResourceUri(draft.id),
1862
+ name: draft.id,
1863
+ title: draft.subject,
1864
+ description: `${draft.status} · updated ${draft.updatedAt}`,
1865
+ mimeType: "text/markdown",
1866
+ })),
1867
+ ...threadsResult.threads.map((thread) => ({
1868
+ uri: buildThreadResourceUri(thread.id),
1869
+ name: thread.id,
1870
+ title: thread.subject,
1871
+ description: `${thread.messageCount} message(s)`,
1872
+ mimeType: "text/markdown",
1873
+ })),
1874
+ ...messages.map((message) => ({
1875
+ uri: buildEmailResourceUri(message.primaryEmailId),
1876
+ name: message.primaryEmailId,
1877
+ title: message.subject,
1878
+ description: `${message.folder} · ${message.internalDate || message.date || "undated"}`,
1879
+ mimeType: "message/rfc822",
1880
+ })),
1881
+ ];
1882
+ const pageSize = 50;
1883
+ const nextCursor = cursor + pageSize < resources.length ? String(cursor + pageSize) : undefined;
1884
+ return {
1885
+ resources: resources.slice(cursor, cursor + pageSize),
1886
+ nextCursor,
1887
+ };
1888
+ });
1889
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1890
+ const target = parseResourceUri(request.params.uri);
1891
+ switch (target.kind) {
1892
+ case "email": {
1893
+ const detail = await imapService.getEmailById(target.emailId);
1894
+ return {
1895
+ contents: [
1896
+ {
1897
+ uri: request.params.uri,
1898
+ mimeType: "text/markdown",
1899
+ text: formatEmailResource(detail),
1900
+ },
1901
+ ],
1902
+ };
1903
+ }
1904
+ case "thread": {
1905
+ const thread = await localIndexService.getThreadById(target.threadId);
1906
+ return {
1907
+ contents: [
1908
+ {
1909
+ uri: request.params.uri,
1910
+ mimeType: "text/markdown",
1911
+ text: formatThreadResource({
1912
+ id: thread.id,
1913
+ subject: thread.subject,
1914
+ latestDate: thread.latestDate,
1915
+ normalizedLabels: thread.normalizedLabels,
1916
+ messages: thread.messages.map((message) => ({
1917
+ id: message.primaryEmailId,
1918
+ subject: message.subject,
1919
+ from: message.from,
1920
+ date: message.date,
1921
+ internalDate: message.internalDate,
1922
+ preview: message.preview,
1923
+ })),
1924
+ }),
1925
+ },
1926
+ ],
1927
+ };
1928
+ }
1929
+ case "draft": {
1930
+ const draft = await draftStore.getDraft(target.draftId);
1931
+ return {
1932
+ contents: [
1933
+ {
1934
+ uri: request.params.uri,
1935
+ mimeType: "text/markdown",
1936
+ text: formatDraftResource(draft),
1937
+ },
1938
+ ],
1939
+ };
1940
+ }
1941
+ case "attachment": {
1942
+ const attachment = await imapService.getAttachmentContent(target.emailId, target.attachmentId, true);
1943
+ const mimeType = attachment.attachment.contentType || "application/octet-stream";
1944
+ if (attachment.text && isTextLikeMimeType(mimeType)) {
1945
+ return {
1946
+ contents: [
1947
+ {
1948
+ uri: request.params.uri,
1949
+ mimeType,
1950
+ text: attachment.text,
1951
+ },
1952
+ ],
1953
+ };
1954
+ }
1955
+ return {
1956
+ contents: [
1957
+ {
1958
+ uri: request.params.uri,
1959
+ mimeType,
1960
+ blob: attachment.base64 || "",
1961
+ },
1962
+ ],
1963
+ };
1964
+ }
1965
+ }
1966
+ });
1967
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1968
+ const name = request.params.name;
1969
+ const args = asObject(request.params.arguments);
1970
+ logger.debug("Handling tool call", "MCPServer", { name, args });
1971
+ try {
1972
+ switch (name) {
1973
+ case "send_email": {
1974
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send email to ${String(args.to ?? "?")} — "${String(args.subject ?? "?")}"`);
1975
+ ensureSendAllowed(config.runtime);
1976
+ const to = parseEmails(requireString(args, "to"));
1977
+ const cc = parseEmails(optionalString(args, "cc"));
1978
+ const bcc = parseEmails(optionalString(args, "bcc"));
1979
+ const subject = requireString(args, "subject");
1980
+ const body = requireString(args, "body");
1981
+ const isHtml = normalizeBoolean(args.isHtml, false);
1982
+ const priority = optionalString(args, "priority");
1983
+ const replyTo = optionalString(args, "replyTo");
1984
+ const attachments = optionalAttachmentList(args.attachments);
1985
+ ensureValidEmails(to, "to");
1986
+ ensureValidEmails(cc, "cc");
1987
+ ensureValidEmails(bcc, "bcc");
1988
+ if (replyTo && !isValidEmail(replyTo)) {
1989
+ throw new McpError(ErrorCode.InvalidParams, "replyTo must be a valid email address.");
1990
+ }
1991
+ const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
1992
+ to,
1993
+ cc,
1994
+ bcc,
1995
+ subject,
1996
+ body,
1997
+ isHtml,
1998
+ priority: priority === "high" || priority === "low" || priority === "normal"
1999
+ ? priority
2000
+ : "normal",
2001
+ replyTo,
2002
+ attachments,
2003
+ }));
2004
+ return createTextResult({
2005
+ messageId: result.messageId,
2006
+ accepted: result.accepted,
2007
+ rejected: result.rejected,
2008
+ response: result.response,
2009
+ });
2010
+ }
2011
+ case "send_test_email": {
2012
+ ensureSendAllowed(config.runtime);
2013
+ const to = requireString(args, "to");
2014
+ if (!isValidEmail(to)) {
2015
+ throw new McpError(ErrorCode.InvalidParams, "to must be a valid email address.");
2016
+ }
2017
+ const result = await withAudit(auditService, name, args, async () => smtpService.sendTestEmail(to, optionalString(args, "customMessage")));
2018
+ return createTextResult({
2019
+ messageId: result.messageId,
2020
+ accepted: result.accepted,
2021
+ rejected: result.rejected,
2022
+ response: result.response,
2023
+ });
2024
+ }
2025
+ case "reply_to_email": {
2026
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Reply to email ${String(args.emailId ?? "?")}`);
2027
+ ensureSendAllowed(config.runtime);
2028
+ const detail = await imapService.getEmailById(requireString(args, "emailId"));
2029
+ const body = requireString(args, "body");
2030
+ const isHtml = normalizeBoolean(args.isHtml, false);
2031
+ const replyAll = normalizeBoolean(args.replyAll, false);
2032
+ const attachments = optionalAttachmentList(args.attachments);
2033
+ const extraCc = parseEmails(optionalString(args, "cc"));
2034
+ const extraBcc = parseEmails(optionalString(args, "bcc"));
2035
+ const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
2036
+ const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
2037
+ const to = uniqueAddresses(recipients.to);
2038
+ ensureValidEmails(to, "to");
2039
+ ensureValidEmails(cc, "cc");
2040
+ ensureValidEmails(extraBcc, "bcc");
2041
+ if (to.length === 0) {
2042
+ throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
2043
+ }
2044
+ const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
2045
+ to,
2046
+ cc,
2047
+ bcc: extraBcc,
2048
+ subject: prefixedSubject(detail.subject, "Re:"),
2049
+ body: buildReplyText(detail, body),
2050
+ isHtml,
2051
+ inReplyTo: detail.messageId,
2052
+ references: detail.messageId ? [detail.messageId] : undefined,
2053
+ attachments,
2054
+ }));
2055
+ return createTextResult({
2056
+ repliedTo: detail.id,
2057
+ to,
2058
+ cc,
2059
+ messageId: result.messageId,
2060
+ accepted: result.accepted,
2061
+ rejected: result.rejected,
2062
+ response: result.response,
2063
+ }, false, [emailSource(detail)]);
2064
+ }
2065
+ case "forward_email": {
2066
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Forward email ${String(args.emailId ?? "?")} to ${String(args.to ?? "?")}`);
2067
+ ensureSendAllowed(config.runtime);
2068
+ const detail = await imapService.getEmailById(requireString(args, "emailId"));
2069
+ const to = parseEmails(requireString(args, "to"));
2070
+ const cc = parseEmails(optionalString(args, "cc"));
2071
+ const bcc = parseEmails(optionalString(args, "bcc"));
2072
+ const body = optionalString(args, "body");
2073
+ const isHtml = normalizeBoolean(args.isHtml, false);
2074
+ const attachments = optionalAttachmentList(args.attachments);
2075
+ ensureValidEmails(to, "to");
2076
+ ensureValidEmails(cc, "cc");
2077
+ ensureValidEmails(bcc, "bcc");
2078
+ const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
2079
+ to,
2080
+ cc,
2081
+ bcc,
2082
+ subject: prefixedSubject(detail.subject, "Fwd:"),
2083
+ body: buildForwardText(detail, body),
2084
+ isHtml,
2085
+ attachments,
2086
+ }));
2087
+ return createTextResult({
2088
+ forwardedMessage: detail.id,
2089
+ to,
2090
+ cc,
2091
+ messageId: result.messageId,
2092
+ accepted: result.accepted,
2093
+ rejected: result.rejected,
2094
+ response: result.response,
2095
+ }, false, [emailSource(detail)]);
2096
+ }
2097
+ case "create_draft": {
2098
+ const to = parseEmails(optionalString(args, "to"));
2099
+ const cc = parseEmails(optionalString(args, "cc"));
2100
+ const bcc = parseEmails(optionalString(args, "bcc"));
2101
+ const subject = requireString(args, "subject");
2102
+ const body = requireString(args, "body");
2103
+ const replyTo = optionalString(args, "replyTo");
2104
+ const attachments = optionalAttachmentList(args.attachments);
2105
+ const priority = optionalString(args, "priority");
2106
+ ensureValidEmails(to, "to");
2107
+ ensureValidEmails(cc, "cc");
2108
+ ensureValidEmails(bcc, "bcc");
2109
+ if (replyTo && !isValidEmail(replyTo)) {
2110
+ throw new McpError(ErrorCode.InvalidParams, "replyTo must be a valid email address.");
2111
+ }
2112
+ const result = await withAudit(auditService, name, args, async () => {
2113
+ const draft = await draftStore.createDraft({
2114
+ mode: "compose",
2115
+ to,
2116
+ cc,
2117
+ bcc,
2118
+ subject,
2119
+ body,
2120
+ isHtml: normalizeBoolean(args.isHtml, false),
2121
+ priority: priority === "high" || priority === "low" || priority === "normal"
2122
+ ? priority
2123
+ : undefined,
2124
+ replyTo,
2125
+ notes: optionalString(args, "notes"),
2126
+ attachments,
2127
+ });
2128
+ const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
2129
+ const synced = remoteSyncDecision.enabled
2130
+ ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
2131
+ : { draft, remoteSync: undefined };
2132
+ const remoteSync = synced.remoteSync ??
2133
+ (remoteSyncDecision.reason
2134
+ ? {
2135
+ ok: false,
2136
+ skipped: true,
2137
+ message: remoteSyncDecision.reason,
2138
+ }
2139
+ : undefined);
2140
+ return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
2141
+ });
2142
+ return createTextResult(result, false, [
2143
+ draftSource(result),
2144
+ ...(result.remoteDraft?.emailId
2145
+ ? [
2146
+ emailSource({
2147
+ id: result.remoteDraft.emailId,
2148
+ subject: result.subject,
2149
+ folder: result.remoteDraft.folder,
2150
+ })
2151
+ ]
2152
+ : []),
2153
+ ]);
2154
+ }
2155
+ case "create_reply_draft": {
2156
+ const detail = await imapService.getEmailById(requireString(args, "emailId"));
2157
+ const body = requireString(args, "body");
2158
+ const isHtml = normalizeBoolean(args.isHtml, false);
2159
+ const replyAll = normalizeBoolean(args.replyAll, false);
2160
+ const attachments = optionalAttachmentList(args.attachments);
2161
+ const extraCc = parseEmails(optionalString(args, "cc"));
2162
+ const extraBcc = parseEmails(optionalString(args, "bcc"));
2163
+ const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
2164
+ const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
2165
+ const to = uniqueAddresses(recipients.to);
2166
+ ensureValidEmails(to, "to");
2167
+ ensureValidEmails(cc, "cc");
2168
+ ensureValidEmails(extraBcc, "bcc");
2169
+ if (to.length === 0) {
2170
+ throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
2171
+ }
2172
+ const result = await withAudit(auditService, name, args, async () => {
2173
+ const draft = await draftStore.createDraft({
2174
+ mode: "reply",
2175
+ to,
2176
+ cc,
2177
+ bcc: extraBcc,
2178
+ subject: prefixedSubject(detail.subject, "Re:"),
2179
+ body: buildReplyText(detail, body),
2180
+ isHtml,
2181
+ inReplyTo: detail.messageId,
2182
+ references: detail.messageId ? [detail.messageId] : undefined,
2183
+ attachments,
2184
+ sourceEmailId: detail.id,
2185
+ sourceMessageId: detail.messageId,
2186
+ notes: optionalString(args, "notes"),
2187
+ });
2188
+ const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
2189
+ const synced = remoteSyncDecision.enabled
2190
+ ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
2191
+ : { draft, remoteSync: undefined };
2192
+ const remoteSync = synced.remoteSync ??
2193
+ (remoteSyncDecision.reason
2194
+ ? {
2195
+ ok: false,
2196
+ skipped: true,
2197
+ message: remoteSyncDecision.reason,
2198
+ }
2199
+ : undefined);
2200
+ return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
2201
+ });
2202
+ return createTextResult(result, false, [
2203
+ draftSource(result),
2204
+ emailSource(detail),
2205
+ ...(result.remoteDraft?.emailId
2206
+ ? [
2207
+ emailSource({
2208
+ id: result.remoteDraft.emailId,
2209
+ subject: result.subject,
2210
+ folder: result.remoteDraft.folder,
2211
+ })
2212
+ ]
2213
+ : []),
2214
+ ]);
2215
+ }
2216
+ case "create_forward_draft": {
2217
+ const detail = await imapService.getEmailById(requireString(args, "emailId"));
2218
+ const to = parseEmails(requireString(args, "to"));
2219
+ const cc = parseEmails(optionalString(args, "cc"));
2220
+ const bcc = parseEmails(optionalString(args, "bcc"));
2221
+ const attachments = optionalAttachmentList(args.attachments);
2222
+ ensureValidEmails(to, "to");
2223
+ ensureValidEmails(cc, "cc");
2224
+ ensureValidEmails(bcc, "bcc");
2225
+ const result = await withAudit(auditService, name, args, async () => {
2226
+ const draft = await draftStore.createDraft({
2227
+ mode: "forward",
2228
+ to,
2229
+ cc,
2230
+ bcc,
2231
+ subject: prefixedSubject(detail.subject, "Fwd:"),
2232
+ body: buildForwardText(detail, optionalString(args, "body")),
2233
+ isHtml: normalizeBoolean(args.isHtml, false),
2234
+ attachments,
2235
+ sourceEmailId: detail.id,
2236
+ sourceMessageId: detail.messageId,
2237
+ notes: optionalString(args, "notes"),
2238
+ });
2239
+ const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
2240
+ const synced = remoteSyncDecision.enabled
2241
+ ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
2242
+ : { draft, remoteSync: undefined };
2243
+ const remoteSync = synced.remoteSync ??
2244
+ (remoteSyncDecision.reason
2245
+ ? {
2246
+ ok: false,
2247
+ skipped: true,
2248
+ message: remoteSyncDecision.reason,
2249
+ }
2250
+ : undefined);
2251
+ return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
2252
+ });
2253
+ return createTextResult(result, false, [
2254
+ draftSource(result),
2255
+ emailSource(detail),
2256
+ ...(result.remoteDraft?.emailId
2257
+ ? [
2258
+ emailSource({
2259
+ id: result.remoteDraft.emailId,
2260
+ subject: result.subject,
2261
+ folder: result.remoteDraft.folder,
2262
+ })
2263
+ ]
2264
+ : []),
2265
+ ]);
2266
+ }
2267
+ case "list_drafts": {
2268
+ const drafts = await draftStore.listDrafts(normalizeBoolean(args.includeSent, false));
2269
+ return createTextResult({
2270
+ total: drafts.length,
2271
+ drafts,
2272
+ }, false, drafts.map(draftSource));
2273
+ }
2274
+ case "list_remote_drafts": {
2275
+ const result = await imapService.listRemoteDrafts(normalizeLimit(args.limit, 50), normalizeLimit(args.offset, 0, 0, 10_000));
2276
+ return createTextResult(result, false, result.emails.map(emailSource));
2277
+ }
2278
+ case "get_draft": {
2279
+ const draft = await draftStore.getDraft(requireString(args, "draftId"));
2280
+ return createTextResult(draft, false, [
2281
+ draftSource(draft),
2282
+ ...(draft.remoteDraft?.emailId
2283
+ ? [
2284
+ emailSource({
2285
+ id: draft.remoteDraft.emailId,
2286
+ subject: draft.subject,
2287
+ folder: draft.remoteDraft.folder,
2288
+ })
2289
+ ]
2290
+ : []),
2291
+ ]);
2292
+ }
2293
+ case "update_draft": {
2294
+ const draftId = requireString(args, "draftId");
2295
+ const to = args.to === undefined ? undefined : parseEmails(optionalString(args, "to"));
2296
+ const cc = args.cc === undefined ? undefined : parseEmails(optionalString(args, "cc"));
2297
+ const bcc = args.bcc === undefined ? undefined : parseEmails(optionalString(args, "bcc"));
2298
+ const replyTo = optionalString(args, "replyTo");
2299
+ const priority = optionalString(args, "priority");
2300
+ const attachments = args.attachments === undefined ? undefined : optionalAttachmentList(args.attachments);
2301
+ if (to) {
2302
+ ensureValidEmails(to, "to");
2303
+ }
2304
+ if (cc) {
2305
+ ensureValidEmails(cc, "cc");
2306
+ }
2307
+ if (bcc) {
2308
+ ensureValidEmails(bcc, "bcc");
2309
+ }
2310
+ if (replyTo && !isValidEmail(replyTo)) {
2311
+ throw new McpError(ErrorCode.InvalidParams, "replyTo must be a valid email address.");
2312
+ }
2313
+ const result = await withAudit(auditService, name, args, async () => {
2314
+ const draft = await draftStore.updateDraft(draftId, {
2315
+ to,
2316
+ cc,
2317
+ bcc,
2318
+ subject: optionalString(args, "subject"),
2319
+ body: optionalString(args, "body"),
2320
+ isHtml: typeof args.isHtml === "boolean" ? args.isHtml : undefined,
2321
+ priority: priority === "high" || priority === "low" || priority === "normal"
2322
+ ? priority
2323
+ : undefined,
2324
+ replyTo,
2325
+ attachments,
2326
+ notes: optionalString(args, "notes"),
2327
+ });
2328
+ const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
2329
+ const synced = remoteSyncDecision.enabled
2330
+ ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
2331
+ : { draft, remoteSync: undefined };
2332
+ const remoteSync = synced.remoteSync ??
2333
+ (remoteSyncDecision.reason
2334
+ ? {
2335
+ ok: false,
2336
+ skipped: true,
2337
+ message: remoteSyncDecision.reason,
2338
+ }
2339
+ : undefined);
2340
+ return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
2341
+ });
2342
+ return createTextResult(result, false, [
2343
+ draftSource(result),
2344
+ ...(result.remoteDraft?.emailId
2345
+ ? [
2346
+ emailSource({
2347
+ id: result.remoteDraft.emailId,
2348
+ subject: result.subject,
2349
+ folder: result.remoteDraft.folder,
2350
+ })
2351
+ ]
2352
+ : []),
2353
+ ]);
2354
+ }
2355
+ case "sync_draft_to_remote": {
2356
+ ensureRemoteDraftSyncAllowed(config.runtime);
2357
+ const draft = await draftStore.getDraft(requireString(args, "draftId"));
2358
+ const synced = await withAudit(auditService, name, args, async () => syncDraftToRemote(draftStore, smtpService, imapService, draft));
2359
+ return createTextResult({ ...synced.draft, remoteSync: synced.remoteSync }, false, [
2360
+ draftSource(synced.draft),
2361
+ ...(synced.draft.remoteDraft?.emailId
2362
+ ? [
2363
+ emailSource({
2364
+ id: synced.draft.remoteDraft.emailId,
2365
+ subject: synced.draft.subject,
2366
+ folder: synced.draft.remoteDraft.folder,
2367
+ })
2368
+ ]
2369
+ : []),
2370
+ ]);
2371
+ }
2372
+ case "send_draft": {
2373
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send draft ${String(args.draftId ?? "?")}`);
2374
+ ensureSendAllowed(config.runtime);
2375
+ const draft = await draftStore.getDraft(requireString(args, "draftId"));
2376
+ ensureValidEmails(draft.to, "to");
2377
+ ensureValidEmails(draft.cc, "cc");
2378
+ ensureValidEmails(draft.bcc, "bcc");
2379
+ if (draft.replyTo && !isValidEmail(draft.replyTo)) {
2380
+ throw new McpError(ErrorCode.InvalidParams, "replyTo must be a valid email address.");
2381
+ }
2382
+ const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
2383
+ to: draft.to,
2384
+ cc: draft.cc,
2385
+ bcc: draft.bcc,
2386
+ subject: draft.subject,
2387
+ body: draft.body,
2388
+ isHtml: draft.isHtml,
2389
+ priority: draft.priority,
2390
+ replyTo: draft.replyTo,
2391
+ inReplyTo: draft.inReplyTo,
2392
+ references: draft.references,
2393
+ attachments: draft.attachments,
2394
+ }));
2395
+ let sentDraft = await draftStore.markSent(draft.id, {
2396
+ messageId: result.messageId,
2397
+ accepted: result.accepted,
2398
+ rejected: result.rejected,
2399
+ response: result.response,
2400
+ });
2401
+ const remoteCleanup = resolveRemoteDraftSync(config.runtime, true).enabled
2402
+ ? await clearRemoteDraft(draftStore, imapService, sentDraft)
2403
+ : {
2404
+ draft: sentDraft,
2405
+ remoteDelete: sentDraft.remoteDraft?.emailId
2406
+ ? {
2407
+ ok: false,
2408
+ skipped: true,
2409
+ message: "Remote draft cleanup skipped by runtime policy.",
2410
+ }
2411
+ : undefined,
2412
+ };
2413
+ sentDraft = remoteCleanup.draft;
2414
+ const sources = [draftSource(sentDraft)];
2415
+ if (sentDraft.sourceEmailId) {
2416
+ sources.push(emailSource(await imapService.getEmailById(sentDraft.sourceEmailId)));
2417
+ }
2418
+ return createTextResult({
2419
+ draftId: sentDraft.id,
2420
+ status: sentDraft.status,
2421
+ messageId: result.messageId,
2422
+ accepted: result.accepted,
2423
+ rejected: result.rejected,
2424
+ response: result.response,
2425
+ remoteDelete: remoteCleanup.remoteDelete,
2426
+ }, false, sources);
2427
+ }
2428
+ case "delete_draft": {
2429
+ const draft = await draftStore.getDraft(requireString(args, "draftId"));
2430
+ const deleted = await withAudit(auditService, name, args, async () => {
2431
+ const remoteCleanup = resolveRemoteDraftSync(config.runtime, true).enabled
2432
+ ? await clearRemoteDraft(draftStore, imapService, draft)
2433
+ : {
2434
+ draft,
2435
+ remoteDelete: draft.remoteDraft?.emailId
2436
+ ? {
2437
+ ok: false,
2438
+ skipped: true,
2439
+ message: "Remote draft cleanup skipped by runtime policy.",
2440
+ }
2441
+ : undefined,
2442
+ };
2443
+ return {
2444
+ ...(await draftStore.deleteDraft(draft.id)),
2445
+ remoteDelete: remoteCleanup.remoteDelete,
2446
+ };
2447
+ });
2448
+ return createTextResult(deleted);
2449
+ }
2450
+ case "get_emails": {
2451
+ const result = await imapService.getEmails({
2452
+ folder: optionalString(args, "folder"),
2453
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2454
+ offset: typeof args.offset === "number" ? args.offset : undefined,
2455
+ });
2456
+ return createTextResult(result, false, result.emails.map(emailSource));
2457
+ }
2458
+ case "get_email_by_id": {
2459
+ const detail = await imapService.getEmailById(requireString(args, "emailId"));
2460
+ return createTextResult(detail, false, [emailSource(detail), ...detail.attachments.map((attachment) => attachmentSource(detail.id, attachment))]);
2461
+ }
2462
+ case "search_emails": {
2463
+ const result = await imapService.searchEmails({
2464
+ query: optionalString(args, "query"),
2465
+ folder: optionalString(args, "folder"),
2466
+ label: optionalString(args, "label"),
2467
+ threadId: optionalString(args, "threadId"),
2468
+ from: optionalString(args, "from"),
2469
+ to: optionalString(args, "to"),
2470
+ subject: optionalString(args, "subject"),
2471
+ hasAttachment: typeof args.hasAttachment === "boolean" ? args.hasAttachment : undefined,
2472
+ attachmentName: optionalString(args, "attachmentName"),
2473
+ isRead: typeof args.isRead === "boolean" ? args.isRead : undefined,
2474
+ isStarred: typeof args.isStarred === "boolean" ? args.isStarred : undefined,
2475
+ dateFrom: optionalString(args, "dateFrom"),
2476
+ dateTo: optionalString(args, "dateTo"),
2477
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2478
+ });
2479
+ return createTextResult(result, false, result.emails.map(emailSource));
2480
+ }
2481
+ case "get_folders":
2482
+ return createTextResult(await imapService.getFolders());
2483
+ case "sync_folders":
2484
+ return createTextResult(await imapService.syncFolders());
2485
+ case "create_folder":
2486
+ ensureMailboxWriteAllowed(config.runtime);
2487
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.createFolder(requireString(args, "path"))));
2488
+ case "rename_folder":
2489
+ ensureMailboxWriteAllowed(config.runtime);
2490
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.renameFolder(requireString(args, "path"), requireString(args, "newPath"))));
2491
+ case "delete_folder":
2492
+ ensureMailboxWriteAllowed(config.runtime);
2493
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteFolder(requireString(args, "path"))));
2494
+ case "mark_email_read":
2495
+ ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isRead, true) ? "mark_read" : "mark_unread");
2496
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.markEmailRead(requireString(args, "emailId"), normalizeBoolean(args.isRead, true))));
2497
+ case "star_email":
2498
+ ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isStarred, true) ? "star" : "unstar");
2499
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(requireString(args, "emailId"), normalizeBoolean(args.isStarred, true))));
2500
+ case "move_email":
2501
+ {
2502
+ ensureMailboxWriteAllowed(config.runtime);
2503
+ const result = await withAudit(auditService, name, args, async () => imapService.moveEmail(requireString(args, "emailId"), requireString(args, "targetFolder")));
2504
+ const sources = result.targetEmailId
2505
+ ? [
2506
+ {
2507
+ uri: buildEmailResourceUri(result.targetEmailId),
2508
+ name: result.targetEmailId,
2509
+ title: `Moved email ${result.targetEmailId}`,
2510
+ description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
2511
+ mimeType: "message/rfc822",
2512
+ },
2513
+ ]
2514
+ : [];
2515
+ return createTextResult(result, false, sources);
2516
+ }
2517
+ case "archive_email":
2518
+ {
2519
+ ensureEmailActionAllowed(config.runtime, "archive");
2520
+ const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(requireString(args, "emailId")));
2521
+ const sources = result.targetEmailId
2522
+ ? [
2523
+ {
2524
+ uri: buildEmailResourceUri(result.targetEmailId),
2525
+ name: result.targetEmailId,
2526
+ title: `Archived email ${result.targetEmailId}`,
2527
+ description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
2528
+ mimeType: "message/rfc822",
2529
+ },
2530
+ ]
2531
+ : [];
2532
+ return createTextResult(result, false, sources);
2533
+ }
2534
+ case "trash_email":
2535
+ {
2536
+ ensureEmailActionAllowed(config.runtime, "trash");
2537
+ const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(requireString(args, "emailId")));
2538
+ const sources = result.targetEmailId
2539
+ ? [
2540
+ {
2541
+ uri: buildEmailResourceUri(result.targetEmailId),
2542
+ name: result.targetEmailId,
2543
+ title: `Trashed email ${result.targetEmailId}`,
2544
+ description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
2545
+ mimeType: "message/rfc822",
2546
+ },
2547
+ ]
2548
+ : [];
2549
+ return createTextResult(result, false, sources);
2550
+ }
2551
+ case "restore_email":
2552
+ {
2553
+ ensureEmailActionAllowed(config.runtime, "restore");
2554
+ const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(requireString(args, "emailId"), optionalString(args, "targetFolder")));
2555
+ const sources = result.targetEmailId
2556
+ ? [
2557
+ {
2558
+ uri: buildEmailResourceUri(result.targetEmailId),
2559
+ name: result.targetEmailId,
2560
+ title: `Restored email ${result.targetEmailId}`,
2561
+ description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
2562
+ mimeType: "message/rfc822",
2563
+ },
2564
+ ]
2565
+ : [];
2566
+ return createTextResult(result, false, sources);
2567
+ }
2568
+ case "delete_email":
2569
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${String(args.emailId ?? "?")} (cannot be recovered)`);
2570
+ ensureMailboxWriteAllowed(config.runtime);
2571
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteEmail(requireString(args, "emailId"))));
2572
+ case "batch_email_action":
2573
+ {
2574
+ const emailIds = [...new Set(parseStringListArg(args, "emailIds"))];
2575
+ if (emailIds.length === 0) {
2576
+ throw new McpError(ErrorCode.InvalidParams, "emailIds must contain at least one email id.");
2577
+ }
2578
+ const action = requireEmailAction(args);
2579
+ ensureEmailActionAllowed(config.runtime, action);
2580
+ const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
2581
+ emailIds,
2582
+ action,
2583
+ targetFolder: optionalString(args, "targetFolder"),
2584
+ continueOnError: normalizeBoolean(args.continueOnError, true),
2585
+ dryRun: normalizeBoolean(args.dryRun, false),
2586
+ }));
2587
+ const sources = result.results.flatMap((entry) => entry.ok ? emailSourceFromActionResult(entry.result) : []);
2588
+ return createTextResult(result, false, sources);
2589
+ }
2590
+ case "get_email_stats": {
2591
+ const folders = await imapService.getFolders();
2592
+ const sample = await imapService.getAnalyticsSample(30, 100);
2593
+ return createTextResult(analyticsService.getEmailStats(sample, folders, config.smtp.username));
2594
+ }
2595
+ case "get_email_analytics": {
2596
+ const sample = await imapService.getAnalyticsSample(30, 100);
2597
+ return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username));
2598
+ }
2599
+ case "get_contacts": {
2600
+ const limit = normalizeLimit(args.limit, 100);
2601
+ const sample = await imapService.getAnalyticsSample(30, limit);
2602
+ return createTextResult(analyticsService.getContacts(sample, limit, config.smtp.username));
2603
+ }
2604
+ case "get_volume_trends": {
2605
+ const days = normalizeLimit(args.days, 30, 1, 365);
2606
+ const sample = await imapService.getAnalyticsSample(days, 150);
2607
+ return createTextResult(analyticsService.getVolumeTrends(sample, days));
2608
+ }
2609
+ case "get_connection_status": {
2610
+ const [smtpStatus, imapStatus] = await Promise.allSettled([
2611
+ smtpService.verifyConnection(),
2612
+ imapService.ping(),
2613
+ ]);
2614
+ return createTextResult({
2615
+ checkedAt: new Date().toISOString(),
2616
+ smtp: {
2617
+ ok: smtpStatus.status === "fulfilled",
2618
+ message: smtpStatus.status === "fulfilled"
2619
+ ? "SMTP connection verified."
2620
+ : smtpStatus.reason instanceof Error
2621
+ ? smtpStatus.reason.message
2622
+ : String(smtpStatus.reason),
2623
+ },
2624
+ imap: {
2625
+ ok: imapStatus.status === "fulfilled",
2626
+ connected: imapService.isConnected(),
2627
+ idle: imapService.getIdleStatus(),
2628
+ message: imapStatus.status === "fulfilled"
2629
+ ? "IMAP connection verified."
2630
+ : imapStatus.reason instanceof Error
2631
+ ? imapStatus.reason.message
2632
+ : String(imapStatus.reason),
2633
+ },
2634
+ });
2635
+ }
2636
+ case "run_doctor": {
2637
+ const includeSmtp = normalizeBoolean(args.includeSmtp, true);
2638
+ const includeImap = normalizeBoolean(args.includeImap, true);
2639
+ const includeIdleProbe = normalizeBoolean(args.includeIdleProbe, false);
2640
+ const idleTimeoutSeconds = normalizeLimit(args.idleTimeoutSeconds, 5, 1, 60);
2641
+ const [smtpStatus, imapStatus, indexStatus, integrity] = await Promise.all([
2642
+ includeSmtp
2643
+ ? Promise.allSettled([smtpService.verifyConnection()]).then(([result]) => result)
2644
+ : Promise.resolve({ status: "fulfilled", value: undefined }),
2645
+ includeImap
2646
+ ? Promise.allSettled([imapService.ping()]).then(([result]) => result)
2647
+ : Promise.resolve({ status: "fulfilled", value: undefined }),
2648
+ localIndexService.getStatus(),
2649
+ localIndexService.runIntegrityCheck(),
2650
+ ]);
2651
+ const idleProbe = includeIdleProbe
2652
+ ? await Promise.allSettled([
2653
+ imapService.waitForMailboxChanges({
2654
+ folder: config.runtime.autoSyncFolder,
2655
+ timeoutMs: idleTimeoutSeconds * 1000,
2656
+ }),
2657
+ ]).then(([result]) => result)
2658
+ : undefined;
2659
+ return createTextResult({
2660
+ checkedAt: new Date().toISOString(),
2661
+ runtime: sanitizeRuntimeConfig(config.runtime),
2662
+ smtp: {
2663
+ ok: smtpStatus.status === "fulfilled",
2664
+ enabled: includeSmtp,
2665
+ message: smtpStatus.status === "fulfilled"
2666
+ ? "SMTP connection verified."
2667
+ : smtpStatus.reason instanceof Error
2668
+ ? smtpStatus.reason.message
2669
+ : String(smtpStatus.reason),
2670
+ },
2671
+ imap: {
2672
+ ok: imapStatus.status === "fulfilled",
2673
+ enabled: includeImap,
2674
+ idle: imapService.getIdleStatus(),
2675
+ message: imapStatus.status === "fulfilled"
2676
+ ? "IMAP connection verified."
2677
+ : imapStatus.reason instanceof Error
2678
+ ? imapStatus.reason.message
2679
+ : String(imapStatus.reason),
2680
+ },
2681
+ idleProbe: idleProbe === undefined
2682
+ ? { skipped: true }
2683
+ : idleProbe.status === "fulfilled"
2684
+ ? idleProbe.value
2685
+ : {
2686
+ ok: false,
2687
+ error: idleProbe.reason instanceof Error ? idleProbe.reason.message : String(idleProbe.reason),
2688
+ },
2689
+ backgroundSync: backgroundSyncService.getStatus(),
2690
+ index: indexStatus,
2691
+ integrity,
2692
+ audit: {
2693
+ path: auditService.getPath(),
2694
+ },
2695
+ });
2696
+ }
2697
+ case "get_runtime_status": {
2698
+ const [indexStatus, drafts] = await Promise.all([
2699
+ localIndexService.getStatus(),
2700
+ draftStore.listDrafts(true),
2701
+ ]);
2702
+ return createTextResult({
2703
+ checkedAt: new Date().toISOString(),
2704
+ runtime: sanitizeRuntimeConfig(config.runtime),
2705
+ backgroundSync: backgroundSyncService.getStatus(),
2706
+ imapIdle: imapService.getIdleStatus(),
2707
+ index: indexStatus,
2708
+ audit: {
2709
+ path: auditService.getPath(),
2710
+ },
2711
+ drafts: {
2712
+ total: drafts.length,
2713
+ active: drafts.filter((draft) => draft.status === "draft").length,
2714
+ remoteSynced: drafts.filter((draft) => draft.remoteSyncState === "synced").length,
2715
+ syncFailed: drafts.filter((draft) => draft.remoteSyncState === "sync_failed").length,
2716
+ },
2717
+ });
2718
+ }
2719
+ case "run_background_sync":
2720
+ return createTextResult({
2721
+ checkedAt: new Date().toISOString(),
2722
+ backgroundSync: await backgroundSyncService.runNow(),
2723
+ index: await localIndexService.getStatus(),
2724
+ });
2725
+ case "wait_for_mailbox_changes":
2726
+ return createTextResult(await imapService.waitForMailboxChanges({
2727
+ folder: optionalString(args, "folder"),
2728
+ timeoutMs: normalizeLimit(args.timeoutSeconds, 15, 1, 300) * 1000,
2729
+ }));
2730
+ case "sync_emails":
2731
+ {
2732
+ const checkpoints = await localIndexService.getSyncCheckpointMap();
2733
+ const snapshot = await imapService.collectEmailsForIndex({
2734
+ folder: optionalString(args, "folder"),
2735
+ full: normalizeBoolean(args.full, false),
2736
+ limitPerFolder: typeof args.limitPerFolder === "number" ? args.limitPerFolder : undefined,
2737
+ includeAttachmentText: normalizeBoolean(args.includeAttachmentText, true),
2738
+ checkpoints,
2739
+ });
2740
+ const indexStatus = await localIndexService.recordSnapshot({
2741
+ folders: snapshot.folders,
2742
+ emails: snapshot.emails,
2743
+ syncedAt: snapshot.syncedAt,
2744
+ folderStats: snapshot.folderStats,
2745
+ });
2746
+ return createTextResult({
2747
+ syncedAt: snapshot.syncedAt,
2748
+ full: snapshot.full,
2749
+ folders: snapshot.folderStats,
2750
+ cachedMessages: snapshot.emails.length,
2751
+ index: {
2752
+ updatedAt: indexStatus.updatedAt,
2753
+ storedMessageCount: indexStatus.storedMessageCount,
2754
+ dedupedMessageCount: indexStatus.dedupedMessageCount,
2755
+ path: indexStatus.path,
2756
+ },
2757
+ });
2758
+ }
2759
+ case "get_index_status":
2760
+ return createTextResult(await localIndexService.getStatus());
2761
+ case "search_indexed_emails":
2762
+ {
2763
+ const result = await localIndexService.search({
2764
+ query: optionalString(args, "query"),
2765
+ folder: optionalString(args, "folder"),
2766
+ label: optionalString(args, "label"),
2767
+ threadId: optionalString(args, "threadId"),
2768
+ from: optionalString(args, "from"),
2769
+ to: optionalString(args, "to"),
2770
+ senderDomain: optionalString(args, "senderDomain"),
2771
+ subject: optionalString(args, "subject"),
2772
+ hasAttachment: typeof args.hasAttachment === "boolean" ? args.hasAttachment : undefined,
2773
+ attachmentName: optionalString(args, "attachmentName"),
2774
+ isRead: typeof args.isRead === "boolean" ? args.isRead : undefined,
2775
+ isStarred: typeof args.isStarred === "boolean" ? args.isStarred : undefined,
2776
+ mailboxRole: optionalString(args, "mailboxRole"),
2777
+ dateFrom: optionalString(args, "dateFrom"),
2778
+ dateTo: optionalString(args, "dateTo"),
2779
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2780
+ });
2781
+ return createTextResult(result, false, result.emails.map(emailSource));
2782
+ }
2783
+ case "get_labels":
2784
+ {
2785
+ const labels = await localIndexService.getLabels(normalizeLimit(args.limit, 250));
2786
+ return createTextResult({
2787
+ total: labels.length,
2788
+ labels,
2789
+ });
2790
+ }
2791
+ case "get_threads":
2792
+ {
2793
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2794
+ folder: "INBOX",
2795
+ limitPerFolder: 100,
2796
+ });
2797
+ const result = await localIndexService.getThreads({
2798
+ query: optionalString(args, "query"),
2799
+ label: optionalString(args, "label"),
2800
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2801
+ });
2802
+ return createTextResult(result, false, result.threads.map(threadSource));
2803
+ }
2804
+ case "get_actionable_threads":
2805
+ {
2806
+ const refresh = await maybeRefreshLocalIndex(imapService, localIndexService, {
2807
+ force: normalizeBoolean(args.syncBefore, false),
2808
+ folder: "INBOX",
2809
+ limitPerFolder: 100,
2810
+ });
2811
+ const result = await localIndexService.getActionableThreads({
2812
+ query: optionalString(args, "query"),
2813
+ label: optionalString(args, "label"),
2814
+ pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
2815
+ ? args.pendingOn
2816
+ : undefined,
2817
+ unreadOnly: normalizeBoolean(args.unreadOnly, true),
2818
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2819
+ });
2820
+ return createTextResult(refresh ? { ...result, indexUpdatedAt: refresh.indexStatus.updatedAt } : result, false, result.threads.map(threadSource));
2821
+ }
2822
+ case "get_inbox_digest":
2823
+ {
2824
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2825
+ force: normalizeBoolean(args.syncBefore, false),
2826
+ folder: "INBOX",
2827
+ limitPerFolder: 100,
2828
+ });
2829
+ const result = await localIndexService.getInboxDigest({
2830
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2831
+ minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
2832
+ });
2833
+ const topThreads = Array.isArray(result.topThreads) ? result.topThreads : [];
2834
+ const staleThreads = Array.isArray(result.staleAwaitingYou) ? result.staleAwaitingYou : [];
2835
+ return createTextResult(result, false, [...topThreads, ...staleThreads]
2836
+ .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
2837
+ .map(threadSource));
2838
+ }
2839
+ case "get_follow_up_candidates":
2840
+ {
2841
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2842
+ force: normalizeBoolean(args.syncBefore, false),
2843
+ folder: "INBOX",
2844
+ limitPerFolder: 100,
2845
+ });
2846
+ const result = await localIndexService.getFollowUpCandidates({
2847
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2848
+ minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
2849
+ pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
2850
+ ? args.pendingOn
2851
+ : undefined,
2852
+ });
2853
+ return createTextResult(result, false, Array.isArray(result.threads)
2854
+ ? result.threads
2855
+ .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
2856
+ .map(threadSource)
2857
+ : []);
2858
+ }
2859
+ case "find_document_threads":
2860
+ {
2861
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2862
+ force: normalizeBoolean(args.syncBefore, false),
2863
+ folder: "INBOX",
2864
+ limitPerFolder: 100,
2865
+ });
2866
+ const result = await localIndexService.findDocumentThreads({
2867
+ category: args.category === "document" ||
2868
+ args.category === "invoice" ||
2869
+ args.category === "contract" ||
2870
+ args.category === "travel" ||
2871
+ args.category === "calendar"
2872
+ ? args.category
2873
+ : undefined,
2874
+ query: optionalString(args, "query"),
2875
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2876
+ });
2877
+ const threads = Array.isArray(result.threads) ? result.threads : [];
2878
+ return createTextResult(result, false, threads
2879
+ .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
2880
+ .map(threadSource));
2881
+ }
2882
+ case "prepare_meeting_context":
2883
+ {
2884
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2885
+ force: normalizeBoolean(args.syncBefore, false),
2886
+ folder: "INBOX",
2887
+ limitPerFolder: 100,
2888
+ });
2889
+ const result = await localIndexService.getMeetingPrep({
2890
+ person: optionalString(args, "person"),
2891
+ domain: optionalString(args, "domain"),
2892
+ limit: typeof args.limit === "number" ? args.limit : undefined,
2893
+ });
2894
+ const threads = Array.isArray(result.threads) ? result.threads : [];
2895
+ return createTextResult(result, false, threads
2896
+ .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
2897
+ .map(threadSource));
2898
+ }
2899
+ case "get_thread_brief":
2900
+ {
2901
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2902
+ folder: "INBOX",
2903
+ limitPerFolder: 100,
2904
+ });
2905
+ const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
2906
+ const result = buildThreadBrief(thread, config.smtp.username);
2907
+ return createTextResult(result, false, [
2908
+ threadSource(thread),
2909
+ ...thread.messages.map((message) => emailSource({
2910
+ id: message.primaryEmailId,
2911
+ subject: message.subject,
2912
+ folder: message.folder,
2913
+ date: message.date,
2914
+ internalDate: message.internalDate,
2915
+ preview: message.preview,
2916
+ from: message.from,
2917
+ messageId: message.messageId,
2918
+ threadId: thread.id,
2919
+ })),
2920
+ ]);
2921
+ }
2922
+ case "get_thread_by_id":
2923
+ {
2924
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2925
+ folder: "INBOX",
2926
+ limitPerFolder: 100,
2927
+ });
2928
+ const result = await localIndexService.getThreadById(requireString(args, "threadId"));
2929
+ return createTextResult(result, false, [threadSource(result), ...result.messages.map((message) => emailSource({
2930
+ id: message.primaryEmailId,
2931
+ subject: message.subject,
2932
+ folder: message.folder,
2933
+ date: message.date,
2934
+ internalDate: message.internalDate,
2935
+ }))]);
2936
+ }
2937
+ case "create_thread_reply_draft":
2938
+ {
2939
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
2940
+ force: normalizeBoolean(args.syncBefore, false),
2941
+ folder: "INBOX",
2942
+ limitPerFolder: 100,
2943
+ });
2944
+ const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
2945
+ const targetMessage = pickReplyTargetFromThread(thread, config.smtp.username, normalizeBoolean(args.preferLatestInbound, true));
2946
+ if (!targetMessage?.primaryEmailId) {
2947
+ throw new McpError(ErrorCode.InvalidParams, "Unable to resolve a reply target from the thread.");
2948
+ }
2949
+ const detail = await imapService.getEmailById(targetMessage.primaryEmailId);
2950
+ const body = requireString(args, "body");
2951
+ const isHtml = normalizeBoolean(args.isHtml, false);
2952
+ const replyAll = normalizeBoolean(args.replyAll, false);
2953
+ const attachments = optionalAttachmentList(args.attachments);
2954
+ const extraCc = parseEmails(optionalString(args, "cc"));
2955
+ const extraBcc = parseEmails(optionalString(args, "bcc"));
2956
+ const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
2957
+ const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
2958
+ const to = uniqueAddresses(recipients.to);
2959
+ ensureValidEmails(to, "to");
2960
+ ensureValidEmails(cc, "cc");
2961
+ ensureValidEmails(extraBcc, "bcc");
2962
+ if (to.length === 0) {
2963
+ throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
2964
+ }
2965
+ const result = await withAudit(auditService, name, args, async () => {
2966
+ const draft = await draftStore.createDraft({
2967
+ mode: "reply",
2968
+ to,
2969
+ cc,
2970
+ bcc: extraBcc,
2971
+ subject: prefixedSubject(detail.subject, "Re:"),
2972
+ body: buildReplyText(detail, body),
2973
+ isHtml,
2974
+ inReplyTo: detail.messageId,
2975
+ references: detail.messageId ? [detail.messageId] : undefined,
2976
+ attachments,
2977
+ sourceEmailId: detail.id,
2978
+ sourceMessageId: detail.messageId,
2979
+ notes: optionalString(args, "notes"),
2980
+ });
2981
+ const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
2982
+ const synced = remoteSyncDecision.enabled
2983
+ ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
2984
+ : { draft, remoteSync: undefined };
2985
+ const remoteSync = synced.remoteSync ??
2986
+ (remoteSyncDecision.reason
2987
+ ? {
2988
+ ok: false,
2989
+ skipped: true,
2990
+ message: remoteSyncDecision.reason,
2991
+ }
2992
+ : undefined);
2993
+ return remoteSync
2994
+ ? { ...synced.draft, remoteSync, threadId: thread.id }
2995
+ : { ...synced.draft, threadId: thread.id };
2996
+ });
2997
+ return createTextResult(result, false, [
2998
+ draftSource(result),
2999
+ threadSource(thread),
3000
+ emailSource(detail),
3001
+ ...(result.remoteDraft?.emailId
3002
+ ? [
3003
+ emailSource({
3004
+ id: result.remoteDraft.emailId,
3005
+ subject: result.subject,
3006
+ folder: result.remoteDraft.folder,
3007
+ })
3008
+ ]
3009
+ : []),
3010
+ ]);
3011
+ }
3012
+ case "apply_thread_action":
3013
+ {
3014
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
3015
+ force: normalizeBoolean(args.syncBefore, false),
3016
+ folder: "INBOX",
3017
+ limitPerFolder: 100,
3018
+ });
3019
+ const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
3020
+ const action = requireEmailAction(args);
3021
+ ensureEmailActionAllowed(config.runtime, action);
3022
+ const unreadOnly = normalizeBoolean(args.unreadOnly, false);
3023
+ const emailIds = [...new Set(thread.messages
3024
+ .filter((message) => !unreadOnly || !message.isRead)
3025
+ .map((message) => message.primaryEmailId))];
3026
+ const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
3027
+ emailIds,
3028
+ action,
3029
+ targetFolder: optionalString(args, "targetFolder"),
3030
+ continueOnError: normalizeBoolean(args.continueOnError, true),
3031
+ dryRun: normalizeBoolean(args.dryRun, false),
3032
+ }));
3033
+ const sources = [
3034
+ threadSource(thread),
3035
+ ...result.results.flatMap((entry) => (entry.ok ? emailSourceFromActionResult(entry.result) : [])),
3036
+ ];
3037
+ return createTextResult({
3038
+ threadId: thread.id,
3039
+ unreadOnly,
3040
+ dryRun: normalizeBoolean(args.dryRun, false),
3041
+ ...result,
3042
+ }, false, sources);
3043
+ }
3044
+ case "list_attachments":
3045
+ {
3046
+ const attachmentList = await imapService.listAttachments(requireString(args, "emailId"));
3047
+ const includeInline = normalizeBoolean(args.includeInline, true);
3048
+ const filenameContains = optionalString(args, "filenameContains");
3049
+ const contentType = optionalString(args, "contentType");
3050
+ const filtered = attachmentList.attachments.filter((attachment) => {
3051
+ if (!includeInline && attachment.isInline) {
3052
+ return false;
3053
+ }
3054
+ if (filenameContains &&
3055
+ !(attachment.filename || "").toLowerCase().includes(filenameContains.toLowerCase())) {
3056
+ return false;
3057
+ }
3058
+ if (contentType &&
3059
+ (attachment.contentType || "").toLowerCase() !== contentType.toLowerCase()) {
3060
+ return false;
3061
+ }
3062
+ return true;
3063
+ });
3064
+ const result = {
3065
+ emailId: attachmentList.emailId,
3066
+ attachments: filtered,
3067
+ };
3068
+ return createTextResult(result, false, result.attachments.map((attachment) => attachmentSource(result.emailId, attachment)));
3069
+ }
3070
+ case "get_attachment_content":
3071
+ {
3072
+ const result = await imapService.getAttachmentContent(requireString(args, "emailId"), requireString(args, "attachmentId"), normalizeBoolean(args.includeBase64, false));
3073
+ return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
3074
+ }
3075
+ case "save_attachment":
3076
+ {
3077
+ const result = await imapService.saveAttachment(requireString(args, "emailId"), requireString(args, "attachmentId"), optionalString(args, "outputPath"));
3078
+ return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
3079
+ }
3080
+ case "save_attachments":
3081
+ {
3082
+ const result = await imapService.saveAttachments({
3083
+ emailId: requireString(args, "emailId"),
3084
+ outputPath: optionalString(args, "outputPath"),
3085
+ includeInline: normalizeBoolean(args.includeInline, false),
3086
+ filenameContains: optionalString(args, "filenameContains"),
3087
+ contentType: optionalString(args, "contentType"),
3088
+ });
3089
+ return createTextResult(result, false, result.saved.map((entry) => attachmentSource(result.emailId, entry.attachment)));
3090
+ }
3091
+ case "clear_cache":
3092
+ analyticsService.clearCache();
3093
+ return createTextResult({
3094
+ clearedAt: new Date().toISOString(),
3095
+ ...imapService.clearCache(),
3096
+ });
3097
+ case "clear_index":
3098
+ return createTextResult({
3099
+ clearedAt: new Date().toISOString(),
3100
+ ...(await localIndexService.clear()),
3101
+ });
3102
+ case "get_logs":
3103
+ return createTextResult(logger.getLogs({
3104
+ level: args.level === "debug" ||
3105
+ args.level === "info" ||
3106
+ args.level === "warn" ||
3107
+ args.level === "error"
3108
+ ? args.level
3109
+ : undefined,
3110
+ limit: normalizeLimit(args.limit, 100),
3111
+ }));
3112
+ case "get_audit_logs":
3113
+ return createTextResult(await auditService.list(normalizeLimit(args.limit, 100)));
3114
+ default:
3115
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
3116
+ }
3117
+ }
3118
+ catch (error) {
3119
+ logger.error("Tool call failed", "MCPServer", { name, error });
3120
+ if (error instanceof McpError) {
3121
+ return createTextResult(error.message, true);
3122
+ }
3123
+ return createTextResult(error instanceof Error ? error.message : String(error), true);
3124
+ }
3125
+ });
3126
+ return {
3127
+ server,
3128
+ smtpService,
3129
+ imapService,
3130
+ localIndexService,
3131
+ draftStore,
3132
+ backgroundSyncService,
3133
+ auditService,
3134
+ };
3135
+ }
3136
+ export async function main() {
3137
+ const config = buildConfigFromEnv();
3138
+ const { server, smtpService, imapService, backgroundSyncService } = createServer(config, {
3139
+ startBackgroundSync: true,
3140
+ });
3141
+ logger.info("Starting ProtonMail MCP server", "MCPServer");
3142
+ const transport = new StdioServerTransport();
3143
+ await server.connect(transport);
3144
+ logger.info("ProtonMail MCP server ready", "MCPServer");
3145
+ const shutdown = async (signal) => {
3146
+ logger.info(`Received ${signal}, shutting down`, "MCPServer");
3147
+ backgroundSyncService.stop();
3148
+ await Promise.allSettled([imapService.disconnect(), smtpService.close()]);
3149
+ process.exit(0);
3150
+ };
3151
+ process.on("SIGINT", () => {
3152
+ void shutdown("SIGINT");
3153
+ });
3154
+ process.on("SIGTERM", () => {
3155
+ void shutdown("SIGTERM");
3156
+ });
3157
+ }
3158
+ process.on("uncaughtException", (error) => {
3159
+ logger.error("Uncaught exception", "MCPServer", error);
3160
+ process.exit(1);
3161
+ });
3162
+ process.on("unhandledRejection", (reason) => {
3163
+ logger.error("Unhandled rejection", "MCPServer", reason);
3164
+ process.exit(1);
3165
+ });
3166
+ const isDirectExecution = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
3167
+ if (isDirectExecution) {
3168
+ main().catch((error) => {
3169
+ logger.error("Fatal server error", "MCPServer", error);
3170
+ process.exit(1);
3171
+ });
3172
+ }
3173
+ //# sourceMappingURL=index.js.map