@lengelhard/imap-email-mcp 1.3.0 → 1.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/index.js +122 -14
- 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
|
|
|
@@ -349,7 +389,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
349
389
|
},
|
|
350
390
|
{
|
|
351
391
|
name: 'download_attachment',
|
|
352
|
-
description: 'Download
|
|
392
|
+
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
393
|
inputSchema: {
|
|
354
394
|
type: 'object',
|
|
355
395
|
properties: {
|
|
@@ -369,6 +409,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
369
409
|
type: 'string',
|
|
370
410
|
description: 'Folder name (default: INBOX)',
|
|
371
411
|
default: 'INBOX'
|
|
412
|
+
},
|
|
413
|
+
save_path: {
|
|
414
|
+
type: 'string',
|
|
415
|
+
description: 'Optional destination. A directory (file keeps its original name) or a full file path. Defaults to ATTACHMENT_DIR.'
|
|
416
|
+
},
|
|
417
|
+
return_base64: {
|
|
418
|
+
type: 'boolean',
|
|
419
|
+
description: 'Return content inline as base64 instead of saving to disk. Fails for attachments over 700KB; prefer the default file mode.',
|
|
420
|
+
default: false
|
|
372
421
|
}
|
|
373
422
|
},
|
|
374
423
|
required: ['uid']
|
|
@@ -649,13 +698,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
649
698
|
);
|
|
650
699
|
|
|
651
700
|
// Also walk the raw MIME struct so we catch parts that mailparser
|
|
652
|
-
// may have skipped
|
|
653
|
-
//
|
|
701
|
+
// may have skipped, and enrich every entry with its part_id so
|
|
702
|
+
// download_attachment can be called directly.
|
|
654
703
|
const fromStruct = findAttachmentParts(msg.attributes.struct || []);
|
|
655
|
-
for (const { filename, contentType } of fromStruct) {
|
|
704
|
+
for (const { filename, contentType, partID, size } of fromStruct) {
|
|
656
705
|
const key = (filename || '').toLowerCase();
|
|
657
706
|
if (filename && !fromParser.has(key)) {
|
|
658
|
-
fromParser.set(key, { filename, contentType, size: null });
|
|
707
|
+
fromParser.set(key, { filename, contentType, size: size ?? null });
|
|
708
|
+
}
|
|
709
|
+
if (filename && fromParser.has(key)) {
|
|
710
|
+
const entry = fromParser.get(key);
|
|
711
|
+
entry.part_id = partID;
|
|
712
|
+
if (entry.size == null) entry.size = size ?? null;
|
|
659
713
|
}
|
|
660
714
|
}
|
|
661
715
|
|
|
@@ -1012,23 +1066,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1012
1066
|
return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
|
|
1013
1067
|
}
|
|
1014
1068
|
|
|
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
1069
|
const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
|
|
1020
1070
|
const attachmentInfo = attachmentParts.find(p => p.partID === partID);
|
|
1021
1071
|
|
|
1072
|
+
// Decode according to the part's Content-Transfer-Encoding
|
|
1073
|
+
// (base64 for nearly all attachments, but not guaranteed).
|
|
1074
|
+
const buffer = decodePartBody(partData.body, attachmentInfo?.encoding || 'base64');
|
|
1075
|
+
const filename = sanitizeFilename(attachmentInfo?.filename || args.filename);
|
|
1076
|
+
const contentType = attachmentInfo?.contentType || 'application/octet-stream';
|
|
1077
|
+
|
|
1078
|
+
// Inline mode: opt-in only, and only when it safely fits in a tool result
|
|
1079
|
+
if (args.return_base64) {
|
|
1080
|
+
if (buffer.length > MAX_INLINE_BYTES) {
|
|
1081
|
+
return {
|
|
1082
|
+
content: [{
|
|
1083
|
+
type: 'text',
|
|
1084
|
+
text: JSON.stringify({
|
|
1085
|
+
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.`,
|
|
1086
|
+
uid: args.uid,
|
|
1087
|
+
partID,
|
|
1088
|
+
filename,
|
|
1089
|
+
size_bytes: buffer.length
|
|
1090
|
+
}, null, 2)
|
|
1091
|
+
}],
|
|
1092
|
+
isError: true
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
content: [{
|
|
1097
|
+
type: 'text',
|
|
1098
|
+
text: JSON.stringify({
|
|
1099
|
+
uid: args.uid,
|
|
1100
|
+
partID,
|
|
1101
|
+
filename,
|
|
1102
|
+
contentType,
|
|
1103
|
+
size_bytes: buffer.length,
|
|
1104
|
+
encoding: 'base64',
|
|
1105
|
+
data: buffer.toString('base64')
|
|
1106
|
+
}, null, 2)
|
|
1107
|
+
}]
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Default mode: save to disk and return the path
|
|
1112
|
+
let target = args.save_path || ATTACHMENT_DIR;
|
|
1113
|
+
let filePath;
|
|
1114
|
+
if (args.save_path && path.extname(args.save_path)) {
|
|
1115
|
+
// Treat as a full file path
|
|
1116
|
+
filePath = args.save_path;
|
|
1117
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1118
|
+
} else {
|
|
1119
|
+
fs.mkdirSync(target, { recursive: true });
|
|
1120
|
+
filePath = path.join(target, filename);
|
|
1121
|
+
// Avoid silently overwriting an existing different file
|
|
1122
|
+
if (fs.existsSync(filePath)) {
|
|
1123
|
+
const ext = path.extname(filename);
|
|
1124
|
+
const base = path.basename(filename, ext);
|
|
1125
|
+
filePath = path.join(target, `${base}_uid${args.uid}_${partID}${ext}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
fs.writeFileSync(filePath, buffer);
|
|
1129
|
+
|
|
1022
1130
|
return {
|
|
1023
1131
|
content: [{
|
|
1024
1132
|
type: 'text',
|
|
1025
1133
|
text: JSON.stringify({
|
|
1026
1134
|
uid: args.uid,
|
|
1027
1135
|
partID,
|
|
1028
|
-
filename
|
|
1029
|
-
contentType
|
|
1030
|
-
|
|
1031
|
-
|
|
1136
|
+
filename,
|
|
1137
|
+
contentType,
|
|
1138
|
+
size_bytes: buffer.length,
|
|
1139
|
+
saved_to: filePath
|
|
1032
1140
|
}, null, 2)
|
|
1033
1141
|
}]
|
|
1034
1142
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengelhard/imap-email-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|