fastmail-mcp-server 0.1.1 → 0.2.1

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/README.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  MCP server for Fastmail. Read, search, organize, and send emails through Claude Desktop.
4
4
 
5
+ ## Features
6
+
7
+ - **Full read/write** - list, search, send, reply, move, mark as spam
8
+ - **Safe sending** - preview→confirm flow prevents accidental sends
9
+ - **Attachment text extraction** - PDFs, Word docs, Excel, PowerPoint extracted as readable text
10
+ - **Legacy .doc support** - uses macOS `textutil` for old Word formats
11
+ - **Image attachments** - returned as viewable content for Claude's built-in OCR
12
+ - **CC/BCC support** - full addressing on send and reply
13
+
14
+ ### Comparison
15
+
16
+ | Feature | radiosilence | @jahfer | willmeyers |
17
+ | --------------------------------- | :----------: | :-----: | :---------: |
18
+ | Read emails | ✅ | ✅ | ✅ |
19
+ | Search emails | ✅ | ❓ | ❓ |
20
+ | Send emails | ✅ | ❌ | ❌ |
21
+ | Reply to threads | ✅ | ❌ | ❌ |
22
+ | CC/BCC support | ✅ | ❌ | ❌ |
23
+ | Safe send (preview→confirm) | ✅ | ❌ | ❌ |
24
+ | Move/organize emails | ✅ | ❌ | ❌ |
25
+ | Mark as spam | ✅ | ❌ | ❌ |
26
+ | List attachments | ✅ | ❌ | ❌ |
27
+ | **Extract text from PDF/DOCX** | ✅ | ❌ | ❌ |
28
+ | **Extract text from legacy .doc** | ✅ | ❌ | ❌ |
29
+ | **Images for Claude OCR** | ✅ | ❌ | ❌ |
30
+ | Bun/TypeScript | ✅ | ✅ | ❌ (Python) |
31
+ | Actively maintained | ✅ | ❓ | ❓ |
32
+
33
+ ❓ = undocumented/unclear
34
+
5
35
  ## Prerequisites
6
36
 
7
37
  Requires [Bun](https://bun.sh) runtime:
@@ -82,14 +112,28 @@ No emails can be sent accidentally.
82
112
 
83
113
  ### Read Operations (no confirmation needed)
84
114
 
85
- | Tool | Description |
86
- | ------------------ | -------------------------------------------- |
87
- | `list_mailboxes` | List all folders with unread counts |
88
- | `list_emails` | List emails in a mailbox (returns summaries) |
89
- | `get_email` | Get full email content by ID |
90
- | `search_emails` | Search across all mailboxes |
91
- | `list_attachments` | List attachments on an email |
92
- | `get_attachment` | Download and read attachment content |
115
+ | Tool | Description |
116
+ | ------------------ | ------------------------------------------------ |
117
+ | `list_mailboxes` | List all folders with unread counts |
118
+ | `list_emails` | List emails in a mailbox (returns summaries) |
119
+ | `get_email` | Get full email content by ID |
120
+ | `search_emails` | Search across all mailboxes |
121
+ | `list_attachments` | List attachments on an email |
122
+ | `get_attachment` | Download and read attachment content (see below) |
123
+
124
+ ### Attachment Handling
125
+
126
+ `get_attachment` automatically extracts readable content from attachments:
127
+
128
+ | Format | Handling |
129
+ | ------------------------------------------- | ----------------------------------------------------------------------------- |
130
+ | Text files (txt, json, csv, xml) | Returned inline |
131
+ | Documents (PDF, DOCX, XLSX, PPTX, RTF, ODT) | Text extracted via [officeparser](https://github.com/harshankur/officeParser) |
132
+ | Legacy Word (.doc) | Text extracted via macOS `textutil` |
133
+ | Images (PNG, JPG, etc) | Returned as image content for Claude to view/OCR |
134
+ | Other binary | Base64 fallback |
135
+
136
+ Claude receives actual text content, not binary blobs - just like when you drag-and-drop files into Claude Desktop.
93
137
 
94
138
  ### Write Operations
95
139
 
package/package.json CHANGED
@@ -1,46 +1,47 @@
1
1
  {
2
- "name": "fastmail-mcp-server",
3
- "version": "0.1.1",
4
- "description": "MCP server for Fastmail - read, search, and send emails via Claude",
5
- "type": "module",
6
- "main": "src/index.ts",
7
- "files": [
8
- "src/**/*"
9
- ],
10
- "scripts": {
11
- "start": "bun run src/index.ts",
12
- "test": "bun run src/test.ts",
13
- "format": "biome check --write",
14
- "lint": "biome check"
15
- },
16
- "keywords": [
17
- "mcp",
18
- "fastmail",
19
- "jmap",
20
- "email",
21
- "claude",
22
- "ai",
23
- "model-context-protocol"
24
- ],
25
- "author": "James Cleveland <jc@blit.cc>",
26
- "license": "MIT",
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/radiosilence/fastmail-mcp-server.git"
30
- },
31
- "bugs": {
32
- "url": "https://github.com/radiosilence/fastmail-mcp-server/issues"
33
- },
34
- "homepage": "https://github.com/radiosilence/fastmail-mcp-server#readme",
35
- "engines": {
36
- "bun": ">=1.0.0"
37
- },
38
- "dependencies": {
39
- "@modelcontextprotocol/sdk": "^1.25.1",
40
- "zod": "^4.3.4"
41
- },
42
- "devDependencies": {
43
- "@biomejs/biome": "^2.3.10",
44
- "@types/bun": "latest"
45
- }
2
+ "name": "fastmail-mcp-server",
3
+ "version": "0.2.1",
4
+ "description": "MCP server for Fastmail - read, search, and send emails via Claude",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "files": [
8
+ "src/**/*"
9
+ ],
10
+ "scripts": {
11
+ "start": "bun run src/index.ts",
12
+ "test": "bun run src/test.ts",
13
+ "format": "biome check --write",
14
+ "lint": "biome check"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "fastmail",
19
+ "jmap",
20
+ "email",
21
+ "claude",
22
+ "ai",
23
+ "model-context-protocol"
24
+ ],
25
+ "author": "James Cleveland <jc@blit.cc>",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/radiosilence/fastmail-mcp-server.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/radiosilence/fastmail-mcp-server/issues"
33
+ },
34
+ "homepage": "https://github.com/radiosilence/fastmail-mcp-server#readme",
35
+ "engines": {
36
+ "bun": ">=1.0.0"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.25.1",
40
+ "officeparser": "^6.0.4",
41
+ "zod": "^4.3.4"
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^2.3.10",
45
+ "@types/bun": "latest"
46
+ }
46
47
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  ResourceTemplate,
5
5
  } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { parseOffice } from "officeparser";
7
8
  import { z } from "zod";
8
9
  import {
9
10
  buildReply,
@@ -23,7 +24,7 @@ import type { Email, EmailAddress, Mailbox } from "./jmap/types.js";
23
24
 
24
25
  const server = new McpServer({
25
26
  name: "fastmail",
26
- version: "0.1.1",
27
+ version: "0.2.1",
27
28
  });
28
29
 
29
30
  // ============ Formatters ============
@@ -98,7 +99,7 @@ ${body}`;
98
99
 
99
100
  server.tool(
100
101
  "list_mailboxes",
101
- "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.",
102
103
  {},
103
104
  async () => {
104
105
  const mailboxes = await listMailboxes();
@@ -281,14 +282,12 @@ server.tool(
281
282
 
282
283
  server.tool(
283
284
  "mark_as_spam",
284
- "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.",
285
286
  {
286
287
  email_id: z.string().describe("The email ID to mark as spam"),
287
288
  action: z
288
289
  .enum(["preview", "confirm"])
289
- .describe(
290
- "'preview' to see what will happen, 'confirm' to actually mark as spam",
291
- ),
290
+ .describe("'preview' first, then 'confirm' after user approval"),
292
291
  },
293
292
  async ({ email_id, action }) => {
294
293
  const email = await getEmail(email_id);
@@ -335,11 +334,13 @@ To proceed, call this tool again with action: "confirm"`,
335
334
 
336
335
  server.tool(
337
336
  "send_email",
338
- "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.",
339
338
  {
340
339
  action: z
341
340
  .enum(["preview", "confirm"])
342
- .describe("'preview' to see the draft, 'confirm' to send"),
341
+ .describe(
342
+ "'preview' to see the draft, 'confirm' to send - ALWAYS preview first",
343
+ ),
343
344
  to: z.string().describe("Recipient email address(es), comma-separated"),
344
345
  subject: z.string().describe("Email subject line"),
345
346
  body: z.string().describe("Email body text"),
@@ -404,11 +405,13 @@ Email ID: ${emailId}`,
404
405
 
405
406
  server.tool(
406
407
  "reply_to_email",
407
- "Reply to an existing email thread. ALWAYS use action='preview' first to review the draft before sending. For reply-all, include original CC recipients in the cc param.",
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.",
408
409
  {
409
410
  action: z
410
411
  .enum(["preview", "confirm"])
411
- .describe("'preview' to see the draft, 'confirm' to send"),
412
+ .describe(
413
+ "'preview' to see the draft, 'confirm' to send - ALWAYS preview first",
414
+ ),
412
415
  email_id: z.string().describe("The email ID to reply to"),
413
416
  body: z
414
417
  .string()
@@ -515,9 +518,90 @@ server.tool(
515
518
  },
516
519
  );
517
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
+
518
602
  server.tool(
519
603
  "get_attachment",
520
- "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.",
521
605
  {
522
606
  email_id: z.string().describe("The email ID the attachment belongs to"),
523
607
  blob_id: z
@@ -527,6 +611,11 @@ server.tool(
527
611
  async ({ email_id, blob_id }) => {
528
612
  const result = await downloadAttachment(email_id, blob_id);
529
613
 
614
+ console.error(
615
+ `[get_attachment] Downloaded ${result.size} bytes, type: ${result.type}, name: ${result.name}`,
616
+ );
617
+
618
+ // Plain text - return directly
530
619
  if (result.isText) {
531
620
  return {
532
621
  content: [
@@ -538,12 +627,58 @@ server.tool(
538
627
  };
539
628
  }
540
629
 
541
- // Binary content - return full base64
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
542
677
  return {
543
678
  content: [
544
679
  {
545
680
  type: "text" as const,
546
- text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\nSize: ${result.content.length} bytes (base64)\n\n--- Base64 Content ---\n${result.content}`,
681
+ text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\nSize: ${Math.round(result.size / 1024)}KB\nEncoding: base64\n\n${base64}`,
547
682
  },
548
683
  ],
549
684
  };
@@ -434,8 +434,10 @@ export async function downloadAttachment(
434
434
  blobId: string,
435
435
  ): Promise<{
436
436
  content: string;
437
+ data: Uint8Array;
437
438
  type: string;
438
439
  name: string | null;
440
+ size: number;
439
441
  isText: boolean;
440
442
  }> {
441
443
  const client = getClient();
@@ -449,6 +451,7 @@ export async function downloadAttachment(
449
451
  }
450
452
 
451
453
  const { data, type } = await client.downloadBlob(blobId, accountId);
454
+ const bytes = new Uint8Array(data);
452
455
 
453
456
  // Determine if it's text-based content
454
457
  const isText =
@@ -456,26 +459,28 @@ export async function downloadAttachment(
456
459
  type.includes("json") ||
457
460
  type.includes("xml") ||
458
461
  type.includes("javascript") ||
459
- type.includes("csv") ||
460
- type === "application/pdf"; // We'll try to extract text from PDFs
462
+ type.includes("csv");
461
463
 
462
- if (isText && type !== "application/pdf") {
464
+ if (isText) {
463
465
  // Return as text
464
466
  const decoder = new TextDecoder();
465
467
  return {
466
468
  content: decoder.decode(data),
469
+ data: bytes,
467
470
  type,
468
471
  name: attachment.name,
472
+ size: bytes.length,
469
473
  isText: true,
470
474
  };
471
475
  }
472
476
 
473
- // For binary files (including PDFs for now), return base64
474
- const base64 = Buffer.from(data).toString("base64");
477
+ // For binary files, return raw data (caller decides what to do)
475
478
  return {
476
- content: base64,
479
+ content: "", // Not used for binary
480
+ data: bytes,
477
481
  type,
478
482
  name: attachment.name,
483
+ size: bytes.length,
479
484
  isText: false,
480
485
  };
481
486
  }