fastmail-mcp-server 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastmail-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for Fastmail - read, search, and send emails via Claude",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.25.1",
40
+ "officeparser": "^6.0.4",
40
41
  "zod": "^4.3.4"
41
42
  },
42
43
  "devDependencies": {
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import {
3
+ McpServer,
4
+ ResourceTemplate,
5
+ } from "@modelcontextprotocol/sdk/server/mcp.js";
3
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { parseOffice } from "officeparser";
4
8
  import { z } from "zod";
5
9
  import {
6
10
  buildReply,
@@ -20,7 +24,7 @@ import type { Email, EmailAddress, Mailbox } from "./jmap/types.js";
20
24
 
21
25
  const server = new McpServer({
22
26
  name: "fastmail",
23
- version: "0.1.0",
27
+ version: "0.2.0",
24
28
  });
25
29
 
26
30
  // ============ Formatters ============
@@ -95,7 +99,7 @@ ${body}`;
95
99
 
96
100
  server.tool(
97
101
  "list_mailboxes",
98
- "List all mailboxes (folders) in the account with their unread counts. Use this to discover available folders before listing emails.",
102
+ "List all mailboxes (folders) in the account with their unread counts. START HERE - use this to discover available folders before listing emails.",
99
103
  {},
100
104
  async () => {
101
105
  const mailboxes = await listMailboxes();
@@ -278,14 +282,12 @@ server.tool(
278
282
 
279
283
  server.tool(
280
284
  "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.",
285
+ "Mark an email as spam. This moves it to Junk AND trains the spam filter - affects future filtering! MUST use action='preview' first, then 'confirm' after user approval.",
282
286
  {
283
287
  email_id: z.string().describe("The email ID to mark as spam"),
284
288
  action: z
285
289
  .enum(["preview", "confirm"])
286
- .describe(
287
- "'preview' to see what will happen, 'confirm' to actually mark as spam",
288
- ),
290
+ .describe("'preview' first, then 'confirm' after user approval"),
289
291
  },
290
292
  async ({ email_id, action }) => {
291
293
  const email = await getEmail(email_id);
@@ -332,23 +334,30 @@ To proceed, call this tool again with action: "confirm"`,
332
334
 
333
335
  server.tool(
334
336
  "send_email",
335
- "Compose and send a new email. ALWAYS use action='preview' first to review the draft before sending.",
337
+ "Compose and send a new email. CRITICAL: You MUST call with action='preview' first, show the user the draft, get explicit approval, then call again with action='confirm'. NEVER skip the preview step.",
336
338
  {
337
339
  action: z
338
340
  .enum(["preview", "confirm"])
339
- .describe("'preview' to see the draft, 'confirm' to send"),
341
+ .describe(
342
+ "'preview' to see the draft, 'confirm' to send - ALWAYS preview first",
343
+ ),
340
344
  to: z.string().describe("Recipient email address(es), comma-separated"),
341
345
  subject: z.string().describe("Email subject line"),
342
346
  body: z.string().describe("Email body text"),
343
347
  cc: z.string().optional().describe("CC recipients, comma-separated"),
348
+ bcc: z
349
+ .string()
350
+ .optional()
351
+ .describe("BCC recipients (hidden), comma-separated"),
344
352
  },
345
- async ({ action, to, subject, body, cc }) => {
353
+ async ({ action, to, subject, body, cc, bcc }) => {
346
354
  // Parse addresses
347
355
  const parseAddresses = (s: string): EmailAddress[] =>
348
356
  s.split(",").map((e) => ({ name: null, email: e.trim() }));
349
357
 
350
358
  const toAddrs = parseAddresses(to);
351
359
  const ccAddrs = cc ? parseAddresses(cc) : undefined;
360
+ const bccAddrs = bcc ? parseAddresses(bcc) : undefined;
352
361
 
353
362
  if (action === "preview") {
354
363
  return {
@@ -359,6 +368,7 @@ server.tool(
359
368
 
360
369
  To: ${formatAddressList(toAddrs)}
361
370
  CC: ${ccAddrs ? formatAddressList(ccAddrs) : "(none)"}
371
+ BCC: ${bccAddrs ? formatAddressList(bccAddrs) : "(none)"}
362
372
  Subject: ${subject}
363
373
 
364
374
  --- Body ---
@@ -376,6 +386,7 @@ To send this email, call this tool again with action: "confirm" and the same par
376
386
  subject,
377
387
  textBody: body,
378
388
  cc: ccAddrs,
389
+ bcc: bccAddrs,
379
390
  });
380
391
 
381
392
  return {
@@ -394,19 +405,40 @@ Email ID: ${emailId}`,
394
405
 
395
406
  server.tool(
396
407
  "reply_to_email",
397
- "Reply to an existing email thread. ALWAYS use action='preview' first to review the draft before sending.",
408
+ "Reply to an existing email thread. CRITICAL: You MUST call with action='preview' first, show the user the draft, get explicit approval, then call again with action='confirm'. NEVER skip the preview step. For reply-all, include original CC recipients in the cc param.",
398
409
  {
399
410
  action: z
400
411
  .enum(["preview", "confirm"])
401
- .describe("'preview' to see the draft, 'confirm' to send"),
412
+ .describe(
413
+ "'preview' to see the draft, 'confirm' to send - ALWAYS preview first",
414
+ ),
402
415
  email_id: z.string().describe("The email ID to reply to"),
403
416
  body: z
404
417
  .string()
405
418
  .describe("Reply body text (your response, without quoting original)"),
419
+ cc: z
420
+ .string()
421
+ .optional()
422
+ .describe("CC recipients for reply-all, comma-separated"),
423
+ bcc: z
424
+ .string()
425
+ .optional()
426
+ .describe("BCC recipients (hidden), comma-separated"),
406
427
  },
407
- async ({ action, email_id, body }) => {
428
+ async ({ action, email_id, body, cc, bcc }) => {
429
+ const parseAddresses = (s: string): EmailAddress[] =>
430
+ s.split(",").map((e) => ({ name: null, email: e.trim() }));
431
+
408
432
  const replyParams = await buildReply(email_id, body);
409
433
 
434
+ // Add cc/bcc if provided
435
+ if (cc) {
436
+ replyParams.cc = parseAddresses(cc);
437
+ }
438
+ if (bcc) {
439
+ replyParams.bcc = parseAddresses(bcc);
440
+ }
441
+
410
442
  if (action === "preview") {
411
443
  return {
412
444
  content: [
@@ -415,6 +447,8 @@ server.tool(
415
447
  text: `📧 REPLY PREVIEW - Review before sending:
416
448
 
417
449
  To: ${formatAddressList(replyParams.to)}
450
+ CC: ${replyParams.cc ? formatAddressList(replyParams.cc) : "(none)"}
451
+ BCC: ${replyParams.bcc ? formatAddressList(replyParams.bcc) : "(none)"}
418
452
  Subject: ${replyParams.subject}
419
453
  In-Reply-To: ${replyParams.inReplyTo || "(none)"}
420
454
 
@@ -484,9 +518,90 @@ server.tool(
484
518
  },
485
519
  );
486
520
 
521
+ // File types that officeparser can extract text from
522
+ const EXTRACTABLE_TYPES = [
523
+ "application/pdf",
524
+ "application/msword", // .doc
525
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
526
+ "application/vnd.ms-excel", // .xls
527
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
528
+ "application/vnd.ms-powerpoint", // .ppt
529
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
530
+ "application/rtf",
531
+ "application/vnd.oasis.opendocument.text", // .odt
532
+ "application/vnd.oasis.opendocument.spreadsheet", // .ods
533
+ "application/vnd.oasis.opendocument.presentation", // .odp
534
+ ];
535
+
536
+ // Also match by extension for when MIME types are wrong
537
+ const EXTRACTABLE_EXTENSIONS = [
538
+ ".pdf",
539
+ ".doc",
540
+ ".docx",
541
+ ".xls",
542
+ ".xlsx",
543
+ ".ppt",
544
+ ".pptx",
545
+ ".rtf",
546
+ ".odt",
547
+ ".ods",
548
+ ".odp",
549
+ ];
550
+
551
+ function canExtractText(mimeType: string, filename: string | null): boolean {
552
+ // Check MIME type first
553
+ if (EXTRACTABLE_TYPES.includes(mimeType)) return true;
554
+
555
+ // Check extension - this catches octet-stream with proper filenames
556
+ if (filename) {
557
+ const ext = filename.toLowerCase().match(/\.[^.]+$/)?.[0];
558
+ console.error(`[canExtractText] filename=${filename}, ext=${ext}`);
559
+ if (ext && EXTRACTABLE_EXTENSIONS.includes(ext)) return true;
560
+ }
561
+
562
+ return false;
563
+ }
564
+
565
+ async function extractText(
566
+ data: Uint8Array,
567
+ filename: string | null,
568
+ ): Promise<string> {
569
+ const buffer = Buffer.from(data);
570
+ const ext = filename?.toLowerCase().match(/\.[^.]+$/)?.[0];
571
+
572
+ // For .doc files, use macOS textutil (officeparser doesn't handle old OLE format well)
573
+ if (ext === ".doc") {
574
+ console.error("[extractText] Using textutil for .doc file");
575
+ const tmpPath = `/tmp/fastmail-${Date.now()}.doc`;
576
+ await Bun.write(tmpPath, buffer);
577
+ try {
578
+ const proc = Bun.spawn([
579
+ "textutil",
580
+ "-convert",
581
+ "txt",
582
+ "-stdout",
583
+ tmpPath,
584
+ ]);
585
+ const output = await new Response(proc.stdout).text();
586
+ return output;
587
+ } finally {
588
+ (await Bun.file(tmpPath).exists()) &&
589
+ (await Bun.$`rm ${tmpPath}`.quiet());
590
+ }
591
+ }
592
+
593
+ // For everything else, use officeparser
594
+ const result = await parseOffice(buffer, { outputFormat: "text" });
595
+ if (typeof result === "string") {
596
+ return result;
597
+ }
598
+ // AST result - extract text from it
599
+ return JSON.stringify(result, null, 2);
600
+ }
601
+
487
602
  server.tool(
488
603
  "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.",
604
+ "Download an attachment. Text files and documents (PDF, DOC, DOCX, XLS, PPT, etc) have text extracted and returned. Images returned as viewable content.",
490
605
  {
491
606
  email_id: z.string().describe("The email ID the attachment belongs to"),
492
607
  blob_id: z
@@ -496,6 +611,11 @@ server.tool(
496
611
  async ({ email_id, blob_id }) => {
497
612
  const result = await downloadAttachment(email_id, blob_id);
498
613
 
614
+ console.error(
615
+ `[get_attachment] Downloaded ${result.size} bytes, type: ${result.type}, name: ${result.name}`,
616
+ );
617
+
618
+ // Plain text - return directly
499
619
  if (result.isText) {
500
620
  return {
501
621
  content: [
@@ -507,18 +627,158 @@ server.tool(
507
627
  };
508
628
  }
509
629
 
510
- // Binary content - return as base64 with warning
630
+ // Documents - extract text
631
+ const shouldExtract = canExtractText(result.type, result.name);
632
+ console.error(
633
+ `[get_attachment] canExtractText(${result.type}, ${result.name}) = ${shouldExtract}`,
634
+ );
635
+
636
+ if (shouldExtract) {
637
+ try {
638
+ console.error(`[get_attachment] Extracting text from ${result.type}`);
639
+ const text = await extractText(result.data, result.name);
640
+ console.error(
641
+ `[get_attachment] Extracted ${text.length} chars of text`,
642
+ );
643
+ return {
644
+ content: [
645
+ {
646
+ type: "text" as const,
647
+ text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\nSize: ${Math.round(result.size / 1024)}KB\n\n--- Extracted Text ---\n${text}`,
648
+ },
649
+ ],
650
+ };
651
+ } catch (err) {
652
+ console.error(`[get_attachment] Text extraction failed:`, err);
653
+ // Fall through to base64
654
+ }
655
+ }
656
+
657
+ const base64 = Buffer.from(result.data).toString("base64");
658
+
659
+ // Images - return as image content
660
+ if (result.type.startsWith("image/")) {
661
+ return {
662
+ content: [
663
+ {
664
+ type: "text" as const,
665
+ text: `Attachment: ${result.name || "(unnamed)"} (${Math.round(result.size / 1024)}KB)`,
666
+ },
667
+ {
668
+ type: "image" as const,
669
+ data: base64,
670
+ mimeType: result.type,
671
+ },
672
+ ],
673
+ };
674
+ }
675
+
676
+ // Other binary - return base64 as last resort
511
677
  return {
512
678
  content: [
513
679
  {
514
680
  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 ? "..." : ""}`,
681
+ text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\nSize: ${Math.round(result.size / 1024)}KB\nEncoding: base64\n\n${base64}`,
682
+ },
683
+ ],
684
+ };
685
+ },
686
+ );
687
+
688
+ // ============ Resources ============
689
+
690
+ // Expose attachments as resources with blob content
691
+ server.resource(
692
+ "attachment",
693
+ new ResourceTemplate("fastmail://attachment/{emailId}/{blobId}", {
694
+ list: undefined,
695
+ }),
696
+ {
697
+ description: "Email attachment content",
698
+ mimeType: "application/octet-stream",
699
+ },
700
+ async (uri, variables) => {
701
+ const { emailId, blobId } = variables as {
702
+ emailId: string;
703
+ blobId: string;
704
+ };
705
+ const result = await downloadAttachment(emailId, blobId);
706
+
707
+ if (result.isText) {
708
+ return {
709
+ contents: [
710
+ {
711
+ uri: uri.toString(),
712
+ mimeType: result.type,
713
+ text: result.content,
714
+ },
715
+ ],
716
+ };
717
+ }
718
+
719
+ // Binary - return as blob (base64)
720
+ return {
721
+ contents: [
722
+ {
723
+ uri: uri.toString(),
724
+ mimeType: result.type,
725
+ blob: result.content, // already base64
516
726
  },
517
727
  ],
518
728
  };
519
729
  },
520
730
  );
521
731
 
732
+ // ============ Prompts ============
733
+
734
+ server.prompt(
735
+ "fastmail-usage",
736
+ "Instructions for using the Fastmail MCP server effectively",
737
+ () => ({
738
+ messages: [
739
+ {
740
+ role: "user",
741
+ content: {
742
+ type: "text",
743
+ text: `# Fastmail MCP Server Usage Guide
744
+
745
+ ## Reading Emails
746
+ 1. Use \`list_mailboxes\` to see available folders
747
+ 2. Use \`list_emails\` with a mailbox name to see emails (e.g., "inbox", "Archive", "Sent")
748
+ 3. Use \`get_email\` with an email ID to read full content
749
+ 4. Use \`search_emails\` to find emails across all folders
750
+
751
+ ## Attachments
752
+ 1. Use \`list_attachments\` to see attachments on an email
753
+ 2. Use \`get_attachment\` with email_id and blob_id to read attachment content
754
+ 3. For binary files (PDFs, images), access via resource URI: fastmail://attachment/{emailId}/{blobId}
755
+
756
+ ## Sending Emails (ALWAYS preview first!)
757
+ 1. Use \`send_email\` with action="preview" to draft
758
+ 2. Review the preview with the user
759
+ 3. Only use action="confirm" after explicit user approval
760
+ 4. Supports to, cc, bcc fields
761
+
762
+ ## Replying
763
+ 1. Use \`reply_to_email\` with action="preview" to draft a reply
764
+ 2. The reply automatically threads correctly
765
+ 3. Add cc/bcc for reply-all scenarios
766
+
767
+ ## Managing Emails
768
+ - \`move_email\` - Move to folder (Archive, Trash, etc.)
769
+ - \`mark_as_read\` - Toggle read/unread
770
+ - \`mark_as_spam\` - Requires preview→confirm (trains spam filter!)
771
+
772
+ ## Safety Rules
773
+ - NEVER send without showing preview first
774
+ - NEVER confirm send without explicit user approval
775
+ - Be careful with mark_as_spam - it affects future filtering`,
776
+ },
777
+ },
778
+ ],
779
+ }),
780
+ );
781
+
522
782
  // ============ Start Server ============
523
783
 
524
784
  const transport = new StdioServerTransport();
@@ -77,12 +77,19 @@ export class JMAPClient {
77
77
  for (const [methodName, data] of result.methodResponses) {
78
78
  if (methodName === "error") {
79
79
  const errorData = data as { type: string; description?: string };
80
+ console.error("[JMAP Error]", JSON.stringify(errorData, null, 2));
80
81
  throw new Error(
81
82
  `JMAP error: ${errorData.type}${errorData.description ? ` - ${errorData.description}` : ""}`,
82
83
  );
83
84
  }
84
85
  }
85
86
 
87
+ // Log responses for debugging
88
+ console.error(
89
+ "[JMAP Response]",
90
+ JSON.stringify(result.methodResponses, null, 2),
91
+ );
92
+
86
93
  return result.methodResponses;
87
94
  }
88
95
 
@@ -301,23 +301,11 @@ export async function sendEmail(params: SendEmailParams): Promise<string> {
301
301
  bcc: params.bcc,
302
302
  subject: params.subject,
303
303
  bodyValues: {
304
- body: {
305
- value: params.textBody,
306
- isEncodingProblem: false,
307
- isTruncated: false,
308
- },
309
- },
310
- textBody: [{ partId: "body", type: "text/plain" } as const].map((p) => ({
311
- ...p,
312
- blobId: null,
313
- size: 0,
314
- name: null,
315
- charset: null,
316
- disposition: null,
317
- cid: null,
318
- language: null,
319
- location: null,
320
- })),
304
+ body: { value: params.textBody },
305
+ } as unknown as EmailCreate["bodyValues"],
306
+ textBody: [
307
+ { partId: "body", type: "text/plain" },
308
+ ] as EmailCreate["textBody"],
321
309
  };
322
310
 
323
311
  if (params.inReplyTo) {
@@ -359,19 +347,52 @@ export async function sendEmail(params: SendEmailParams): Promise<string> {
359
347
  ],
360
348
  ]);
361
349
 
362
- // Extract created email ID
350
+ // Extract created email ID and check for errors
363
351
  const emailSetResponse = responses[0];
364
352
  if (!emailSetResponse) {
365
353
  throw new Error("No response from Email/set");
366
354
  }
367
355
 
368
- const created = (
369
- emailSetResponse[1] as { created?: Record<string, { id: string }> }
370
- ).created;
371
- const emailId = created?.draft?.id;
356
+ const emailSetResult = emailSetResponse[1] as {
357
+ created?: Record<string, { id: string }>;
358
+ notCreated?: Record<string, { type: string; description?: string }>;
359
+ };
360
+
361
+ // Check for creation errors
362
+ if (emailSetResult.notCreated?.draft) {
363
+ const err = emailSetResult.notCreated.draft;
364
+ console.error("[Email/set notCreated]", JSON.stringify(err, null, 2));
365
+ throw new Error(
366
+ `Failed to create email: ${err.type}${err.description ? ` - ${err.description}` : ""}`,
367
+ );
368
+ }
372
369
 
370
+ const emailId = emailSetResult.created?.draft?.id;
373
371
  if (!emailId) {
374
- throw new Error("Failed to create email");
372
+ console.error(
373
+ "[Email/set response]",
374
+ JSON.stringify(emailSetResult, null, 2),
375
+ );
376
+ throw new Error("Failed to create email - no ID returned");
377
+ }
378
+
379
+ // Check submission response
380
+ const submissionResponse = responses[1];
381
+ if (submissionResponse) {
382
+ const submissionResult = submissionResponse[1] as {
383
+ created?: Record<string, unknown>;
384
+ notCreated?: Record<string, { type: string; description?: string }>;
385
+ };
386
+ if (submissionResult.notCreated?.submission) {
387
+ const err = submissionResult.notCreated.submission;
388
+ console.error(
389
+ "[EmailSubmission/set notCreated]",
390
+ JSON.stringify(err, null, 2),
391
+ );
392
+ throw new Error(
393
+ `Failed to submit email: ${err.type}${err.description ? ` - ${err.description}` : ""}`,
394
+ );
395
+ }
375
396
  }
376
397
 
377
398
  return emailId;
@@ -413,8 +434,10 @@ export async function downloadAttachment(
413
434
  blobId: string,
414
435
  ): Promise<{
415
436
  content: string;
437
+ data: Uint8Array;
416
438
  type: string;
417
439
  name: string | null;
440
+ size: number;
418
441
  isText: boolean;
419
442
  }> {
420
443
  const client = getClient();
@@ -428,6 +451,7 @@ export async function downloadAttachment(
428
451
  }
429
452
 
430
453
  const { data, type } = await client.downloadBlob(blobId, accountId);
454
+ const bytes = new Uint8Array(data);
431
455
 
432
456
  // Determine if it's text-based content
433
457
  const isText =
@@ -435,26 +459,28 @@ export async function downloadAttachment(
435
459
  type.includes("json") ||
436
460
  type.includes("xml") ||
437
461
  type.includes("javascript") ||
438
- type.includes("csv") ||
439
- type === "application/pdf"; // We'll try to extract text from PDFs
462
+ type.includes("csv");
440
463
 
441
- if (isText && type !== "application/pdf") {
464
+ if (isText) {
442
465
  // Return as text
443
466
  const decoder = new TextDecoder();
444
467
  return {
445
468
  content: decoder.decode(data),
469
+ data: bytes,
446
470
  type,
447
471
  name: attachment.name,
472
+ size: bytes.length,
448
473
  isText: true,
449
474
  };
450
475
  }
451
476
 
452
- // For binary files (including PDFs for now), return base64
453
- const base64 = Buffer.from(data).toString("base64");
477
+ // For binary files, return raw data (caller decides what to do)
454
478
  return {
455
- content: base64,
479
+ content: "", // Not used for binary
480
+ data: bytes,
456
481
  type,
457
482
  name: attachment.name,
483
+ size: bytes.length,
458
484
  isText: false,
459
485
  };
460
486
  }
package/src/jmap/types.ts CHANGED
@@ -123,6 +123,12 @@ export interface EmailBodyPart {
123
123
  subParts?: EmailBodyPart[];
124
124
  }
125
125
 
126
+ // Minimal body part for creating emails - only partId and type required
127
+ export interface EmailBodyPartCreate {
128
+ partId: string;
129
+ type: string;
130
+ }
131
+
126
132
  export interface EmailBodyValue {
127
133
  value: string;
128
134
  isEncodingProblem: boolean;