@lengelhard/imap-email-mcp 1.3.0 → 1.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/index.js +132 -18
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -18,6 +18,38 @@ import {
|
|
|
18
18
|
import imaps from 'imap-simple';
|
|
19
19
|
import { simpleParser } from 'mailparser';
|
|
20
20
|
import nodemailer from 'nodemailer';
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import os from 'os';
|
|
24
|
+
|
|
25
|
+
// Default directory where download_attachment saves files
|
|
26
|
+
const ATTACHMENT_DIR =
|
|
27
|
+
process.env.ATTACHMENT_DIR ||
|
|
28
|
+
path.join(os.homedir(), 'Downloads', 'email-attachments');
|
|
29
|
+
|
|
30
|
+
// Keep inline base64 well under the 1MB tool-result cap most MCP clients enforce.
|
|
31
|
+
// 700KB raw ≈ 933KB base64, leaving headroom for the JSON envelope.
|
|
32
|
+
const MAX_INLINE_BYTES = 700 * 1024;
|
|
33
|
+
|
|
34
|
+
function sanitizeFilename(name) {
|
|
35
|
+
return (name || 'attachment').replace(/[/\\?%*:|"<>\x00-\x1f]/g, '_').slice(0, 200);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Decode a fetched MIME part body according to its Content-Transfer-Encoding
|
|
39
|
+
function decodePartBody(body, encoding) {
|
|
40
|
+
const enc = (encoding || '').toLowerCase();
|
|
41
|
+
if (enc === 'base64') {
|
|
42
|
+
return Buffer.from(String(body).replace(/\s+/g, ''), 'base64');
|
|
43
|
+
}
|
|
44
|
+
if (enc === 'quoted-printable') {
|
|
45
|
+
const text = String(body)
|
|
46
|
+
.replace(/=\r?\n/g, '')
|
|
47
|
+
.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
48
|
+
return Buffer.from(text, 'latin1');
|
|
49
|
+
}
|
|
50
|
+
// 7bit / 8bit / binary — body is already raw
|
|
51
|
+
return Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'latin1');
|
|
52
|
+
}
|
|
21
53
|
|
|
22
54
|
// Configuration from environment variables
|
|
23
55
|
const IMAP_CONFIG = {
|
|
@@ -83,7 +115,15 @@ function findAttachmentParts(struct, parts = []) {
|
|
|
83
115
|
);
|
|
84
116
|
|
|
85
117
|
if (isAttachment) {
|
|
86
|
-
parts.push({
|
|
118
|
+
parts.push({
|
|
119
|
+
node,
|
|
120
|
+
filename,
|
|
121
|
+
contentType,
|
|
122
|
+
disposition,
|
|
123
|
+
partID: node.partID,
|
|
124
|
+
size: node.size ?? null,
|
|
125
|
+
encoding: node.encoding || null
|
|
126
|
+
});
|
|
87
127
|
}
|
|
88
128
|
}
|
|
89
129
|
|
|
@@ -163,7 +203,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
163
203
|
},
|
|
164
204
|
{
|
|
165
205
|
name: 'get_email',
|
|
166
|
-
description: 'Get full email content by UID',
|
|
206
|
+
description: 'Get full email content by UID. IMPORTANT: UIDs are folder-scoped. Always pass the same folder the UID came from (list_emails or search_emails). Omitting folder opens INBOX and may silently return a different message.',
|
|
167
207
|
inputSchema: {
|
|
168
208
|
type: 'object',
|
|
169
209
|
properties: {
|
|
@@ -199,6 +239,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
199
239
|
type: 'string',
|
|
200
240
|
description: 'Search by sender'
|
|
201
241
|
},
|
|
242
|
+
to: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
description: 'Search by recipient (use with folder: "Sent" to find outgoing mail)'
|
|
245
|
+
},
|
|
202
246
|
body: {
|
|
203
247
|
type: 'string',
|
|
204
248
|
description: 'Search in body text'
|
|
@@ -349,7 +393,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
349
393
|
},
|
|
350
394
|
{
|
|
351
395
|
name: 'download_attachment',
|
|
352
|
-
description: 'Download
|
|
396
|
+
description: 'Download an email attachment. By default the file is decoded and saved to disk (ATTACHMENT_DIR, default ~/Downloads/email-attachments) and the local file path is returned. Set return_base64=true to get the content inline instead (only allowed for attachments under 700KB).',
|
|
353
397
|
inputSchema: {
|
|
354
398
|
type: 'object',
|
|
355
399
|
properties: {
|
|
@@ -369,6 +413,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
369
413
|
type: 'string',
|
|
370
414
|
description: 'Folder name (default: INBOX)',
|
|
371
415
|
default: 'INBOX'
|
|
416
|
+
},
|
|
417
|
+
save_path: {
|
|
418
|
+
type: 'string',
|
|
419
|
+
description: 'Optional destination. A directory (file keeps its original name) or a full file path. Defaults to ATTACHMENT_DIR.'
|
|
420
|
+
},
|
|
421
|
+
return_base64: {
|
|
422
|
+
type: 'boolean',
|
|
423
|
+
description: 'Return content inline as base64 instead of saving to disk. Fails for attachments over 700KB; prefer the default file mode.',
|
|
424
|
+
default: false
|
|
372
425
|
}
|
|
373
426
|
},
|
|
374
427
|
required: ['uid']
|
|
@@ -631,6 +684,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
631
684
|
type: 'text',
|
|
632
685
|
text: JSON.stringify({
|
|
633
686
|
uid: msg.attributes.uid,
|
|
687
|
+
folder,
|
|
634
688
|
from: parsed.from?.text,
|
|
635
689
|
to: parsed.to?.text,
|
|
636
690
|
cc: parsed.cc?.text,
|
|
@@ -642,20 +696,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
642
696
|
// Start with what mailparser found (covers Content-Disposition: attachment
|
|
643
697
|
// and inline+filename cases for content it fully parsed).
|
|
644
698
|
const fromParser = new Map(
|
|
645
|
-
(parsed.attachments || []).map(a => [
|
|
646
|
-
|
|
647
|
-
{ filename: a.filename, contentType: a.contentType, size: a.size }
|
|
699
|
+
(parsed.attachments || []).map((a, i) => [
|
|
700
|
+
a.filename ? a.filename.toLowerCase() : `__unnamed_${i}`,
|
|
701
|
+
{ filename: a.filename || null, contentType: a.contentType, size: a.size }
|
|
648
702
|
])
|
|
649
703
|
);
|
|
650
704
|
|
|
651
705
|
// Also walk the raw MIME struct so we catch parts that mailparser
|
|
652
|
-
// may have skipped
|
|
653
|
-
//
|
|
706
|
+
// may have skipped, and enrich every entry with its part_id so
|
|
707
|
+
// download_attachment can be called directly.
|
|
654
708
|
const fromStruct = findAttachmentParts(msg.attributes.struct || []);
|
|
655
|
-
for (const { filename, contentType } of fromStruct) {
|
|
709
|
+
for (const { filename, contentType, partID, size } of fromStruct) {
|
|
656
710
|
const key = (filename || '').toLowerCase();
|
|
657
711
|
if (filename && !fromParser.has(key)) {
|
|
658
|
-
fromParser.set(key, { filename, contentType, size: null });
|
|
712
|
+
fromParser.set(key, { filename, contentType, size: size ?? null });
|
|
713
|
+
}
|
|
714
|
+
if (filename && fromParser.has(key)) {
|
|
715
|
+
const entry = fromParser.get(key);
|
|
716
|
+
entry.part_id = partID;
|
|
717
|
+
if (entry.size == null) entry.size = size ?? null;
|
|
659
718
|
}
|
|
660
719
|
}
|
|
661
720
|
|
|
@@ -680,6 +739,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
680
739
|
let searchCriteria = [];
|
|
681
740
|
if (args.subject) searchCriteria.push(['SUBJECT', args.subject]);
|
|
682
741
|
if (args.from) searchCriteria.push(['FROM', args.from]);
|
|
742
|
+
if (args.to) searchCriteria.push(['TO', args.to]);
|
|
683
743
|
if (args.body) searchCriteria.push(['BODY', args.body]);
|
|
684
744
|
|
|
685
745
|
if (searchCriteria.length === 0) {
|
|
@@ -1012,23 +1072,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1012
1072
|
return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
|
|
1013
1073
|
}
|
|
1014
1074
|
|
|
1015
|
-
// imap-simple returns the body already base64-encoded by the mail server.
|
|
1016
|
-
// Re-encoding would corrupt the output — strip whitespace and return directly.
|
|
1017
|
-
const base64Content = String(partData.body).replace(/\s+/g, '');
|
|
1018
|
-
|
|
1019
1075
|
const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
|
|
1020
1076
|
const attachmentInfo = attachmentParts.find(p => p.partID === partID);
|
|
1021
1077
|
|
|
1078
|
+
// Decode according to the part's Content-Transfer-Encoding
|
|
1079
|
+
// (base64 for nearly all attachments, but not guaranteed).
|
|
1080
|
+
const buffer = decodePartBody(partData.body, attachmentInfo?.encoding || 'base64');
|
|
1081
|
+
const filename = sanitizeFilename(attachmentInfo?.filename || args.filename);
|
|
1082
|
+
const contentType = attachmentInfo?.contentType || 'application/octet-stream';
|
|
1083
|
+
|
|
1084
|
+
// Inline mode: opt-in only, and only when it safely fits in a tool result
|
|
1085
|
+
if (args.return_base64) {
|
|
1086
|
+
if (buffer.length > MAX_INLINE_BYTES) {
|
|
1087
|
+
return {
|
|
1088
|
+
content: [{
|
|
1089
|
+
type: 'text',
|
|
1090
|
+
text: JSON.stringify({
|
|
1091
|
+
error: `Attachment is ${buffer.length} bytes; inline base64 is limited to ${MAX_INLINE_BYTES} bytes because the encoded result would exceed the MCP tool-result size cap. Call again without return_base64 to save it to disk and get the file path.`,
|
|
1092
|
+
uid: args.uid,
|
|
1093
|
+
partID,
|
|
1094
|
+
filename,
|
|
1095
|
+
size_bytes: buffer.length
|
|
1096
|
+
}, null, 2)
|
|
1097
|
+
}],
|
|
1098
|
+
isError: true
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
content: [{
|
|
1103
|
+
type: 'text',
|
|
1104
|
+
text: JSON.stringify({
|
|
1105
|
+
uid: args.uid,
|
|
1106
|
+
partID,
|
|
1107
|
+
filename,
|
|
1108
|
+
contentType,
|
|
1109
|
+
size_bytes: buffer.length,
|
|
1110
|
+
encoding: 'base64',
|
|
1111
|
+
data: buffer.toString('base64')
|
|
1112
|
+
}, null, 2)
|
|
1113
|
+
}]
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Default mode: save to disk and return the path
|
|
1118
|
+
let target = args.save_path || ATTACHMENT_DIR;
|
|
1119
|
+
let filePath;
|
|
1120
|
+
if (args.save_path && path.extname(args.save_path)) {
|
|
1121
|
+
// Treat as a full file path
|
|
1122
|
+
filePath = args.save_path;
|
|
1123
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1124
|
+
} else {
|
|
1125
|
+
fs.mkdirSync(target, { recursive: true });
|
|
1126
|
+
filePath = path.join(target, filename);
|
|
1127
|
+
// Avoid silently overwriting an existing different file
|
|
1128
|
+
if (fs.existsSync(filePath)) {
|
|
1129
|
+
const ext = path.extname(filename);
|
|
1130
|
+
const base = path.basename(filename, ext);
|
|
1131
|
+
filePath = path.join(target, `${base}_uid${args.uid}_${partID}${ext}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
fs.writeFileSync(filePath, buffer);
|
|
1135
|
+
|
|
1022
1136
|
return {
|
|
1023
1137
|
content: [{
|
|
1024
1138
|
type: 'text',
|
|
1025
1139
|
text: JSON.stringify({
|
|
1026
1140
|
uid: args.uid,
|
|
1027
1141
|
partID,
|
|
1028
|
-
filename
|
|
1029
|
-
contentType
|
|
1030
|
-
|
|
1031
|
-
|
|
1142
|
+
filename,
|
|
1143
|
+
contentType,
|
|
1144
|
+
size_bytes: buffer.length,
|
|
1145
|
+
saved_to: filePath
|
|
1032
1146
|
}, null, 2)
|
|
1033
1147
|
}]
|
|
1034
1148
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengelhard/imap-email-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "MCP server for Claude Code that provides email capabilities through IMAP/SMTP. Read, search, compose, and manage emails from any IMAP provider.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|