fastmail-mcp-server 0.2.2 → 0.4.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 +43 -4
- package/package.json +4 -1
- package/src/index.ts +299 -14
- package/src/jmap/client.ts +1 -0
- package/src/jmap/methods.ts +279 -3
- package/src/jmap/types.ts +13 -0
package/README.md
CHANGED
|
@@ -4,12 +4,15 @@ MCP server for Fastmail. Read, search, organize, and send emails through Claude
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Full read/write** - list, search, send, reply, move, mark as spam
|
|
7
|
+
- **Full read/write** - list, search, send, reply, forward, move, mark as spam
|
|
8
8
|
- **Safe sending** - preview→confirm flow prevents accidental sends
|
|
9
|
+
- **Masked emails** - create/manage disposable email addresses
|
|
10
|
+
- **Advanced search** - filter by date, sender, attachments, unread, flagged status
|
|
11
|
+
- **Thread support** - get_email returns full conversation context
|
|
9
12
|
- **Attachment text extraction** - PDFs, Word docs, Excel, PowerPoint extracted as readable text
|
|
10
13
|
- **Legacy .doc support** - uses macOS `textutil` for old Word formats
|
|
11
14
|
- **Image attachments** - returned as viewable content for Claude's built-in OCR
|
|
12
|
-
- **CC/BCC support** - full addressing on send and
|
|
15
|
+
- **CC/BCC support** - full addressing on send, reply, and forward
|
|
13
16
|
|
|
14
17
|
### Comparison
|
|
15
18
|
|
|
@@ -17,12 +20,16 @@ MCP server for Fastmail. Read, search, organize, and send emails through Claude
|
|
|
17
20
|
| --------------------------------- | :----------: | :-----: | :---------: |
|
|
18
21
|
| Read emails | ✅ | ✅ | ✅ |
|
|
19
22
|
| Search emails | ✅ | ✅ | ✅ |
|
|
23
|
+
| Advanced search filters | ✅ | ❌ | ❌ |
|
|
20
24
|
| Send emails | ✅ | ❌ | ✅ |
|
|
21
25
|
| Reply to threads | ✅ | ❌ | ❌ |
|
|
26
|
+
| Forward emails | ✅ | ❌ | ❌ |
|
|
22
27
|
| CC/BCC support | ✅ | ❌ | ✅ |
|
|
23
28
|
| Safe send (preview→confirm) | ✅ | ❌ | ❌ |
|
|
24
29
|
| Move/organize emails | ✅ | ❌ | ❌ |
|
|
25
30
|
| Mark as spam | ✅ | ❌ | ❌ |
|
|
31
|
+
| **Masked emails** | ✅ | ❌ | ❌ |
|
|
32
|
+
| **Thread context** | ✅ | ❌ | ❌ |
|
|
26
33
|
| List attachments | ✅ | ❌ | ❌ |
|
|
27
34
|
| **Extract text from PDF/DOCX** | ✅ | ❌ | ❌ |
|
|
28
35
|
| **Extract text from legacy .doc** | ✅ | ❌ | ❌ |
|
|
@@ -59,6 +66,16 @@ Token format: `fmu1-xxxxxxxx-xxxxxxxxxxxx...`
|
|
|
59
66
|
|
|
60
67
|
### 2. Configure Claude Desktop
|
|
61
68
|
|
|
69
|
+
Install the server globally:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Via mise (recommended)
|
|
73
|
+
mise use -g npm:fastmail-mcp-server
|
|
74
|
+
|
|
75
|
+
# Or via npm
|
|
76
|
+
npm install -g fastmail-mcp-server
|
|
77
|
+
```
|
|
78
|
+
|
|
62
79
|
Open the Claude Desktop config file:
|
|
63
80
|
|
|
64
81
|
```bash
|
|
@@ -76,8 +93,7 @@ Add the fastmail server config:
|
|
|
76
93
|
{
|
|
77
94
|
"mcpServers": {
|
|
78
95
|
"fastmail": {
|
|
79
|
-
"command": "
|
|
80
|
-
"args": ["-y", "fastmail-mcp-server"],
|
|
96
|
+
"command": "fastmail-mcp-server",
|
|
81
97
|
"env": {
|
|
82
98
|
"FASTMAIL_API_TOKEN": "fmu1-your-token-here"
|
|
83
99
|
}
|
|
@@ -86,6 +102,8 @@ Add the fastmail server config:
|
|
|
86
102
|
}
|
|
87
103
|
```
|
|
88
104
|
|
|
105
|
+
> **Note:** If Claude Desktop can't find the command, use the full path from `which fastmail-mcp-server`
|
|
106
|
+
|
|
89
107
|
### 3. Restart Claude Desktop
|
|
90
108
|
|
|
91
109
|
Quit Claude Desktop completely (Cmd+Q) and reopen it. The Fastmail tools should now appear.
|
|
@@ -142,6 +160,17 @@ Claude receives actual text content, not binary blobs - just like when you drag-
|
|
|
142
160
|
| `mark_as_spam` | Move to Junk + train filter | **Yes** |
|
|
143
161
|
| `send_email` | Send a new email | **Yes** |
|
|
144
162
|
| `reply_to_email` | Reply to an email thread | **Yes** |
|
|
163
|
+
| `forward_email` | Forward an email | **Yes** |
|
|
164
|
+
|
|
165
|
+
### Masked Email Operations
|
|
166
|
+
|
|
167
|
+
| Tool | Description |
|
|
168
|
+
| ---------------------- | ---------------------------------- |
|
|
169
|
+
| `list_masked_emails` | List all masked email addresses |
|
|
170
|
+
| `create_masked_email` | Create a new disposable address |
|
|
171
|
+
| `enable_masked_email` | Re-enable a disabled masked email |
|
|
172
|
+
| `disable_masked_email` | Stop receiving at a masked address |
|
|
173
|
+
| `delete_masked_email` | Permanently delete a masked email |
|
|
145
174
|
|
|
146
175
|
## Example Prompts
|
|
147
176
|
|
|
@@ -152,13 +181,23 @@ Claude receives actual text content, not binary blobs - just like when you drag-
|
|
|
152
181
|
|
|
153
182
|
"Search for emails from john@example.com"
|
|
154
183
|
|
|
184
|
+
"Find unread emails from last week with attachments"
|
|
185
|
+
|
|
186
|
+
"Show me flagged emails from December"
|
|
187
|
+
|
|
155
188
|
"What would be a good response to the latest email from the solicitor?"
|
|
156
189
|
|
|
157
190
|
"Draft a reply to that insurance email explaining the situation"
|
|
158
191
|
|
|
192
|
+
"Forward that receipt to my accountant"
|
|
193
|
+
|
|
159
194
|
"Move all the newsletters to Archive"
|
|
160
195
|
|
|
161
196
|
"Mark that spam email as junk"
|
|
197
|
+
|
|
198
|
+
"Create a masked email for signing up to this sketchy website"
|
|
199
|
+
|
|
200
|
+
"List my masked emails and disable the one for that service I cancelled"
|
|
162
201
|
```
|
|
163
202
|
|
|
164
203
|
## Troubleshooting
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastmail-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "MCP server for Fastmail - read, search, and send emails via Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"fastmail-mcp-server": "src/index.ts"
|
|
9
|
+
},
|
|
7
10
|
"files": [
|
|
8
11
|
"src/**/*"
|
|
9
12
|
],
|
package/src/index.ts
CHANGED
|
@@ -7,24 +7,34 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
import { parseOffice } from "officeparser";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import {
|
|
10
|
+
buildForward,
|
|
10
11
|
buildReply,
|
|
12
|
+
createMaskedEmail,
|
|
11
13
|
downloadAttachment,
|
|
12
14
|
getAttachments,
|
|
13
15
|
getEmail,
|
|
14
16
|
getMailboxByName,
|
|
17
|
+
getThreadEmails,
|
|
15
18
|
listEmails,
|
|
16
19
|
listMailboxes,
|
|
20
|
+
listMaskedEmails,
|
|
17
21
|
markAsRead,
|
|
18
22
|
markAsSpam,
|
|
19
23
|
moveEmail,
|
|
20
24
|
searchEmails,
|
|
21
25
|
sendEmail,
|
|
26
|
+
updateMaskedEmail,
|
|
22
27
|
} from "./jmap/methods.js";
|
|
23
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
Email,
|
|
30
|
+
EmailAddress,
|
|
31
|
+
Mailbox,
|
|
32
|
+
MaskedEmail,
|
|
33
|
+
} from "./jmap/types.js";
|
|
24
34
|
|
|
25
35
|
const server = new McpServer({
|
|
26
36
|
name: "fastmail",
|
|
27
|
-
version: "0.
|
|
37
|
+
version: "0.4.0",
|
|
28
38
|
});
|
|
29
39
|
|
|
30
40
|
// ============ Formatters ============
|
|
@@ -145,7 +155,7 @@ server.tool(
|
|
|
145
155
|
|
|
146
156
|
server.tool(
|
|
147
157
|
"get_email",
|
|
148
|
-
"Get the full content of a specific email by its ID.
|
|
158
|
+
"Get the full content of a specific email by its ID. Automatically includes the full thread context (all emails in the conversation) sorted oldest-first.",
|
|
149
159
|
{
|
|
150
160
|
email_id: z
|
|
151
161
|
.string()
|
|
@@ -162,33 +172,106 @@ server.tool(
|
|
|
162
172
|
};
|
|
163
173
|
}
|
|
164
174
|
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
// Get full thread context
|
|
176
|
+
const threadEmails = await getThreadEmails(email.threadId);
|
|
177
|
+
|
|
178
|
+
if (threadEmails.length <= 1) {
|
|
179
|
+
// Single email, no thread
|
|
180
|
+
const text = formatEmailFull(email);
|
|
181
|
+
return { content: [{ type: "text" as const, text }] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Format thread with all emails
|
|
185
|
+
const threadText = threadEmails
|
|
186
|
+
.map((e, i) => {
|
|
187
|
+
const marker = e.id === email_id ? ">>> SELECTED EMAIL <<<\n" : "";
|
|
188
|
+
return `${marker}[${i + 1}/${threadEmails.length}]\n${formatEmailFull(e)}`;
|
|
189
|
+
})
|
|
190
|
+
.join("\n\n========== THREAD ==========\n\n");
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text" as const,
|
|
196
|
+
text: `Thread contains ${threadEmails.length} emails:\n\n${threadText}`,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|
|
167
200
|
},
|
|
168
201
|
);
|
|
169
202
|
|
|
170
203
|
server.tool(
|
|
171
204
|
"search_emails",
|
|
172
|
-
"Search for emails
|
|
205
|
+
"Search for emails with flexible filters. Use 'query' for general search, or specific fields for precise filtering. Supports date ranges, attachment filtering, unread/flagged status.",
|
|
173
206
|
{
|
|
174
207
|
query: z
|
|
175
208
|
.string()
|
|
176
|
-
.
|
|
177
|
-
|
|
178
|
-
|
|
209
|
+
.optional()
|
|
210
|
+
.describe("General search - searches subject, body, from, and to fields"),
|
|
211
|
+
from: z.string().optional().describe("Search sender address/name"),
|
|
212
|
+
to: z.string().optional().describe("Search recipient address/name"),
|
|
213
|
+
cc: z.string().optional().describe("Search CC recipients"),
|
|
214
|
+
subject: z.string().optional().describe("Search subject line only"),
|
|
215
|
+
body: z.string().optional().describe("Search email body only"),
|
|
216
|
+
mailbox: z
|
|
217
|
+
.string()
|
|
218
|
+
.optional()
|
|
219
|
+
.describe("Limit search to a specific mailbox/folder"),
|
|
220
|
+
has_attachment: z
|
|
221
|
+
.boolean()
|
|
222
|
+
.optional()
|
|
223
|
+
.describe("Only emails with attachments"),
|
|
224
|
+
before: z
|
|
225
|
+
.string()
|
|
226
|
+
.optional()
|
|
227
|
+
.describe("Emails before this date (YYYY-MM-DD or ISO 8601)"),
|
|
228
|
+
after: z
|
|
229
|
+
.string()
|
|
230
|
+
.optional()
|
|
231
|
+
.describe("Emails after this date (YYYY-MM-DD or ISO 8601)"),
|
|
232
|
+
unread: z.boolean().optional().describe("Only unread emails"),
|
|
233
|
+
flagged: z.boolean().optional().describe("Only flagged/starred emails"),
|
|
179
234
|
limit: z
|
|
180
235
|
.number()
|
|
181
236
|
.optional()
|
|
182
237
|
.describe("Maximum number of results (default 25, max 100)"),
|
|
183
238
|
},
|
|
184
|
-
async ({
|
|
185
|
-
|
|
239
|
+
async ({
|
|
240
|
+
query,
|
|
241
|
+
from,
|
|
242
|
+
to,
|
|
243
|
+
cc,
|
|
244
|
+
subject,
|
|
245
|
+
body,
|
|
246
|
+
mailbox,
|
|
247
|
+
has_attachment,
|
|
248
|
+
before,
|
|
249
|
+
after,
|
|
250
|
+
unread,
|
|
251
|
+
flagged,
|
|
252
|
+
limit,
|
|
253
|
+
}) => {
|
|
254
|
+
const emails = await searchEmails(
|
|
255
|
+
{
|
|
256
|
+
query,
|
|
257
|
+
from,
|
|
258
|
+
to,
|
|
259
|
+
cc,
|
|
260
|
+
subject,
|
|
261
|
+
body,
|
|
262
|
+
mailbox,
|
|
263
|
+
hasAttachment: has_attachment,
|
|
264
|
+
before,
|
|
265
|
+
after,
|
|
266
|
+
unread,
|
|
267
|
+
flagged,
|
|
268
|
+
},
|
|
269
|
+
Math.min(limit || 25, 100),
|
|
270
|
+
);
|
|
186
271
|
|
|
187
272
|
if (emails.length === 0) {
|
|
188
273
|
return {
|
|
189
|
-
content: [
|
|
190
|
-
{ type: "text" as const, text: `No emails found for: ${query}` },
|
|
191
|
-
],
|
|
274
|
+
content: [{ type: "text" as const, text: "No emails found." }],
|
|
192
275
|
};
|
|
193
276
|
}
|
|
194
277
|
|
|
@@ -478,6 +561,79 @@ Email ID: ${emailId}`,
|
|
|
478
561
|
},
|
|
479
562
|
);
|
|
480
563
|
|
|
564
|
+
server.tool(
|
|
565
|
+
"forward_email",
|
|
566
|
+
"Forward an email to new recipients. 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.",
|
|
567
|
+
{
|
|
568
|
+
action: z
|
|
569
|
+
.enum(["preview", "confirm"])
|
|
570
|
+
.describe(
|
|
571
|
+
"'preview' to see the draft, 'confirm' to send - ALWAYS preview first",
|
|
572
|
+
),
|
|
573
|
+
email_id: z.string().describe("The email ID to forward"),
|
|
574
|
+
to: z.string().describe("Recipient email address(es), comma-separated"),
|
|
575
|
+
body: z
|
|
576
|
+
.string()
|
|
577
|
+
.describe("Your message to include above the forwarded content"),
|
|
578
|
+
cc: z.string().optional().describe("CC recipients, comma-separated"),
|
|
579
|
+
bcc: z
|
|
580
|
+
.string()
|
|
581
|
+
.optional()
|
|
582
|
+
.describe("BCC recipients (hidden), comma-separated"),
|
|
583
|
+
},
|
|
584
|
+
async ({ action, email_id, to, body, cc, bcc }) => {
|
|
585
|
+
const parseAddresses = (s: string): EmailAddress[] =>
|
|
586
|
+
s.split(",").map((e) => ({ name: null, email: e.trim() }));
|
|
587
|
+
|
|
588
|
+
const forwardParams = await buildForward(email_id, body);
|
|
589
|
+
forwardParams.to = parseAddresses(to);
|
|
590
|
+
|
|
591
|
+
if (cc) {
|
|
592
|
+
forwardParams.cc = parseAddresses(cc);
|
|
593
|
+
}
|
|
594
|
+
if (bcc) {
|
|
595
|
+
forwardParams.bcc = parseAddresses(bcc);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (action === "preview") {
|
|
599
|
+
return {
|
|
600
|
+
content: [
|
|
601
|
+
{
|
|
602
|
+
type: "text" as const,
|
|
603
|
+
text: `📧 FORWARD PREVIEW - Review before sending:
|
|
604
|
+
|
|
605
|
+
To: ${formatAddressList(forwardParams.to)}
|
|
606
|
+
CC: ${forwardParams.cc ? formatAddressList(forwardParams.cc) : "(none)"}
|
|
607
|
+
BCC: ${forwardParams.bcc ? formatAddressList(forwardParams.bcc) : "(none)"}
|
|
608
|
+
Subject: ${forwardParams.subject}
|
|
609
|
+
Forwarding from: ${forwardParams.originalFrom}
|
|
610
|
+
|
|
611
|
+
--- Your Message + Forwarded Content ---
|
|
612
|
+
${forwardParams.textBody}
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
To send this forward, call this tool again with action: "confirm" and the same parameters.`,
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const emailId = await sendEmail(forwardParams);
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
content: [
|
|
625
|
+
{
|
|
626
|
+
type: "text" as const,
|
|
627
|
+
text: `✓ Email forwarded successfully!
|
|
628
|
+
To: ${formatAddressList(forwardParams.to)}
|
|
629
|
+
Subject: ${forwardParams.subject}
|
|
630
|
+
Email ID: ${emailId}`,
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
};
|
|
634
|
+
},
|
|
635
|
+
);
|
|
636
|
+
|
|
481
637
|
// ============ Attachment Tools ============
|
|
482
638
|
|
|
483
639
|
server.tool(
|
|
@@ -685,6 +841,135 @@ server.tool(
|
|
|
685
841
|
},
|
|
686
842
|
);
|
|
687
843
|
|
|
844
|
+
// ============ Masked Email Tools ============
|
|
845
|
+
|
|
846
|
+
function formatMaskedEmail(m: MaskedEmail): string {
|
|
847
|
+
const status = m.state || "unknown";
|
|
848
|
+
const domain = m.forDomain ? ` (${m.forDomain})` : "";
|
|
849
|
+
const desc = m.description ? ` - ${m.description}` : "";
|
|
850
|
+
const lastMsg = m.lastMessageAt
|
|
851
|
+
? `\n Last message: ${new Date(m.lastMessageAt).toLocaleString()}`
|
|
852
|
+
: "";
|
|
853
|
+
return `${m.email}${domain}${desc}\n Status: ${status}${lastMsg}\n ID: ${m.id}`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
server.tool(
|
|
857
|
+
"list_masked_emails",
|
|
858
|
+
"List all masked email addresses (aliases) in the account. Masked emails let you create disposable addresses that forward to your inbox.",
|
|
859
|
+
{},
|
|
860
|
+
async () => {
|
|
861
|
+
const maskedEmails = await listMaskedEmails();
|
|
862
|
+
|
|
863
|
+
if (maskedEmails.length === 0) {
|
|
864
|
+
return {
|
|
865
|
+
content: [{ type: "text" as const, text: "No masked emails found." }],
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Sort by state (enabled first), then by email
|
|
870
|
+
const sorted = maskedEmails.sort((a, b) => {
|
|
871
|
+
if (a.state === "enabled" && b.state !== "enabled") return -1;
|
|
872
|
+
if (a.state !== "enabled" && b.state === "enabled") return 1;
|
|
873
|
+
return a.email.localeCompare(b.email);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
const text = sorted.map(formatMaskedEmail).join("\n\n");
|
|
877
|
+
return {
|
|
878
|
+
content: [
|
|
879
|
+
{
|
|
880
|
+
type: "text" as const,
|
|
881
|
+
text: `Masked Emails (${maskedEmails.length}):\n\n${text}`,
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
};
|
|
885
|
+
},
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
server.tool(
|
|
889
|
+
"create_masked_email",
|
|
890
|
+
"Create a new masked email address. Perfect for signups where you want a disposable address. The masked email forwards to your inbox.",
|
|
891
|
+
{
|
|
892
|
+
for_domain: z
|
|
893
|
+
.string()
|
|
894
|
+
.optional()
|
|
895
|
+
.describe(
|
|
896
|
+
"The website/domain this masked email is for (e.g., 'netflix.com')",
|
|
897
|
+
),
|
|
898
|
+
description: z
|
|
899
|
+
.string()
|
|
900
|
+
.optional()
|
|
901
|
+
.describe(
|
|
902
|
+
"A note to remember what this is for (e.g., 'Netflix account')",
|
|
903
|
+
),
|
|
904
|
+
prefix: z
|
|
905
|
+
.string()
|
|
906
|
+
.optional()
|
|
907
|
+
.describe(
|
|
908
|
+
"Custom prefix for the email address (optional, random if not specified)",
|
|
909
|
+
),
|
|
910
|
+
},
|
|
911
|
+
async ({ for_domain, description, prefix }) => {
|
|
912
|
+
const maskedEmail = await createMaskedEmail({
|
|
913
|
+
forDomain: for_domain,
|
|
914
|
+
description,
|
|
915
|
+
emailPrefix: prefix,
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
return {
|
|
919
|
+
content: [
|
|
920
|
+
{
|
|
921
|
+
type: "text" as const,
|
|
922
|
+
text: `Created masked email:\n\n${formatMaskedEmail(maskedEmail)}`,
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
};
|
|
926
|
+
},
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
server.tool(
|
|
930
|
+
"enable_masked_email",
|
|
931
|
+
"Enable a disabled masked email address so it can receive emails again.",
|
|
932
|
+
{
|
|
933
|
+
id: z.string().describe("The masked email ID (from list_masked_emails)"),
|
|
934
|
+
},
|
|
935
|
+
async ({ id }) => {
|
|
936
|
+
await updateMaskedEmail(id, "enabled");
|
|
937
|
+
return {
|
|
938
|
+
content: [{ type: "text" as const, text: `Masked email ${id} enabled.` }],
|
|
939
|
+
};
|
|
940
|
+
},
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
server.tool(
|
|
944
|
+
"disable_masked_email",
|
|
945
|
+
"Disable a masked email address. Emails sent to it will be rejected but the address is preserved.",
|
|
946
|
+
{
|
|
947
|
+
id: z.string().describe("The masked email ID (from list_masked_emails)"),
|
|
948
|
+
},
|
|
949
|
+
async ({ id }) => {
|
|
950
|
+
await updateMaskedEmail(id, "disabled");
|
|
951
|
+
return {
|
|
952
|
+
content: [
|
|
953
|
+
{ type: "text" as const, text: `Masked email ${id} disabled.` },
|
|
954
|
+
],
|
|
955
|
+
};
|
|
956
|
+
},
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
server.tool(
|
|
960
|
+
"delete_masked_email",
|
|
961
|
+
"Permanently delete a masked email address. This cannot be undone!",
|
|
962
|
+
{
|
|
963
|
+
id: z.string().describe("The masked email ID (from list_masked_emails)"),
|
|
964
|
+
},
|
|
965
|
+
async ({ id }) => {
|
|
966
|
+
await updateMaskedEmail(id, "deleted");
|
|
967
|
+
return {
|
|
968
|
+
content: [{ type: "text" as const, text: `Masked email ${id} deleted.` }],
|
|
969
|
+
};
|
|
970
|
+
},
|
|
971
|
+
);
|
|
972
|
+
|
|
688
973
|
// ============ Resources ============
|
|
689
974
|
|
|
690
975
|
// Expose attachments as resources with blob content
|
package/src/jmap/client.ts
CHANGED
package/src/jmap/methods.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
EmailCreate,
|
|
6
6
|
Identity,
|
|
7
7
|
Mailbox,
|
|
8
|
+
MaskedEmail,
|
|
8
9
|
} from "./types.js";
|
|
9
10
|
|
|
10
11
|
// Standard properties to fetch for email listings
|
|
@@ -121,17 +122,143 @@ export async function getEmail(emailId: string): Promise<Email | null> {
|
|
|
121
122
|
return result.list[0] || null;
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
export async function getThreadEmails(threadId: string): Promise<Email[]> {
|
|
126
|
+
const client = getClient();
|
|
127
|
+
const accountId = await client.getAccountId();
|
|
128
|
+
|
|
129
|
+
// Get thread to find all email IDs
|
|
130
|
+
const threadResult = await client.call<{
|
|
131
|
+
list: { id: string; emailIds: string[] }[];
|
|
132
|
+
}>("Thread/get", {
|
|
133
|
+
accountId,
|
|
134
|
+
ids: [threadId],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const thread = threadResult.list[0];
|
|
138
|
+
if (!thread || thread.emailIds.length === 0) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Fetch all emails in the thread
|
|
143
|
+
const emailResult = await client.call<{ list: Email[] }>("Email/get", {
|
|
144
|
+
accountId,
|
|
145
|
+
ids: thread.emailIds,
|
|
146
|
+
properties: EMAIL_FULL_PROPERTIES,
|
|
147
|
+
fetchTextBodyValues: true,
|
|
148
|
+
fetchHTMLBodyValues: true,
|
|
149
|
+
maxBodyValueBytes: 1024 * 1024,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Sort by receivedAt ascending (oldest first)
|
|
153
|
+
return emailResult.list.sort(
|
|
154
|
+
(a, b) =>
|
|
155
|
+
new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime(),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface SearchFilter {
|
|
160
|
+
query?: string; // General search across subject, from, to, body
|
|
161
|
+
from?: string;
|
|
162
|
+
to?: string;
|
|
163
|
+
cc?: string;
|
|
164
|
+
bcc?: string;
|
|
165
|
+
subject?: string;
|
|
166
|
+
body?: string;
|
|
167
|
+
mailbox?: string; // Mailbox name or ID
|
|
168
|
+
hasAttachment?: boolean;
|
|
169
|
+
minSize?: number;
|
|
170
|
+
maxSize?: number;
|
|
171
|
+
before?: string; // ISO date or YYYY-MM-DD
|
|
172
|
+
after?: string; // ISO date or YYYY-MM-DD
|
|
173
|
+
unread?: boolean;
|
|
174
|
+
flagged?: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
124
177
|
export async function searchEmails(
|
|
125
|
-
|
|
178
|
+
filter: string | SearchFilter,
|
|
126
179
|
limit = 25,
|
|
127
180
|
): Promise<Email[]> {
|
|
128
181
|
const client = getClient();
|
|
129
182
|
const accountId = await client.getAccountId();
|
|
130
183
|
|
|
131
|
-
//
|
|
184
|
+
// Handle simple string query (backwards compat)
|
|
185
|
+
if (typeof filter === "string") {
|
|
186
|
+
filter = { query: filter };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Build JMAP filter
|
|
190
|
+
const jmapFilter: Record<string, unknown> = {};
|
|
191
|
+
|
|
192
|
+
// General query - OR across multiple fields (Fastmail doesn't support "text")
|
|
193
|
+
if (filter.query) {
|
|
194
|
+
const queryResult = await client.call<{ ids: string[] }>("Email/query", {
|
|
195
|
+
accountId,
|
|
196
|
+
filter: {
|
|
197
|
+
operator: "OR",
|
|
198
|
+
conditions: [
|
|
199
|
+
{ subject: filter.query },
|
|
200
|
+
{ from: filter.query },
|
|
201
|
+
{ to: filter.query },
|
|
202
|
+
{ body: filter.query },
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
sort: [{ property: "receivedAt", isAscending: false }],
|
|
206
|
+
limit,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (queryResult.ids.length === 0) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const getResult = await client.call<{ list: Email[] }>("Email/get", {
|
|
214
|
+
accountId,
|
|
215
|
+
ids: queryResult.ids,
|
|
216
|
+
properties: EMAIL_LIST_PROPERTIES,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return getResult.list;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Specific field filters
|
|
223
|
+
if (filter.from) jmapFilter.from = filter.from;
|
|
224
|
+
if (filter.to) jmapFilter.to = filter.to;
|
|
225
|
+
if (filter.cc) jmapFilter.cc = filter.cc;
|
|
226
|
+
if (filter.bcc) jmapFilter.bcc = filter.bcc;
|
|
227
|
+
if (filter.subject) jmapFilter.subject = filter.subject;
|
|
228
|
+
if (filter.body) jmapFilter.body = filter.body;
|
|
229
|
+
|
|
230
|
+
// Mailbox filter
|
|
231
|
+
if (filter.mailbox) {
|
|
232
|
+
const mailbox = await getMailboxByName(filter.mailbox);
|
|
233
|
+
if (mailbox) {
|
|
234
|
+
jmapFilter.inMailbox = mailbox.id;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Boolean/size filters
|
|
239
|
+
if (filter.hasAttachment) jmapFilter.hasAttachment = true;
|
|
240
|
+
if (filter.minSize) jmapFilter.minSize = filter.minSize;
|
|
241
|
+
if (filter.maxSize) jmapFilter.maxSize = filter.maxSize;
|
|
242
|
+
|
|
243
|
+
// Date filters - normalize to ISO 8601
|
|
244
|
+
if (filter.before) {
|
|
245
|
+
jmapFilter.before = filter.before.includes("T")
|
|
246
|
+
? filter.before
|
|
247
|
+
: `${filter.before}T00:00:00Z`;
|
|
248
|
+
}
|
|
249
|
+
if (filter.after) {
|
|
250
|
+
jmapFilter.after = filter.after.includes("T")
|
|
251
|
+
? filter.after
|
|
252
|
+
: `${filter.after}T00:00:00Z`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Keyword filters
|
|
256
|
+
if (filter.unread) jmapFilter.notKeyword = "$seen";
|
|
257
|
+
if (filter.flagged) jmapFilter.hasKeyword = "$flagged";
|
|
258
|
+
|
|
132
259
|
const queryResult = await client.call<{ ids: string[] }>("Email/query", {
|
|
133
260
|
accountId,
|
|
134
|
-
filter:
|
|
261
|
+
filter: jmapFilter,
|
|
135
262
|
sort: [{ property: "receivedAt", isAscending: false }],
|
|
136
263
|
limit,
|
|
137
264
|
});
|
|
@@ -524,3 +651,152 @@ export async function buildReply(
|
|
|
524
651
|
references: references.length > 0 ? references : undefined,
|
|
525
652
|
};
|
|
526
653
|
}
|
|
654
|
+
|
|
655
|
+
// Helper to build a forward
|
|
656
|
+
export async function buildForward(
|
|
657
|
+
originalEmailId: string,
|
|
658
|
+
forwardBody: string,
|
|
659
|
+
): Promise<
|
|
660
|
+
SendEmailParams & { originalSubject: string; originalFrom: string }
|
|
661
|
+
> {
|
|
662
|
+
const original = await getEmail(originalEmailId);
|
|
663
|
+
if (!original) {
|
|
664
|
+
throw new Error(`Original email not found: ${originalEmailId}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Build subject (add Fwd: if not present)
|
|
668
|
+
let subject = original.subject || "";
|
|
669
|
+
if (!subject.toLowerCase().startsWith("fwd:")) {
|
|
670
|
+
subject = `Fwd: ${subject}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Get original body
|
|
674
|
+
let originalBodyText = "";
|
|
675
|
+
if (original.bodyValues) {
|
|
676
|
+
const textPart = original.textBody?.[0];
|
|
677
|
+
if (textPart?.partId && original.bodyValues[textPart.partId]) {
|
|
678
|
+
originalBodyText = original.bodyValues[textPart.partId]?.value ?? "";
|
|
679
|
+
} else {
|
|
680
|
+
const firstValue = Object.values(original.bodyValues)[0];
|
|
681
|
+
if (firstValue) {
|
|
682
|
+
originalBodyText = firstValue.value;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Format sender
|
|
688
|
+
const sender = original.from?.[0];
|
|
689
|
+
const senderStr = sender
|
|
690
|
+
? sender.name
|
|
691
|
+
? `${sender.name} <${sender.email}>`
|
|
692
|
+
: sender.email
|
|
693
|
+
: "unknown";
|
|
694
|
+
|
|
695
|
+
const date = original.receivedAt
|
|
696
|
+
? new Date(original.receivedAt).toLocaleString()
|
|
697
|
+
: "unknown date";
|
|
698
|
+
|
|
699
|
+
// Build full body with attribution
|
|
700
|
+
const fullBody = `${forwardBody}
|
|
701
|
+
|
|
702
|
+
---------- Forwarded message ---------
|
|
703
|
+
From: ${senderStr}
|
|
704
|
+
Date: ${date}
|
|
705
|
+
Subject: ${original.subject || ""}
|
|
706
|
+
|
|
707
|
+
${originalBodyText}`;
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
to: [], // Caller must provide
|
|
711
|
+
subject,
|
|
712
|
+
textBody: fullBody,
|
|
713
|
+
originalSubject: original.subject || "",
|
|
714
|
+
originalFrom: senderStr,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ============ Masked Email Methods ============
|
|
719
|
+
|
|
720
|
+
export async function listMaskedEmails(): Promise<MaskedEmail[]> {
|
|
721
|
+
const client = getClient();
|
|
722
|
+
const accountId = await client.getAccountId();
|
|
723
|
+
|
|
724
|
+
const result = await client.call<{ list: MaskedEmail[] }>("MaskedEmail/get", {
|
|
725
|
+
accountId,
|
|
726
|
+
ids: null, // null = get all
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
return result.list;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export interface CreateMaskedEmailParams {
|
|
733
|
+
forDomain?: string;
|
|
734
|
+
description?: string;
|
|
735
|
+
emailPrefix?: string;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export async function createMaskedEmail(
|
|
739
|
+
params: CreateMaskedEmailParams = {},
|
|
740
|
+
): Promise<MaskedEmail> {
|
|
741
|
+
const client = getClient();
|
|
742
|
+
const accountId = await client.getAccountId();
|
|
743
|
+
|
|
744
|
+
const createObj: Record<string, unknown> = {
|
|
745
|
+
state: "enabled",
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
if (params.forDomain) {
|
|
749
|
+
createObj.forDomain = params.forDomain;
|
|
750
|
+
}
|
|
751
|
+
if (params.description) {
|
|
752
|
+
createObj.description = params.description;
|
|
753
|
+
}
|
|
754
|
+
if (params.emailPrefix) {
|
|
755
|
+
createObj.emailPrefix = params.emailPrefix;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const result = await client.call<{
|
|
759
|
+
created?: Record<string, MaskedEmail>;
|
|
760
|
+
notCreated?: Record<string, { type: string; description?: string }>;
|
|
761
|
+
}>("MaskedEmail/set", {
|
|
762
|
+
accountId,
|
|
763
|
+
create: { new: createObj },
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
if (result.notCreated?.new) {
|
|
767
|
+
const err = result.notCreated.new;
|
|
768
|
+
throw new Error(
|
|
769
|
+
`Failed to create masked email: ${err.type}${err.description ? ` - ${err.description}` : ""}`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const created = result.created?.new;
|
|
774
|
+
if (!created) {
|
|
775
|
+
throw new Error("No masked email returned from create");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return created;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
export async function updateMaskedEmail(
|
|
782
|
+
id: string,
|
|
783
|
+
state: "enabled" | "disabled" | "deleted",
|
|
784
|
+
): Promise<void> {
|
|
785
|
+
const client = getClient();
|
|
786
|
+
const accountId = await client.getAccountId();
|
|
787
|
+
|
|
788
|
+
const result = await client.call<{
|
|
789
|
+
updated?: Record<string, unknown>;
|
|
790
|
+
notUpdated?: Record<string, { type: string; description?: string }>;
|
|
791
|
+
}>("MaskedEmail/set", {
|
|
792
|
+
accountId,
|
|
793
|
+
update: { [id]: { state } },
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
if (result.notUpdated?.[id]) {
|
|
797
|
+
const err = result.notUpdated[id];
|
|
798
|
+
throw new Error(
|
|
799
|
+
`Failed to update masked email: ${err.type}${err.description ? ` - ${err.description}` : ""}`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
package/src/jmap/types.ts
CHANGED
|
@@ -202,3 +202,16 @@ export interface EmailCreate {
|
|
|
202
202
|
messageId?: string[];
|
|
203
203
|
headers?: EmailHeader[];
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
// Masked Email Types (Fastmail-specific)
|
|
207
|
+
export interface MaskedEmail {
|
|
208
|
+
id: string;
|
|
209
|
+
email: string;
|
|
210
|
+
state?: string; // pending, enabled, disabled, deleted
|
|
211
|
+
forDomain?: string | null;
|
|
212
|
+
description?: string | null;
|
|
213
|
+
lastMessageAt?: string | null;
|
|
214
|
+
createdAt?: string | null;
|
|
215
|
+
createdBy?: string | null;
|
|
216
|
+
url?: string | null;
|
|
217
|
+
}
|