fastmail-mcp-server 0.2.2 → 0.4.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/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 reply
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** | ✅ | ❌ | ❌ |
@@ -142,6 +149,17 @@ Claude receives actual text content, not binary blobs - just like when you drag-
142
149
  | `mark_as_spam` | Move to Junk + train filter | **Yes** |
143
150
  | `send_email` | Send a new email | **Yes** |
144
151
  | `reply_to_email` | Reply to an email thread | **Yes** |
152
+ | `forward_email` | Forward an email | **Yes** |
153
+
154
+ ### Masked Email Operations
155
+
156
+ | Tool | Description |
157
+ | ---------------------- | ---------------------------------- |
158
+ | `list_masked_emails` | List all masked email addresses |
159
+ | `create_masked_email` | Create a new disposable address |
160
+ | `enable_masked_email` | Re-enable a disabled masked email |
161
+ | `disable_masked_email` | Stop receiving at a masked address |
162
+ | `delete_masked_email` | Permanently delete a masked email |
145
163
 
146
164
  ## Example Prompts
147
165
 
@@ -152,13 +170,23 @@ Claude receives actual text content, not binary blobs - just like when you drag-
152
170
 
153
171
  "Search for emails from john@example.com"
154
172
 
173
+ "Find unread emails from last week with attachments"
174
+
175
+ "Show me flagged emails from December"
176
+
155
177
  "What would be a good response to the latest email from the solicitor?"
156
178
 
157
179
  "Draft a reply to that insurance email explaining the situation"
158
180
 
181
+ "Forward that receipt to my accountant"
182
+
159
183
  "Move all the newsletters to Archive"
160
184
 
161
185
  "Mark that spam email as junk"
186
+
187
+ "Create a masked email for signing up to this sketchy website"
188
+
189
+ "List my masked emails and disable the one for that service I cancelled"
162
190
  ```
163
191
 
164
192
  ## Troubleshooting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastmail-mcp-server",
3
- "version": "0.2.2",
3
+ "version": "0.4.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",
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 { Email, EmailAddress, Mailbox } from "./jmap/types.js";
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.2.2",
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. Returns complete email with headers, body text, and attachment info.",
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
- const text = formatEmailFull(email);
166
- return { content: [{ type: "text" as const, text }] };
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 across all mailboxes. Supports full-text search of email content, subjects, and addresses.",
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
- .describe(
177
- "Search query - searches subject, body, and addresses. Examples: 'from:alice@example.com', 'subject:invoice', 'meeting notes'",
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 ({ query, limit }) => {
185
- const emails = await searchEmails(query, Math.min(limit || 25, 100));
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
@@ -53,6 +53,7 @@ export class JMAPClient {
53
53
  "urn:ietf:params:jmap:core",
54
54
  "urn:ietf:params:jmap:mail",
55
55
  "urn:ietf:params:jmap:submission",
56
+ "https://www.fastmail.com/dev/maskedemail",
56
57
  ],
57
58
  methodCalls,
58
59
  };
@@ -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
- query: string,
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
- // Query for email IDs with text filter
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: { text: query },
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
+ }