fastmail-mcp-server 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import {
6
+ buildReply,
7
+ downloadAttachment,
8
+ getAttachments,
9
+ getEmail,
10
+ getMailboxByName,
11
+ listEmails,
12
+ listMailboxes,
13
+ markAsRead,
14
+ markAsSpam,
15
+ moveEmail,
16
+ searchEmails,
17
+ sendEmail,
18
+ } from "./jmap/methods.js";
19
+ import type { Email, EmailAddress, Mailbox } from "./jmap/types.js";
20
+
21
+ const server = new McpServer({
22
+ name: "fastmail",
23
+ version: "0.1.0",
24
+ });
25
+
26
+ // ============ Formatters ============
27
+
28
+ function formatAddress(addr: EmailAddress): string {
29
+ if (addr.name) {
30
+ return `${addr.name} <${addr.email}>`;
31
+ }
32
+ return addr.email;
33
+ }
34
+
35
+ function formatAddressList(addrs: EmailAddress[] | null): string {
36
+ if (!addrs || addrs.length === 0) return "(none)";
37
+ return addrs.map(formatAddress).join(", ");
38
+ }
39
+
40
+ function formatMailbox(m: Mailbox): string {
41
+ const role = m.role ? ` [${m.role}]` : "";
42
+ const unread = m.unreadEmails > 0 ? ` (${m.unreadEmails} unread)` : "";
43
+ return `${m.name}${role}${unread} - ${m.totalEmails} emails (id: ${m.id})`;
44
+ }
45
+
46
+ function formatEmailSummary(e: Email): string {
47
+ const from = formatAddressList(e.from);
48
+ const date = new Date(e.receivedAt).toLocaleString();
49
+ const attachment = e.hasAttachment ? " [attachment]" : "";
50
+ const unread = !e.keywords.$seen ? " [UNREAD]" : "";
51
+ return `${unread}${attachment}
52
+ ID: ${e.id}
53
+ From: ${from}
54
+ Subject: ${e.subject || "(no subject)"}
55
+ Date: ${date}
56
+ Preview: ${e.preview}`;
57
+ }
58
+
59
+ function formatEmailFull(e: Email): string {
60
+ const from = formatAddressList(e.from);
61
+ const to = formatAddressList(e.to);
62
+ const cc = formatAddressList(e.cc);
63
+ const date = new Date(e.receivedAt).toLocaleString();
64
+
65
+ // Get body text
66
+ let body = "";
67
+ if (e.bodyValues) {
68
+ // Prefer text body
69
+ const textPart = e.textBody?.[0];
70
+ if (textPart?.partId && e.bodyValues[textPart.partId]) {
71
+ body = e.bodyValues[textPart.partId]?.value ?? "";
72
+ } else {
73
+ // Fall back to first body value
74
+ const firstValue = Object.values(e.bodyValues)[0];
75
+ if (firstValue) {
76
+ body = firstValue.value;
77
+ }
78
+ }
79
+ }
80
+
81
+ return `ID: ${e.id}
82
+ Thread ID: ${e.threadId}
83
+ From: ${from}
84
+ To: ${to}
85
+ CC: ${cc}
86
+ Subject: ${e.subject || "(no subject)"}
87
+ Date: ${date}
88
+ Has Attachment: ${e.hasAttachment}
89
+
90
+ --- Body ---
91
+ ${body}`;
92
+ }
93
+
94
+ // ============ Read-Only Tools ============
95
+
96
+ server.tool(
97
+ "list_mailboxes",
98
+ "List all mailboxes (folders) in the account with their unread counts. Use this to discover available folders before listing emails.",
99
+ {},
100
+ async () => {
101
+ const mailboxes = await listMailboxes();
102
+ const sorted = mailboxes.sort((a, b) => {
103
+ // Put role-based mailboxes first
104
+ if (a.role && !b.role) return -1;
105
+ if (!a.role && b.role) return 1;
106
+ return a.name.localeCompare(b.name);
107
+ });
108
+
109
+ const text = sorted.map(formatMailbox).join("\n");
110
+ return { content: [{ type: "text" as const, text }] };
111
+ },
112
+ );
113
+
114
+ server.tool(
115
+ "list_emails",
116
+ "List emails in a specific mailbox/folder. Returns email summaries with ID, from, subject, date, and preview. Use the email ID with get_email for full content.",
117
+ {
118
+ mailbox: z
119
+ .string()
120
+ .describe(
121
+ "Mailbox name (e.g., 'INBOX', 'Sent', 'Archive') or role (e.g., 'inbox', 'sent', 'drafts', 'trash', 'junk')",
122
+ ),
123
+ limit: z
124
+ .number()
125
+ .optional()
126
+ .describe("Maximum number of emails to return (default 25, max 100)"),
127
+ },
128
+ async ({ mailbox, limit }) => {
129
+ const emails = await listEmails(mailbox, Math.min(limit || 25, 100));
130
+
131
+ if (emails.length === 0) {
132
+ return {
133
+ content: [{ type: "text" as const, text: `No emails in ${mailbox}` }],
134
+ };
135
+ }
136
+
137
+ const text = emails.map(formatEmailSummary).join("\n\n---\n\n");
138
+ return { content: [{ type: "text" as const, text }] };
139
+ },
140
+ );
141
+
142
+ server.tool(
143
+ "get_email",
144
+ "Get the full content of a specific email by its ID. Returns complete email with headers, body text, and attachment info.",
145
+ {
146
+ email_id: z
147
+ .string()
148
+ .describe("The email ID (obtained from list_emails or search_emails)"),
149
+ },
150
+ async ({ email_id }) => {
151
+ const email = await getEmail(email_id);
152
+
153
+ if (!email) {
154
+ return {
155
+ content: [
156
+ { type: "text" as const, text: `Email not found: ${email_id}` },
157
+ ],
158
+ };
159
+ }
160
+
161
+ const text = formatEmailFull(email);
162
+ return { content: [{ type: "text" as const, text }] };
163
+ },
164
+ );
165
+
166
+ server.tool(
167
+ "search_emails",
168
+ "Search for emails across all mailboxes. Supports full-text search of email content, subjects, and addresses.",
169
+ {
170
+ query: z
171
+ .string()
172
+ .describe(
173
+ "Search query - searches subject, body, and addresses. Examples: 'from:alice@example.com', 'subject:invoice', 'meeting notes'",
174
+ ),
175
+ limit: z
176
+ .number()
177
+ .optional()
178
+ .describe("Maximum number of results (default 25, max 100)"),
179
+ },
180
+ async ({ query, limit }) => {
181
+ const emails = await searchEmails(query, Math.min(limit || 25, 100));
182
+
183
+ if (emails.length === 0) {
184
+ return {
185
+ content: [
186
+ { type: "text" as const, text: `No emails found for: ${query}` },
187
+ ],
188
+ };
189
+ }
190
+
191
+ const text = emails.map(formatEmailSummary).join("\n\n---\n\n");
192
+ return { content: [{ type: "text" as const, text }] };
193
+ },
194
+ );
195
+
196
+ // ============ Write Tools (with safety) ============
197
+
198
+ server.tool(
199
+ "move_email",
200
+ "Move an email to a different mailbox/folder.",
201
+ {
202
+ email_id: z.string().describe("The email ID to move"),
203
+ target_mailbox: z
204
+ .string()
205
+ .describe(
206
+ "Target mailbox name (e.g., 'Archive', 'Trash') or role (e.g., 'archive', 'trash')",
207
+ ),
208
+ },
209
+ async ({ email_id, target_mailbox }) => {
210
+ // Get email info for confirmation message
211
+ const email = await getEmail(email_id);
212
+ if (!email) {
213
+ return {
214
+ content: [
215
+ { type: "text" as const, text: `Email not found: ${email_id}` },
216
+ ],
217
+ };
218
+ }
219
+
220
+ const targetBox = await getMailboxByName(target_mailbox);
221
+ if (!targetBox) {
222
+ return {
223
+ content: [
224
+ {
225
+ type: "text" as const,
226
+ text: `Mailbox not found: ${target_mailbox}`,
227
+ },
228
+ ],
229
+ };
230
+ }
231
+
232
+ await moveEmail(email_id, target_mailbox);
233
+
234
+ return {
235
+ content: [
236
+ {
237
+ type: "text" as const,
238
+ text: `Moved email "${email.subject}" to ${targetBox.name}`,
239
+ },
240
+ ],
241
+ };
242
+ },
243
+ );
244
+
245
+ server.tool(
246
+ "mark_as_read",
247
+ "Mark an email as read or unread.",
248
+ {
249
+ email_id: z.string().describe("The email ID"),
250
+ read: z
251
+ .boolean()
252
+ .optional()
253
+ .describe("true to mark read, false to mark unread (default: true)"),
254
+ },
255
+ async ({ email_id, read }) => {
256
+ const email = await getEmail(email_id);
257
+ if (!email) {
258
+ return {
259
+ content: [
260
+ { type: "text" as const, text: `Email not found: ${email_id}` },
261
+ ],
262
+ };
263
+ }
264
+
265
+ await markAsRead(email_id, read ?? true);
266
+
267
+ const status = (read ?? true) ? "read" : "unread";
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text" as const,
272
+ text: `Marked "${email.subject}" as ${status}`,
273
+ },
274
+ ],
275
+ };
276
+ },
277
+ );
278
+
279
+ server.tool(
280
+ "mark_as_spam",
281
+ "Mark an email as spam. This moves it to the Junk folder AND trains the spam filter. USE WITH CAUTION - this affects future filtering. Requires explicit confirmation.",
282
+ {
283
+ email_id: z.string().describe("The email ID to mark as spam"),
284
+ action: z
285
+ .enum(["preview", "confirm"])
286
+ .describe(
287
+ "'preview' to see what will happen, 'confirm' to actually mark as spam",
288
+ ),
289
+ },
290
+ async ({ email_id, action }) => {
291
+ const email = await getEmail(email_id);
292
+ if (!email) {
293
+ return {
294
+ content: [
295
+ { type: "text" as const, text: `Email not found: ${email_id}` },
296
+ ],
297
+ };
298
+ }
299
+
300
+ if (action === "preview") {
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text" as const,
305
+ text: `⚠️ SPAM PREVIEW - This will:
306
+ 1. Move the email to Junk folder
307
+ 2. Train the spam filter to mark similar emails as spam
308
+
309
+ Email: "${email.subject}"
310
+ From: ${formatAddressList(email.from)}
311
+
312
+ To proceed, call this tool again with action: "confirm"`,
313
+ },
314
+ ],
315
+ };
316
+ }
317
+
318
+ await markAsSpam(email_id);
319
+
320
+ return {
321
+ content: [
322
+ {
323
+ type: "text" as const,
324
+ text: `Marked as spam: "${email.subject}" from ${formatAddressList(email.from)}`,
325
+ },
326
+ ],
327
+ };
328
+ },
329
+ );
330
+
331
+ // ============ Send/Reply Tools (with preview→confirm flow) ============
332
+
333
+ server.tool(
334
+ "send_email",
335
+ "Compose and send a new email. ALWAYS use action='preview' first to review the draft before sending.",
336
+ {
337
+ action: z
338
+ .enum(["preview", "confirm"])
339
+ .describe("'preview' to see the draft, 'confirm' to send"),
340
+ to: z.string().describe("Recipient email address(es), comma-separated"),
341
+ subject: z.string().describe("Email subject line"),
342
+ body: z.string().describe("Email body text"),
343
+ cc: z.string().optional().describe("CC recipients, comma-separated"),
344
+ },
345
+ async ({ action, to, subject, body, cc }) => {
346
+ // Parse addresses
347
+ const parseAddresses = (s: string): EmailAddress[] =>
348
+ s.split(",").map((e) => ({ name: null, email: e.trim() }));
349
+
350
+ const toAddrs = parseAddresses(to);
351
+ const ccAddrs = cc ? parseAddresses(cc) : undefined;
352
+
353
+ if (action === "preview") {
354
+ return {
355
+ content: [
356
+ {
357
+ type: "text" as const,
358
+ text: `📧 EMAIL PREVIEW - Review before sending:
359
+
360
+ To: ${formatAddressList(toAddrs)}
361
+ CC: ${ccAddrs ? formatAddressList(ccAddrs) : "(none)"}
362
+ Subject: ${subject}
363
+
364
+ --- Body ---
365
+ ${body}
366
+
367
+ ---
368
+ To send this email, call this tool again with action: "confirm" and the same parameters.`,
369
+ },
370
+ ],
371
+ };
372
+ }
373
+
374
+ const emailId = await sendEmail({
375
+ to: toAddrs,
376
+ subject,
377
+ textBody: body,
378
+ cc: ccAddrs,
379
+ });
380
+
381
+ return {
382
+ content: [
383
+ {
384
+ type: "text" as const,
385
+ text: `✓ Email sent successfully!
386
+ To: ${formatAddressList(toAddrs)}
387
+ Subject: ${subject}
388
+ Email ID: ${emailId}`,
389
+ },
390
+ ],
391
+ };
392
+ },
393
+ );
394
+
395
+ server.tool(
396
+ "reply_to_email",
397
+ "Reply to an existing email thread. ALWAYS use action='preview' first to review the draft before sending.",
398
+ {
399
+ action: z
400
+ .enum(["preview", "confirm"])
401
+ .describe("'preview' to see the draft, 'confirm' to send"),
402
+ email_id: z.string().describe("The email ID to reply to"),
403
+ body: z
404
+ .string()
405
+ .describe("Reply body text (your response, without quoting original)"),
406
+ },
407
+ async ({ action, email_id, body }) => {
408
+ const replyParams = await buildReply(email_id, body);
409
+
410
+ if (action === "preview") {
411
+ return {
412
+ content: [
413
+ {
414
+ type: "text" as const,
415
+ text: `📧 REPLY PREVIEW - Review before sending:
416
+
417
+ To: ${formatAddressList(replyParams.to)}
418
+ Subject: ${replyParams.subject}
419
+ In-Reply-To: ${replyParams.inReplyTo || "(none)"}
420
+
421
+ --- Your Reply ---
422
+ ${body}
423
+
424
+ ---
425
+ To send this reply, call this tool again with action: "confirm" and the same parameters.`,
426
+ },
427
+ ],
428
+ };
429
+ }
430
+
431
+ const emailId = await sendEmail(replyParams);
432
+
433
+ return {
434
+ content: [
435
+ {
436
+ type: "text" as const,
437
+ text: `✓ Reply sent successfully!
438
+ To: ${formatAddressList(replyParams.to)}
439
+ Subject: ${replyParams.subject}
440
+ Email ID: ${emailId}`,
441
+ },
442
+ ],
443
+ };
444
+ },
445
+ );
446
+
447
+ // ============ Attachment Tools ============
448
+
449
+ server.tool(
450
+ "list_attachments",
451
+ "List all attachments on an email. Returns attachment names, types, sizes, and blob IDs for downloading.",
452
+ {
453
+ email_id: z.string().describe("The email ID to get attachments from"),
454
+ },
455
+ async ({ email_id }) => {
456
+ const attachments = await getAttachments(email_id);
457
+
458
+ if (attachments.length === 0) {
459
+ return {
460
+ content: [
461
+ { type: "text" as const, text: "No attachments on this email." },
462
+ ],
463
+ };
464
+ }
465
+
466
+ const lines = attachments.map((a, i) => {
467
+ const size =
468
+ a.size > 1024 * 1024
469
+ ? `${(a.size / 1024 / 1024).toFixed(1)} MB`
470
+ : a.size > 1024
471
+ ? `${(a.size / 1024).toFixed(1)} KB`
472
+ : `${a.size} bytes`;
473
+ return `${i + 1}. ${a.name || "(unnamed)"}\n Type: ${a.type}\n Size: ${size}\n Blob ID: ${a.blobId}`;
474
+ });
475
+
476
+ return {
477
+ content: [
478
+ {
479
+ type: "text" as const,
480
+ text: `Attachments (${attachments.length}):\n\n${lines.join("\n\n")}`,
481
+ },
482
+ ],
483
+ };
484
+ },
485
+ );
486
+
487
+ server.tool(
488
+ "get_attachment",
489
+ "Download and read an attachment's content. Works best with text-based files (txt, csv, json, xml, html, etc). Binary files are returned as base64.",
490
+ {
491
+ email_id: z.string().describe("The email ID the attachment belongs to"),
492
+ blob_id: z
493
+ .string()
494
+ .describe("The blob ID of the attachment (from list_attachments)"),
495
+ },
496
+ async ({ email_id, blob_id }) => {
497
+ const result = await downloadAttachment(email_id, blob_id);
498
+
499
+ if (result.isText) {
500
+ return {
501
+ content: [
502
+ {
503
+ type: "text" as const,
504
+ text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\n\n--- Content ---\n${result.content}`,
505
+ },
506
+ ],
507
+ };
508
+ }
509
+
510
+ // Binary content - return as base64 with warning
511
+ return {
512
+ content: [
513
+ {
514
+ type: "text" as const,
515
+ text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\n\nThis is a binary file. Base64 content (first 1000 chars):\n${result.content.slice(0, 1000)}${result.content.length > 1000 ? "..." : ""}`,
516
+ },
517
+ ],
518
+ };
519
+ },
520
+ );
521
+
522
+ // ============ Start Server ============
523
+
524
+ const transport = new StdioServerTransport();
525
+ await server.connect(transport);
@@ -0,0 +1,149 @@
1
+ import type {
2
+ JMAPMethodCall,
3
+ JMAPMethodResponse,
4
+ JMAPRequest,
5
+ JMAPResponse,
6
+ JMAPSession,
7
+ } from "./types.js";
8
+
9
+ const FASTMAIL_SESSION_URL = "https://api.fastmail.com/jmap/session";
10
+
11
+ export class JMAPClient {
12
+ private session: JMAPSession | null = null;
13
+ private token: string;
14
+
15
+ constructor(token: string) {
16
+ this.token = token;
17
+ }
18
+
19
+ async getSession(): Promise<JMAPSession> {
20
+ if (this.session) {
21
+ return this.session;
22
+ }
23
+
24
+ const response = await fetch(FASTMAIL_SESSION_URL, {
25
+ headers: {
26
+ Authorization: `Bearer ${this.token}`,
27
+ },
28
+ });
29
+
30
+ if (!response.ok) {
31
+ const text = await response.text();
32
+ throw new Error(`Failed to get JMAP session: ${response.status} ${text}`);
33
+ }
34
+
35
+ this.session = (await response.json()) as JMAPSession;
36
+ return this.session;
37
+ }
38
+
39
+ async getAccountId(): Promise<string> {
40
+ const session = await this.getSession();
41
+ const accountId = session.primaryAccounts["urn:ietf:params:jmap:mail"];
42
+ if (!accountId) {
43
+ throw new Error("No mail account found in session");
44
+ }
45
+ return accountId;
46
+ }
47
+
48
+ async request(methodCalls: JMAPMethodCall[]): Promise<JMAPMethodResponse[]> {
49
+ const session = await this.getSession();
50
+
51
+ const request: JMAPRequest = {
52
+ using: [
53
+ "urn:ietf:params:jmap:core",
54
+ "urn:ietf:params:jmap:mail",
55
+ "urn:ietf:params:jmap:submission",
56
+ ],
57
+ methodCalls,
58
+ };
59
+
60
+ const response = await fetch(session.apiUrl, {
61
+ method: "POST",
62
+ headers: {
63
+ Authorization: `Bearer ${this.token}`,
64
+ "Content-Type": "application/json",
65
+ },
66
+ body: JSON.stringify(request),
67
+ });
68
+
69
+ if (!response.ok) {
70
+ const text = await response.text();
71
+ throw new Error(`JMAP request failed: ${response.status} ${text}`);
72
+ }
73
+
74
+ const result = (await response.json()) as JMAPResponse;
75
+
76
+ // Check for JMAP-level errors in responses
77
+ for (const [methodName, data] of result.methodResponses) {
78
+ if (methodName === "error") {
79
+ const errorData = data as { type: string; description?: string };
80
+ throw new Error(
81
+ `JMAP error: ${errorData.type}${errorData.description ? ` - ${errorData.description}` : ""}`,
82
+ );
83
+ }
84
+ }
85
+
86
+ return result.methodResponses;
87
+ }
88
+
89
+ // Helper to make a single method call and extract the response
90
+ async call<T>(
91
+ method: string,
92
+ args: Record<string, unknown>,
93
+ callId = "0",
94
+ ): Promise<T> {
95
+ const responses = await this.request([[method, args, callId]]);
96
+ const response = responses[0];
97
+ if (!response) {
98
+ throw new Error(`No response for method ${method}`);
99
+ }
100
+ return response[1] as T;
101
+ }
102
+
103
+ // Download a blob (attachment) by ID
104
+ async downloadBlob(
105
+ blobId: string,
106
+ accountId: string,
107
+ ): Promise<{ data: ArrayBuffer; type: string }> {
108
+ const session = await this.getSession();
109
+
110
+ // JMAP download URL template: {downloadUrl}/{accountId}/{blobId}/{name}?accept={type}
111
+ const url = session.downloadUrl
112
+ .replace("{accountId}", accountId)
113
+ .replace("{blobId}", blobId)
114
+ .replace("{name}", "attachment")
115
+ .replace("{type}", "application/octet-stream");
116
+
117
+ const response = await fetch(url, {
118
+ headers: {
119
+ Authorization: `Bearer ${this.token}`,
120
+ },
121
+ });
122
+
123
+ if (!response.ok) {
124
+ throw new Error(`Failed to download blob: ${response.status}`);
125
+ }
126
+
127
+ return {
128
+ data: await response.arrayBuffer(),
129
+ type: response.headers.get("content-type") || "application/octet-stream",
130
+ };
131
+ }
132
+ }
133
+
134
+ // Singleton client instance
135
+ let client: JMAPClient | null = null;
136
+
137
+ export function getClient(): JMAPClient {
138
+ if (!client) {
139
+ const token = process.env.FASTMAIL_API_TOKEN;
140
+ if (!token) {
141
+ throw new Error(
142
+ "FASTMAIL_API_TOKEN environment variable is required. " +
143
+ "Generate one at Fastmail → Settings → Privacy & Security → Integrations → API tokens",
144
+ );
145
+ }
146
+ client = new JMAPClient(token);
147
+ }
148
+ return client;
149
+ }