apple-mail-mcp 1.4.0 → 1.5.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 +2 -0
- package/build/index.js +0 -0
- package/build/services/appleMailManager.d.ts +22 -0
- package/build/services/appleMailManager.d.ts.map +1 -1
- package/build/services/appleMailManager.js +144 -28
- package/build/utils/mimeParse.d.ts +41 -0
- package/build/utils/mimeParse.d.ts.map +1 -0
- package/build/utils/mimeParse.js +238 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e
|
|
|
6
6
|
[](https://github.com/sweetrb/apple-mail-mcp/actions/workflows/ci.yml)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
|
|
9
|
+
> **Note:** This is the **npm/Node.js** package — install with `npx` or `npm`. There is an unrelated Python project of the same name on PyPI ([`imdinu/apple-mail-mcp`](https://github.com/imdinu/apple-mail-mcp)) installed via `pipx`/`uvx`. If you're using `uvx` and seeing a `cyclopts` dependency error, you're looking for that project, not this one.
|
|
10
|
+
|
|
9
11
|
## What is This?
|
|
10
12
|
|
|
11
13
|
This server acts as a bridge between AI assistants and Apple Mail. Once configured, you can ask Claude (or any MCP-compatible AI) to:
|
package/build/index.js
CHANGED
|
File without changes
|
|
@@ -100,6 +100,16 @@ export declare class AppleMailManager {
|
|
|
100
100
|
* Get the content of a message.
|
|
101
101
|
*/
|
|
102
102
|
getMessageContent(id: string): MessageContent | null;
|
|
103
|
+
/**
|
|
104
|
+
* Get the raw MIME source of a message.
|
|
105
|
+
* Used as fallback for attachment extraction when AppleScript
|
|
106
|
+
* mail attachments returns empty.
|
|
107
|
+
*
|
|
108
|
+
* Timeout is 2x the default (120s) because `source of msg` returns
|
|
109
|
+
* the entire raw message including base64-encoded attachments —
|
|
110
|
+
* a 20MB attachment can take several seconds over Exchange/IMAP.
|
|
111
|
+
*/
|
|
112
|
+
getRawSource(id: string): string | null;
|
|
103
113
|
/**
|
|
104
114
|
* List messages in a mailbox.
|
|
105
115
|
*
|
|
@@ -111,6 +121,14 @@ export declare class AppleMailManager {
|
|
|
111
121
|
listMessages(mailbox?: string, account?: string, limit?: number, from?: string, offset?: number): Message[];
|
|
112
122
|
/**
|
|
113
123
|
* Parse message list output from AppleScript.
|
|
124
|
+
*
|
|
125
|
+
* Two emission schemas, disambiguated by length:
|
|
126
|
+
* 7 fields: single-mailbox — ...|hasAtt (mailbox from caller)
|
|
127
|
+
* 8 fields: all-mailboxes — ...|mailbox|hasAtt
|
|
128
|
+
*
|
|
129
|
+
* `hasAttachments` here is the fast-path AppleScript count only; it will
|
|
130
|
+
* false-negative for MIME-embedded attachments (a known AppleScript
|
|
131
|
+
* limitation). Use getMessage or list-attachments for authoritative info.
|
|
114
132
|
*/
|
|
115
133
|
private parseMessageList;
|
|
116
134
|
/**
|
|
@@ -233,10 +251,14 @@ export declare class AppleMailManager {
|
|
|
233
251
|
batchUnflagMessages(ids: string[]): BatchOperationResult[];
|
|
234
252
|
/**
|
|
235
253
|
* List attachments for a message.
|
|
254
|
+
* Tries AppleScript first, falls back to MIME source parsing
|
|
255
|
+
* when AppleScript returns empty (known issue across all account types).
|
|
236
256
|
*/
|
|
237
257
|
listAttachments(id: string): Attachment[];
|
|
238
258
|
/**
|
|
239
259
|
* Save an attachment from a message to disk.
|
|
260
|
+
* Tries AppleScript first, falls back to MIME source extraction
|
|
261
|
+
* when AppleScript can't find the attachment.
|
|
240
262
|
*/
|
|
241
263
|
saveAttachment(id: string, attachmentName: string, savePath: string): boolean;
|
|
242
264
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;
|
|
1
|
+
{"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"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"}
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* @module services/appleMailManager
|
|
14
14
|
*/
|
|
15
15
|
import { spawnSync } from "child_process";
|
|
16
|
-
import { existsSync } from "fs";
|
|
16
|
+
import { existsSync, writeFileSync } from "fs";
|
|
17
17
|
import { isAbsolute, resolve } from "path";
|
|
18
18
|
import { homedir } from "os";
|
|
19
19
|
import { executeAppleScript } from "../utils/applescript.js";
|
|
20
|
+
import { parseMimeAttachments, extractMimeAttachment } from "../utils/mimeParse.js";
|
|
20
21
|
// =============================================================================
|
|
21
22
|
// Text Processing Utilities
|
|
22
23
|
// =============================================================================
|
|
@@ -423,7 +424,24 @@ export class AppleMailManager {
|
|
|
423
424
|
set msgDeleted to deleted status of msg as string
|
|
424
425
|
set msgMailbox to name of mb
|
|
425
426
|
set msgAccount to name of acct
|
|
426
|
-
|
|
427
|
+
set hasAtt to "false"
|
|
428
|
+
try
|
|
429
|
+
set attCount to count of mail attachments of msg
|
|
430
|
+
if attCount > 0 then set hasAtt to "true"
|
|
431
|
+
end try
|
|
432
|
+
-- MIME-embedded attachments are invisible to AppleScript's
|
|
433
|
+
-- attachment object. Fall back to scanning the raw source.
|
|
434
|
+
-- This reads the full message source (can be MB-sized for
|
|
435
|
+
-- messages with large bodies), so it's the slowest part of
|
|
436
|
+
-- get-message for attachmentless messages. Accepted as the
|
|
437
|
+
-- cost of correct hasAttachments in the detail view.
|
|
438
|
+
if hasAtt is "false" then
|
|
439
|
+
try
|
|
440
|
+
set rawSrc to source of msg
|
|
441
|
+
if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
|
|
442
|
+
end try
|
|
443
|
+
end if
|
|
444
|
+
return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount & "|||" & hasAtt
|
|
427
445
|
end if
|
|
428
446
|
end try
|
|
429
447
|
end repeat
|
|
@@ -453,7 +471,7 @@ export class AppleMailManager {
|
|
|
453
471
|
isDeleted: parts[6] === "true",
|
|
454
472
|
mailbox: parts[7],
|
|
455
473
|
account: parts[8],
|
|
456
|
-
hasAttachments: false,
|
|
474
|
+
hasAttachments: parts.length > 9 ? parts[9] === "true" : false,
|
|
457
475
|
};
|
|
458
476
|
}
|
|
459
477
|
/**
|
|
@@ -502,6 +520,40 @@ export class AppleMailManager {
|
|
|
502
520
|
htmlContent: htmlContent || undefined,
|
|
503
521
|
};
|
|
504
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Get the raw MIME source of a message.
|
|
525
|
+
* Used as fallback for attachment extraction when AppleScript
|
|
526
|
+
* mail attachments returns empty.
|
|
527
|
+
*
|
|
528
|
+
* Timeout is 2x the default (120s) because `source of msg` returns
|
|
529
|
+
* the entire raw message including base64-encoded attachments —
|
|
530
|
+
* a 20MB attachment can take several seconds over Exchange/IMAP.
|
|
531
|
+
*/
|
|
532
|
+
getRawSource(id) {
|
|
533
|
+
const script = buildAppLevelScript(`
|
|
534
|
+
try
|
|
535
|
+
repeat with acct in accounts
|
|
536
|
+
repeat with mb in mailboxes of acct
|
|
537
|
+
try
|
|
538
|
+
set matchingMsgs to (messages of mb whose id is ${Number(id)})
|
|
539
|
+
if (count of matchingMsgs) > 0 then
|
|
540
|
+
set msg to item 1 of matchingMsgs
|
|
541
|
+
return source of msg
|
|
542
|
+
end if
|
|
543
|
+
end try
|
|
544
|
+
end repeat
|
|
545
|
+
end repeat
|
|
546
|
+
return ""
|
|
547
|
+
on error errMsg
|
|
548
|
+
return ""
|
|
549
|
+
end try
|
|
550
|
+
`);
|
|
551
|
+
const result = executeAppleScript(script, { timeoutMs: 120000 });
|
|
552
|
+
if (!result.success || !result.output.trim()) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
return result.output;
|
|
556
|
+
}
|
|
505
557
|
/**
|
|
506
558
|
* List messages in a mailbox.
|
|
507
559
|
*
|
|
@@ -549,8 +601,12 @@ export class AppleMailManager {
|
|
|
549
601
|
set msgDate to ${AS_DATE_TO_STRING}
|
|
550
602
|
set msgRead to read status of msg as string
|
|
551
603
|
set msgFlagged to flagged status of msg as string
|
|
604
|
+
set msgHasAtt to "false"
|
|
605
|
+
try
|
|
606
|
+
if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
|
|
607
|
+
end try
|
|
552
608
|
if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
|
|
553
|
-
set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged
|
|
609
|
+
set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgHasAtt
|
|
554
610
|
set msgCount to msgCount + 1
|
|
555
611
|
end if
|
|
556
612
|
end try
|
|
@@ -583,8 +639,12 @@ export class AppleMailManager {
|
|
|
583
639
|
set msgDate to ${AS_DATE_TO_STRING}
|
|
584
640
|
set msgRead to read status of msg as string
|
|
585
641
|
set msgFlagged to flagged status of msg as string
|
|
642
|
+
set msgHasAtt to "false"
|
|
643
|
+
try
|
|
644
|
+
if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
|
|
645
|
+
end try
|
|
586
646
|
if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
|
|
587
|
-
set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb
|
|
647
|
+
set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb & "|||" & msgHasAtt
|
|
588
648
|
set msgCount to msgCount + 1
|
|
589
649
|
end if
|
|
590
650
|
end if
|
|
@@ -607,6 +667,14 @@ export class AppleMailManager {
|
|
|
607
667
|
}
|
|
608
668
|
/**
|
|
609
669
|
* Parse message list output from AppleScript.
|
|
670
|
+
*
|
|
671
|
+
* Two emission schemas, disambiguated by length:
|
|
672
|
+
* 7 fields: single-mailbox — ...|hasAtt (mailbox from caller)
|
|
673
|
+
* 8 fields: all-mailboxes — ...|mailbox|hasAtt
|
|
674
|
+
*
|
|
675
|
+
* `hasAttachments` here is the fast-path AppleScript count only; it will
|
|
676
|
+
* false-negative for MIME-embedded attachments (a known AppleScript
|
|
677
|
+
* limitation). Use getMessage or list-attachments for authoritative info.
|
|
610
678
|
*/
|
|
611
679
|
parseMessageList(output, mailbox, account) {
|
|
612
680
|
const items = output.split("|||ITEM|||");
|
|
@@ -615,6 +683,15 @@ export class AppleMailManager {
|
|
|
615
683
|
const parts = item.split("|||");
|
|
616
684
|
if (parts.length < 6)
|
|
617
685
|
continue;
|
|
686
|
+
let msgMailbox = mailbox;
|
|
687
|
+
let hasAttachments = false;
|
|
688
|
+
if (parts.length >= 8) {
|
|
689
|
+
msgMailbox = parts[6];
|
|
690
|
+
hasAttachments = parts[7] === "true";
|
|
691
|
+
}
|
|
692
|
+
else if (parts.length === 7) {
|
|
693
|
+
hasAttachments = parts[6] === "true";
|
|
694
|
+
}
|
|
618
695
|
messages.push({
|
|
619
696
|
id: parts[0].trim(),
|
|
620
697
|
subject: parts[1],
|
|
@@ -625,9 +702,9 @@ export class AppleMailManager {
|
|
|
625
702
|
isFlagged: parts[5] === "true",
|
|
626
703
|
isJunk: false,
|
|
627
704
|
isDeleted: false,
|
|
628
|
-
mailbox:
|
|
705
|
+
mailbox: msgMailbox,
|
|
629
706
|
account,
|
|
630
|
-
hasAttachments
|
|
707
|
+
hasAttachments,
|
|
631
708
|
});
|
|
632
709
|
}
|
|
633
710
|
return messages;
|
|
@@ -1102,8 +1179,11 @@ export class AppleMailManager {
|
|
|
1102
1179
|
}
|
|
1103
1180
|
/**
|
|
1104
1181
|
* List attachments for a message.
|
|
1182
|
+
* Tries AppleScript first, falls back to MIME source parsing
|
|
1183
|
+
* when AppleScript returns empty (known issue across all account types).
|
|
1105
1184
|
*/
|
|
1106
1185
|
listAttachments(id) {
|
|
1186
|
+
// Attempt 1: AppleScript mail attachments
|
|
1107
1187
|
const script = buildAppLevelScript(`
|
|
1108
1188
|
try
|
|
1109
1189
|
repeat with acct in accounts
|
|
@@ -1133,26 +1213,39 @@ export class AppleMailManager {
|
|
|
1133
1213
|
end try
|
|
1134
1214
|
`);
|
|
1135
1215
|
const result = executeAppleScript(script, { timeoutMs: 60000 });
|
|
1136
|
-
if (
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1216
|
+
if (result.success && result.output.trim()) {
|
|
1217
|
+
const items = result.output.split("|||ITEM|||");
|
|
1218
|
+
const attachments = [];
|
|
1219
|
+
for (const item of items) {
|
|
1220
|
+
const parts = item.split("|||");
|
|
1221
|
+
if (parts.length < 3)
|
|
1222
|
+
continue;
|
|
1223
|
+
attachments.push({
|
|
1224
|
+
id: `${id}-${parts[0]}`,
|
|
1225
|
+
name: parts[0],
|
|
1226
|
+
mimeType: parts[1],
|
|
1227
|
+
size: parseInt(parts[2]) || 0,
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
if (attachments.length > 0)
|
|
1231
|
+
return attachments;
|
|
1151
1232
|
}
|
|
1152
|
-
|
|
1233
|
+
// Attempt 2: MIME source fallback
|
|
1234
|
+
const rawSource = this.getRawSource(id);
|
|
1235
|
+
if (!rawSource)
|
|
1236
|
+
return [];
|
|
1237
|
+
const mimeAttachments = parseMimeAttachments(rawSource);
|
|
1238
|
+
return mimeAttachments.map((att) => ({
|
|
1239
|
+
id: `${id}-${att.name}`,
|
|
1240
|
+
name: att.name,
|
|
1241
|
+
mimeType: att.mimeType,
|
|
1242
|
+
size: att.size,
|
|
1243
|
+
}));
|
|
1153
1244
|
}
|
|
1154
1245
|
/**
|
|
1155
1246
|
* Save an attachment from a message to disk.
|
|
1247
|
+
* Tries AppleScript first, falls back to MIME source extraction
|
|
1248
|
+
* when AppleScript can't find the attachment.
|
|
1156
1249
|
*/
|
|
1157
1250
|
saveAttachment(id, attachmentName, savePath) {
|
|
1158
1251
|
// Validate attachment name: block path separators, traversal, null bytes, and backslashes
|
|
@@ -1170,9 +1263,8 @@ export class AppleMailManager {
|
|
|
1170
1263
|
}
|
|
1171
1264
|
const safeName = escapeForAppleScript(attachmentName);
|
|
1172
1265
|
const safePath = escapeForAppleScript(resolvedPath);
|
|
1173
|
-
// Use Number(id) as defense-in-depth — the Zod schema already enforces numeric IDs,
|
|
1174
|
-
// but this ensures raw interpolation into AppleScript is safe even if validation changes.
|
|
1175
1266
|
const numericId = Number(id);
|
|
1267
|
+
// Attempt 1: AppleScript save
|
|
1176
1268
|
const script = buildAppLevelScript(`
|
|
1177
1269
|
try
|
|
1178
1270
|
repeat with acct in accounts
|
|
@@ -1199,11 +1291,35 @@ export class AppleMailManager {
|
|
|
1199
1291
|
end try
|
|
1200
1292
|
`);
|
|
1201
1293
|
const result = executeAppleScript(script, { timeoutMs: 60000 });
|
|
1202
|
-
if (
|
|
1203
|
-
|
|
1294
|
+
if (result.success && result.output === "ok") {
|
|
1295
|
+
return true;
|
|
1296
|
+
}
|
|
1297
|
+
// Attempt 2: MIME source fallback
|
|
1298
|
+
const rawSource = this.getRawSource(id);
|
|
1299
|
+
if (!rawSource) {
|
|
1300
|
+
console.error(`Failed to save attachment: could not retrieve message source`);
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
const attachment = extractMimeAttachment(rawSource, attachmentName);
|
|
1304
|
+
if (!attachment) {
|
|
1305
|
+
console.error(`Failed to save attachment: "${attachmentName}" not found in MIME source`);
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
const outPath = resolve(resolvedPath, attachmentName);
|
|
1310
|
+
// Verify the resolved output path is still within allowed directories
|
|
1311
|
+
const isOutAllowed = allowedPrefixes.some((prefix) => outPath.startsWith(prefix));
|
|
1312
|
+
if (!isOutAllowed) {
|
|
1313
|
+
console.error(`Output path "${outPath}" is outside allowed directories`);
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1316
|
+
writeFileSync(outPath, attachment.data);
|
|
1317
|
+
return true;
|
|
1318
|
+
}
|
|
1319
|
+
catch (err) {
|
|
1320
|
+
console.error(`Failed to write attachment to disk: ${err}`);
|
|
1204
1321
|
return false;
|
|
1205
1322
|
}
|
|
1206
|
-
return true;
|
|
1207
1323
|
}
|
|
1208
1324
|
// ===========================================================================
|
|
1209
1325
|
// Mailbox Operations
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIME Source Parser for Attachment Extraction
|
|
3
|
+
*
|
|
4
|
+
* Parses raw email MIME source to extract attachment metadata and content.
|
|
5
|
+
* Used as a fallback when AppleScript's `mail attachments` returns empty
|
|
6
|
+
* (which happens across all account types: iCloud, Google, Exchange).
|
|
7
|
+
*
|
|
8
|
+
* @module utils/mimeParse
|
|
9
|
+
*/
|
|
10
|
+
export interface MimeAttachmentInfo {
|
|
11
|
+
/** Filename from Content-Disposition or Content-Type name parameter */
|
|
12
|
+
name: string;
|
|
13
|
+
/** MIME type from Content-Type header */
|
|
14
|
+
mimeType: string;
|
|
15
|
+
/** Size in bytes from Content-Disposition size parameter, or estimated from body */
|
|
16
|
+
size: number;
|
|
17
|
+
}
|
|
18
|
+
export interface MimeAttachmentData extends MimeAttachmentInfo {
|
|
19
|
+
/** Decoded binary content */
|
|
20
|
+
data: Buffer;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parse MIME source and return metadata for all file attachments.
|
|
24
|
+
* Skips inline dispositions (signature images, etc.). Descends into
|
|
25
|
+
* nested multipart/* containers.
|
|
26
|
+
*
|
|
27
|
+
* @param source - Raw MIME source of the email
|
|
28
|
+
* @returns Array of attachment metadata (name, mimeType, size)
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseMimeAttachments(source: string): MimeAttachmentInfo[];
|
|
31
|
+
/**
|
|
32
|
+
* Extract and decode a specific attachment from MIME source by filename.
|
|
33
|
+
* Supports base64, quoted-printable, and 7bit/8bit/binary transfer encodings.
|
|
34
|
+
* Descends into nested multipart/* containers.
|
|
35
|
+
*
|
|
36
|
+
* @param source - Raw MIME source of the email
|
|
37
|
+
* @param attachmentName - Filename to extract
|
|
38
|
+
* @returns Decoded attachment data, or null if not found
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractMimeAttachment(source: string, attachmentName: string): MimeAttachmentData | null;
|
|
41
|
+
//# sourceMappingURL=mimeParse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mimeParse.d.ts","sourceRoot":"","sources":["../../src/utils/mimeParse.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,IAAI,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;CACd;AAyLD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAyBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,kBAAkB,GAAG,IAAI,CAwB3B"}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIME Source Parser for Attachment Extraction
|
|
3
|
+
*
|
|
4
|
+
* Parses raw email MIME source to extract attachment metadata and content.
|
|
5
|
+
* Used as a fallback when AppleScript's `mail attachments` returns empty
|
|
6
|
+
* (which happens across all account types: iCloud, Google, Exchange).
|
|
7
|
+
*
|
|
8
|
+
* @module utils/mimeParse
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Extract the boundary string from a Content-Type header value
|
|
12
|
+
* (or from any string containing a boundary= parameter).
|
|
13
|
+
*/
|
|
14
|
+
function extractBoundary(source) {
|
|
15
|
+
const match = source.match(/boundary="?([^";\s\r\n]+)"?/i);
|
|
16
|
+
return match ? match[1] : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Extract a header value from a MIME part header block.
|
|
20
|
+
* Handles folded headers (continuation lines starting with whitespace).
|
|
21
|
+
*/
|
|
22
|
+
function getHeader(headers, name) {
|
|
23
|
+
const regex = new RegExp(`^${name}:\\s*(.+(?:\\r?\\n[ \\t]+.+)*)`, "im");
|
|
24
|
+
const match = headers.match(regex);
|
|
25
|
+
if (!match)
|
|
26
|
+
return null;
|
|
27
|
+
// Unfold: replace newline+whitespace with single space
|
|
28
|
+
return match[1].replace(/\r?\n[ \t]+/g, " ").trim();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Extract filename from Content-Disposition or Content-Type headers.
|
|
32
|
+
*/
|
|
33
|
+
function extractFilename(headers) {
|
|
34
|
+
// Try Content-Disposition filename first
|
|
35
|
+
const dispHeader = getHeader(headers, "Content-Disposition");
|
|
36
|
+
if (dispHeader) {
|
|
37
|
+
const fnMatch = dispHeader.match(/filename="?([^";\r\n]+)"?/i);
|
|
38
|
+
if (fnMatch)
|
|
39
|
+
return fnMatch[1].trim();
|
|
40
|
+
}
|
|
41
|
+
// Fall back to Content-Type name parameter
|
|
42
|
+
const ctHeader = getHeader(headers, "Content-Type");
|
|
43
|
+
if (ctHeader) {
|
|
44
|
+
const nameMatch = ctHeader.match(/name="?([^";\r\n]+)"?/i);
|
|
45
|
+
if (nameMatch)
|
|
46
|
+
return nameMatch[1].trim();
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Check if a MIME part has inline disposition (not a real attachment).
|
|
52
|
+
*/
|
|
53
|
+
function isInlineDisposition(headers) {
|
|
54
|
+
const dispHeader = getHeader(headers, "Content-Disposition");
|
|
55
|
+
if (!dispHeader)
|
|
56
|
+
return false;
|
|
57
|
+
return dispHeader.toLowerCase().startsWith("inline");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Extract size from Content-Disposition size parameter.
|
|
61
|
+
*/
|
|
62
|
+
function extractSize(headers) {
|
|
63
|
+
const dispHeader = getHeader(headers, "Content-Disposition");
|
|
64
|
+
if (dispHeader) {
|
|
65
|
+
const sizeMatch = dispHeader.match(/size=(\d+)/i);
|
|
66
|
+
if (sizeMatch)
|
|
67
|
+
return parseInt(sizeMatch[1], 10);
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Extract MIME type from Content-Type header.
|
|
73
|
+
*/
|
|
74
|
+
function extractMimeType(headers) {
|
|
75
|
+
const ctHeader = getHeader(headers, "Content-Type");
|
|
76
|
+
if (!ctHeader)
|
|
77
|
+
return "application/octet-stream";
|
|
78
|
+
const typeMatch = ctHeader.match(/^([^;\s]+)/);
|
|
79
|
+
return typeMatch ? typeMatch[1].toLowerCase() : "application/octet-stream";
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Estimate decoded size from base64 content length.
|
|
83
|
+
*/
|
|
84
|
+
function estimateBase64Size(base64Body) {
|
|
85
|
+
const cleaned = base64Body.replace(/[\s\r\n]/g, "");
|
|
86
|
+
return Math.floor((cleaned.length * 3) / 4);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Split a MIME block into parts using the given boundary.
|
|
90
|
+
* Does not recurse — call walkLeafParts for recursive traversal.
|
|
91
|
+
*/
|
|
92
|
+
function splitMimeParts(source, boundary) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
const boundaryDelim = `--${boundary}`;
|
|
95
|
+
const sections = source.split(boundaryDelim);
|
|
96
|
+
for (const section of sections) {
|
|
97
|
+
const trimmed = section.trim();
|
|
98
|
+
if (!trimmed || trimmed.startsWith("--"))
|
|
99
|
+
continue;
|
|
100
|
+
// Split headers from body at first blank line
|
|
101
|
+
const blankLineIdx = trimmed.search(/\r?\n\r?\n/);
|
|
102
|
+
if (blankLineIdx === -1)
|
|
103
|
+
continue;
|
|
104
|
+
const headers = trimmed.substring(0, blankLineIdx);
|
|
105
|
+
const body = trimmed.substring(blankLineIdx).replace(/^\r?\n\r?\n/, "");
|
|
106
|
+
parts.push({ headers, body });
|
|
107
|
+
}
|
|
108
|
+
return parts;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Walk a multipart MIME block and return all non-multipart leaf parts,
|
|
112
|
+
* descending into nested multipart/* containers (alternative, related, mixed).
|
|
113
|
+
*/
|
|
114
|
+
function walkLeafParts(source, boundary) {
|
|
115
|
+
const result = [];
|
|
116
|
+
const parts = splitMimeParts(source, boundary);
|
|
117
|
+
for (const part of parts) {
|
|
118
|
+
const ct = getHeader(part.headers, "Content-Type");
|
|
119
|
+
if (ct && /^multipart\//i.test(ct)) {
|
|
120
|
+
const nestedBoundary = extractBoundary(ct);
|
|
121
|
+
if (nestedBoundary) {
|
|
122
|
+
result.push(...walkLeafParts(part.body, nestedBoundary));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
result.push(part);
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Decode a MIME part body to bytes based on its transfer encoding.
|
|
132
|
+
* Supports base64, quoted-printable, and 7bit/8bit/binary (raw).
|
|
133
|
+
*/
|
|
134
|
+
function decodeBody(body, encoding) {
|
|
135
|
+
const enc = (encoding || "").toLowerCase().trim();
|
|
136
|
+
if (enc === "base64") {
|
|
137
|
+
return Buffer.from(body.replace(/[\s\r\n]/g, ""), "base64");
|
|
138
|
+
}
|
|
139
|
+
if (enc === "quoted-printable") {
|
|
140
|
+
return decodeQuotedPrintable(body);
|
|
141
|
+
}
|
|
142
|
+
// 7bit, 8bit, binary, or unspecified — treat as raw bytes
|
|
143
|
+
return Buffer.from(body, "binary");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Decode quoted-printable-encoded body to bytes.
|
|
147
|
+
* Handles soft line breaks (=<CRLF>) and =XX hex escapes per RFC 2045 §6.7.
|
|
148
|
+
*/
|
|
149
|
+
function decodeQuotedPrintable(body) {
|
|
150
|
+
// Remove soft line breaks: `=` immediately followed by CRLF or LF
|
|
151
|
+
const noSoft = body.replace(/=\r?\n/g, "");
|
|
152
|
+
const bytes = [];
|
|
153
|
+
for (let i = 0; i < noSoft.length; i++) {
|
|
154
|
+
const c = noSoft[i];
|
|
155
|
+
if (c === "=" && i + 2 < noSoft.length) {
|
|
156
|
+
const hex = noSoft.substring(i + 1, i + 3);
|
|
157
|
+
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
|
|
158
|
+
bytes.push(parseInt(hex, 16));
|
|
159
|
+
i += 2;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
bytes.push(c.charCodeAt(0) & 0xff);
|
|
164
|
+
}
|
|
165
|
+
return Buffer.from(bytes);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Estimate body size for metadata when Content-Disposition size is absent.
|
|
169
|
+
*/
|
|
170
|
+
function estimateSize(body, encoding) {
|
|
171
|
+
const enc = (encoding || "").toLowerCase().trim();
|
|
172
|
+
if (enc === "base64")
|
|
173
|
+
return estimateBase64Size(body);
|
|
174
|
+
// For other encodings the body length is a reasonable proxy
|
|
175
|
+
return body.length;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Parse MIME source and return metadata for all file attachments.
|
|
179
|
+
* Skips inline dispositions (signature images, etc.). Descends into
|
|
180
|
+
* nested multipart/* containers.
|
|
181
|
+
*
|
|
182
|
+
* @param source - Raw MIME source of the email
|
|
183
|
+
* @returns Array of attachment metadata (name, mimeType, size)
|
|
184
|
+
*/
|
|
185
|
+
export function parseMimeAttachments(source) {
|
|
186
|
+
if (!source || !source.trim())
|
|
187
|
+
return [];
|
|
188
|
+
const boundary = extractBoundary(source);
|
|
189
|
+
if (!boundary)
|
|
190
|
+
return [];
|
|
191
|
+
const parts = walkLeafParts(source, boundary);
|
|
192
|
+
const attachments = [];
|
|
193
|
+
for (const part of parts) {
|
|
194
|
+
const filename = extractFilename(part.headers);
|
|
195
|
+
if (!filename)
|
|
196
|
+
continue;
|
|
197
|
+
if (isInlineDisposition(part.headers))
|
|
198
|
+
continue;
|
|
199
|
+
const encoding = getHeader(part.headers, "Content-Transfer-Encoding");
|
|
200
|
+
attachments.push({
|
|
201
|
+
name: filename,
|
|
202
|
+
mimeType: extractMimeType(part.headers),
|
|
203
|
+
size: extractSize(part.headers) || estimateSize(part.body, encoding),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return attachments;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Extract and decode a specific attachment from MIME source by filename.
|
|
210
|
+
* Supports base64, quoted-printable, and 7bit/8bit/binary transfer encodings.
|
|
211
|
+
* Descends into nested multipart/* containers.
|
|
212
|
+
*
|
|
213
|
+
* @param source - Raw MIME source of the email
|
|
214
|
+
* @param attachmentName - Filename to extract
|
|
215
|
+
* @returns Decoded attachment data, or null if not found
|
|
216
|
+
*/
|
|
217
|
+
export function extractMimeAttachment(source, attachmentName) {
|
|
218
|
+
if (!source || !source.trim())
|
|
219
|
+
return null;
|
|
220
|
+
const boundary = extractBoundary(source);
|
|
221
|
+
if (!boundary)
|
|
222
|
+
return null;
|
|
223
|
+
const parts = walkLeafParts(source, boundary);
|
|
224
|
+
for (const part of parts) {
|
|
225
|
+
const filename = extractFilename(part.headers);
|
|
226
|
+
if (filename !== attachmentName)
|
|
227
|
+
continue;
|
|
228
|
+
const encoding = getHeader(part.headers, "Content-Transfer-Encoding");
|
|
229
|
+
const data = decodeBody(part.body, encoding);
|
|
230
|
+
return {
|
|
231
|
+
name: filename,
|
|
232
|
+
mimeType: extractMimeType(part.headers),
|
|
233
|
+
size: extractSize(part.headers) || data.length,
|
|
234
|
+
data,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-mail-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
"format": "prettier --write src",
|
|
28
28
|
"format:check": "prettier --check src",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
|
+
"version": "node -e \"const p=require('./package.json'); const f='.claude-plugin/plugin.json'; const c=JSON.parse(require('fs').readFileSync(f,'utf8')); c.version=p.version; require('fs').writeFileSync(f,JSON.stringify(c,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
|
|
30
31
|
"prepublishOnly": "npm run lint && npm run test && npm run build",
|
|
31
|
-
"prepare": "husky
|
|
32
|
+
"prepare": "husky"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
34
35
|
"mcp",
|