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 +52 -8
- package/package.json +45 -44
- package/src/index.ts +148 -13
- package/src/jmap/methods.ts +11 -6
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
};
|
package/src/jmap/methods.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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:
|
|
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
|
}
|