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 +2 -1
- package/src/index.ts +276 -16
- package/src/jmap/client.ts +7 -0
- package/src/jmap/methods.ts +55 -29
- package/src/jmap/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastmail-mcp-server",
|
|
3
|
-
"version": "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 {
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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}\
|
|
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();
|
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;
|
|
@@ -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
|
|
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 (
|
|
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:
|
|
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;
|