fastmail-mcp-server 0.1.0 → 0.1.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/package.json +1 -1
- package/src/index.ts +132 -7
- package/src/jmap/client.ts +7 -0
- package/src/jmap/methods.ts +44 -23
- package/src/jmap/types.ts +6 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
McpServer,
|
|
4
|
+
ResourceTemplate,
|
|
5
|
+
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
7
|
import { z } from "zod";
|
|
5
8
|
import {
|
|
@@ -20,7 +23,7 @@ import type { Email, EmailAddress, Mailbox } from "./jmap/types.js";
|
|
|
20
23
|
|
|
21
24
|
const server = new McpServer({
|
|
22
25
|
name: "fastmail",
|
|
23
|
-
version: "0.1.
|
|
26
|
+
version: "0.1.1",
|
|
24
27
|
});
|
|
25
28
|
|
|
26
29
|
// ============ Formatters ============
|
|
@@ -341,14 +344,19 @@ server.tool(
|
|
|
341
344
|
subject: z.string().describe("Email subject line"),
|
|
342
345
|
body: z.string().describe("Email body text"),
|
|
343
346
|
cc: z.string().optional().describe("CC recipients, comma-separated"),
|
|
347
|
+
bcc: z
|
|
348
|
+
.string()
|
|
349
|
+
.optional()
|
|
350
|
+
.describe("BCC recipients (hidden), comma-separated"),
|
|
344
351
|
},
|
|
345
|
-
async ({ action, to, subject, body, cc }) => {
|
|
352
|
+
async ({ action, to, subject, body, cc, bcc }) => {
|
|
346
353
|
// Parse addresses
|
|
347
354
|
const parseAddresses = (s: string): EmailAddress[] =>
|
|
348
355
|
s.split(",").map((e) => ({ name: null, email: e.trim() }));
|
|
349
356
|
|
|
350
357
|
const toAddrs = parseAddresses(to);
|
|
351
358
|
const ccAddrs = cc ? parseAddresses(cc) : undefined;
|
|
359
|
+
const bccAddrs = bcc ? parseAddresses(bcc) : undefined;
|
|
352
360
|
|
|
353
361
|
if (action === "preview") {
|
|
354
362
|
return {
|
|
@@ -359,6 +367,7 @@ server.tool(
|
|
|
359
367
|
|
|
360
368
|
To: ${formatAddressList(toAddrs)}
|
|
361
369
|
CC: ${ccAddrs ? formatAddressList(ccAddrs) : "(none)"}
|
|
370
|
+
BCC: ${bccAddrs ? formatAddressList(bccAddrs) : "(none)"}
|
|
362
371
|
Subject: ${subject}
|
|
363
372
|
|
|
364
373
|
--- Body ---
|
|
@@ -376,6 +385,7 @@ To send this email, call this tool again with action: "confirm" and the same par
|
|
|
376
385
|
subject,
|
|
377
386
|
textBody: body,
|
|
378
387
|
cc: ccAddrs,
|
|
388
|
+
bcc: bccAddrs,
|
|
379
389
|
});
|
|
380
390
|
|
|
381
391
|
return {
|
|
@@ -394,7 +404,7 @@ Email ID: ${emailId}`,
|
|
|
394
404
|
|
|
395
405
|
server.tool(
|
|
396
406
|
"reply_to_email",
|
|
397
|
-
"Reply to an existing email thread. ALWAYS use action='preview' first to review the draft before sending.",
|
|
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.",
|
|
398
408
|
{
|
|
399
409
|
action: z
|
|
400
410
|
.enum(["preview", "confirm"])
|
|
@@ -403,10 +413,29 @@ server.tool(
|
|
|
403
413
|
body: z
|
|
404
414
|
.string()
|
|
405
415
|
.describe("Reply body text (your response, without quoting original)"),
|
|
416
|
+
cc: z
|
|
417
|
+
.string()
|
|
418
|
+
.optional()
|
|
419
|
+
.describe("CC recipients for reply-all, comma-separated"),
|
|
420
|
+
bcc: z
|
|
421
|
+
.string()
|
|
422
|
+
.optional()
|
|
423
|
+
.describe("BCC recipients (hidden), comma-separated"),
|
|
406
424
|
},
|
|
407
|
-
async ({ action, email_id, body }) => {
|
|
425
|
+
async ({ action, email_id, body, cc, bcc }) => {
|
|
426
|
+
const parseAddresses = (s: string): EmailAddress[] =>
|
|
427
|
+
s.split(",").map((e) => ({ name: null, email: e.trim() }));
|
|
428
|
+
|
|
408
429
|
const replyParams = await buildReply(email_id, body);
|
|
409
430
|
|
|
431
|
+
// Add cc/bcc if provided
|
|
432
|
+
if (cc) {
|
|
433
|
+
replyParams.cc = parseAddresses(cc);
|
|
434
|
+
}
|
|
435
|
+
if (bcc) {
|
|
436
|
+
replyParams.bcc = parseAddresses(bcc);
|
|
437
|
+
}
|
|
438
|
+
|
|
410
439
|
if (action === "preview") {
|
|
411
440
|
return {
|
|
412
441
|
content: [
|
|
@@ -415,6 +444,8 @@ server.tool(
|
|
|
415
444
|
text: `📧 REPLY PREVIEW - Review before sending:
|
|
416
445
|
|
|
417
446
|
To: ${formatAddressList(replyParams.to)}
|
|
447
|
+
CC: ${replyParams.cc ? formatAddressList(replyParams.cc) : "(none)"}
|
|
448
|
+
BCC: ${replyParams.bcc ? formatAddressList(replyParams.bcc) : "(none)"}
|
|
418
449
|
Subject: ${replyParams.subject}
|
|
419
450
|
In-Reply-To: ${replyParams.inReplyTo || "(none)"}
|
|
420
451
|
|
|
@@ -507,18 +538,112 @@ server.tool(
|
|
|
507
538
|
};
|
|
508
539
|
}
|
|
509
540
|
|
|
510
|
-
// Binary content - return
|
|
541
|
+
// Binary content - return full base64
|
|
511
542
|
return {
|
|
512
543
|
content: [
|
|
513
544
|
{
|
|
514
545
|
type: "text" as const,
|
|
515
|
-
text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\
|
|
546
|
+
text: `Attachment: ${result.name || "(unnamed)"}\nType: ${result.type}\nSize: ${result.content.length} bytes (base64)\n\n--- Base64 Content ---\n${result.content}`,
|
|
516
547
|
},
|
|
517
548
|
],
|
|
518
549
|
};
|
|
519
550
|
},
|
|
520
551
|
);
|
|
521
552
|
|
|
553
|
+
// ============ Resources ============
|
|
554
|
+
|
|
555
|
+
// Expose attachments as resources with blob content
|
|
556
|
+
server.resource(
|
|
557
|
+
"attachment",
|
|
558
|
+
new ResourceTemplate("fastmail://attachment/{emailId}/{blobId}", {
|
|
559
|
+
list: undefined,
|
|
560
|
+
}),
|
|
561
|
+
{
|
|
562
|
+
description: "Email attachment content",
|
|
563
|
+
mimeType: "application/octet-stream",
|
|
564
|
+
},
|
|
565
|
+
async (uri, variables) => {
|
|
566
|
+
const { emailId, blobId } = variables as {
|
|
567
|
+
emailId: string;
|
|
568
|
+
blobId: string;
|
|
569
|
+
};
|
|
570
|
+
const result = await downloadAttachment(emailId, blobId);
|
|
571
|
+
|
|
572
|
+
if (result.isText) {
|
|
573
|
+
return {
|
|
574
|
+
contents: [
|
|
575
|
+
{
|
|
576
|
+
uri: uri.toString(),
|
|
577
|
+
mimeType: result.type,
|
|
578
|
+
text: result.content,
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Binary - return as blob (base64)
|
|
585
|
+
return {
|
|
586
|
+
contents: [
|
|
587
|
+
{
|
|
588
|
+
uri: uri.toString(),
|
|
589
|
+
mimeType: result.type,
|
|
590
|
+
blob: result.content, // already base64
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
};
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// ============ Prompts ============
|
|
598
|
+
|
|
599
|
+
server.prompt(
|
|
600
|
+
"fastmail-usage",
|
|
601
|
+
"Instructions for using the Fastmail MCP server effectively",
|
|
602
|
+
() => ({
|
|
603
|
+
messages: [
|
|
604
|
+
{
|
|
605
|
+
role: "user",
|
|
606
|
+
content: {
|
|
607
|
+
type: "text",
|
|
608
|
+
text: `# Fastmail MCP Server Usage Guide
|
|
609
|
+
|
|
610
|
+
## Reading Emails
|
|
611
|
+
1. Use \`list_mailboxes\` to see available folders
|
|
612
|
+
2. Use \`list_emails\` with a mailbox name to see emails (e.g., "inbox", "Archive", "Sent")
|
|
613
|
+
3. Use \`get_email\` with an email ID to read full content
|
|
614
|
+
4. Use \`search_emails\` to find emails across all folders
|
|
615
|
+
|
|
616
|
+
## Attachments
|
|
617
|
+
1. Use \`list_attachments\` to see attachments on an email
|
|
618
|
+
2. Use \`get_attachment\` with email_id and blob_id to read attachment content
|
|
619
|
+
3. For binary files (PDFs, images), access via resource URI: fastmail://attachment/{emailId}/{blobId}
|
|
620
|
+
|
|
621
|
+
## Sending Emails (ALWAYS preview first!)
|
|
622
|
+
1. Use \`send_email\` with action="preview" to draft
|
|
623
|
+
2. Review the preview with the user
|
|
624
|
+
3. Only use action="confirm" after explicit user approval
|
|
625
|
+
4. Supports to, cc, bcc fields
|
|
626
|
+
|
|
627
|
+
## Replying
|
|
628
|
+
1. Use \`reply_to_email\` with action="preview" to draft a reply
|
|
629
|
+
2. The reply automatically threads correctly
|
|
630
|
+
3. Add cc/bcc for reply-all scenarios
|
|
631
|
+
|
|
632
|
+
## Managing Emails
|
|
633
|
+
- \`move_email\` - Move to folder (Archive, Trash, etc.)
|
|
634
|
+
- \`mark_as_read\` - Toggle read/unread
|
|
635
|
+
- \`mark_as_spam\` - Requires preview→confirm (trains spam filter!)
|
|
636
|
+
|
|
637
|
+
## Safety Rules
|
|
638
|
+
- NEVER send without showing preview first
|
|
639
|
+
- NEVER confirm send without explicit user approval
|
|
640
|
+
- Be careful with mark_as_spam - it affects future filtering`,
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
|
|
522
647
|
// ============ Start Server ============
|
|
523
648
|
|
|
524
649
|
const transport = new StdioServerTransport();
|
package/src/jmap/client.ts
CHANGED
|
@@ -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
|
|
package/src/jmap/methods.ts
CHANGED
|
@@ -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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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;
|
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;
|