@lengelhard/imap-email-mcp 1.2.1 → 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 +264 -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']
|
|
@@ -461,11 +510,44 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
461
510
|
},
|
|
462
511
|
required: ['uid', 'seen']
|
|
463
512
|
}
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
name: 'move_thread',
|
|
516
|
+
description: 'Move an entire email thread (all messages matching a subject) from one folder to another in a single call. Strips Re:/Fwd: prefixes for matching. Use move_email for single-message precision moves.',
|
|
517
|
+
inputSchema: {
|
|
518
|
+
type: 'object',
|
|
519
|
+
properties: {
|
|
520
|
+
subject: {
|
|
521
|
+
type: 'string',
|
|
522
|
+
description: 'Subject line of the thread to move. Re:/Fwd: prefixes are stripped automatically for matching.'
|
|
523
|
+
},
|
|
524
|
+
from_folder: {
|
|
525
|
+
type: 'string',
|
|
526
|
+
description: 'Source folder to search for thread messages'
|
|
527
|
+
},
|
|
528
|
+
to_folder: {
|
|
529
|
+
type: 'string',
|
|
530
|
+
description: 'Destination folder to move messages into'
|
|
531
|
+
},
|
|
532
|
+
from_address: {
|
|
533
|
+
type: 'string',
|
|
534
|
+
description: 'Optional sender address to disambiguate threads with generic subjects'
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
required: ['subject', 'from_folder', 'to_folder']
|
|
538
|
+
}
|
|
464
539
|
}
|
|
465
540
|
]
|
|
466
541
|
};
|
|
467
542
|
});
|
|
468
543
|
|
|
544
|
+
// Strip Re:/Fwd:/Fw: prefixes from a subject line for thread matching.
|
|
545
|
+
// Handles nested prefixes like "Re: Re: Fwd: topic" → "topic"
|
|
546
|
+
function normalizeSubject(subject) {
|
|
547
|
+
if (!subject) return '';
|
|
548
|
+
return subject.replace(/^(\s*(Re|Fwd|Fw)\s*:\s*)+/i, '').trim();
|
|
549
|
+
}
|
|
550
|
+
|
|
469
551
|
// Helper function to connect to IMAP
|
|
470
552
|
async function connectIMAP() {
|
|
471
553
|
if (!IMAP_CONFIG.imap.password) {
|
|
@@ -616,13 +698,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
616
698
|
);
|
|
617
699
|
|
|
618
700
|
// Also walk the raw MIME struct so we catch parts that mailparser
|
|
619
|
-
// may have skipped
|
|
620
|
-
//
|
|
701
|
+
// may have skipped, and enrich every entry with its part_id so
|
|
702
|
+
// download_attachment can be called directly.
|
|
621
703
|
const fromStruct = findAttachmentParts(msg.attributes.struct || []);
|
|
622
|
-
for (const { filename, contentType } of fromStruct) {
|
|
704
|
+
for (const { filename, contentType, partID, size } of fromStruct) {
|
|
623
705
|
const key = (filename || '').toLowerCase();
|
|
624
706
|
if (filename && !fromParser.has(key)) {
|
|
625
|
-
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;
|
|
626
713
|
}
|
|
627
714
|
}
|
|
628
715
|
|
|
@@ -979,23 +1066,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
979
1066
|
return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
|
|
980
1067
|
}
|
|
981
1068
|
|
|
982
|
-
// imap-simple returns the body already base64-encoded by the mail server.
|
|
983
|
-
// Re-encoding would corrupt the output — strip whitespace and return directly.
|
|
984
|
-
const base64Content = String(partData.body).replace(/\s+/g, '');
|
|
985
|
-
|
|
986
1069
|
const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
|
|
987
1070
|
const attachmentInfo = attachmentParts.find(p => p.partID === partID);
|
|
988
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
|
+
|
|
989
1130
|
return {
|
|
990
1131
|
content: [{
|
|
991
1132
|
type: 'text',
|
|
992
1133
|
text: JSON.stringify({
|
|
993
1134
|
uid: args.uid,
|
|
994
1135
|
partID,
|
|
995
|
-
filename
|
|
996
|
-
contentType
|
|
997
|
-
|
|
998
|
-
|
|
1136
|
+
filename,
|
|
1137
|
+
contentType,
|
|
1138
|
+
size_bytes: buffer.length,
|
|
1139
|
+
saved_to: filePath
|
|
999
1140
|
}, null, 2)
|
|
1000
1141
|
}]
|
|
1001
1142
|
};
|
|
@@ -1108,6 +1249,115 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1108
1249
|
}
|
|
1109
1250
|
}
|
|
1110
1251
|
|
|
1252
|
+
case 'move_thread': {
|
|
1253
|
+
const { subject, from_folder, to_folder, from_address } = args;
|
|
1254
|
+
const normalizedTarget = normalizeSubject(subject);
|
|
1255
|
+
|
|
1256
|
+
if (!normalizedTarget) {
|
|
1257
|
+
return {
|
|
1258
|
+
content: [{ type: 'text', text: 'Error: subject cannot be empty' }],
|
|
1259
|
+
isError: true
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const connection = await connectIMAP();
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
await connection.openBox(from_folder);
|
|
1267
|
+
|
|
1268
|
+
// Build IMAP search criteria: match subject header (and optionally sender)
|
|
1269
|
+
const searchCriteria = [['SUBJECT', normalizedTarget]];
|
|
1270
|
+
if (from_address) {
|
|
1271
|
+
searchCriteria.push(['FROM', from_address]);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const fetchOptions = {
|
|
1275
|
+
bodies: ['HEADER.FIELDS (SUBJECT FROM)'],
|
|
1276
|
+
struct: false
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
const messages = await connection.search(searchCriteria, fetchOptions);
|
|
1280
|
+
|
|
1281
|
+
if (messages.length === 0) {
|
|
1282
|
+
return {
|
|
1283
|
+
content: [{
|
|
1284
|
+
type: 'text',
|
|
1285
|
+
text: JSON.stringify({
|
|
1286
|
+
success: false,
|
|
1287
|
+
moved: 0,
|
|
1288
|
+
failed: 0,
|
|
1289
|
+
from_folder,
|
|
1290
|
+
to_folder,
|
|
1291
|
+
message: `No messages found matching subject "${normalizedTarget}" in ${from_folder}`
|
|
1292
|
+
}, null, 2)
|
|
1293
|
+
}]
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// IMAP SUBJECT search is a substring match, so post-filter to ensure
|
|
1298
|
+
// each message's normalized subject matches the target exactly.
|
|
1299
|
+
const matchingUIDs = [];
|
|
1300
|
+
for (const msg of messages) {
|
|
1301
|
+
const header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
|
|
1302
|
+
const msgSubject = normalizeSubject((header.subject || [])[0] || '');
|
|
1303
|
+
if (msgSubject.toLowerCase() === normalizedTarget.toLowerCase()) {
|
|
1304
|
+
matchingUIDs.push(msg.attributes.uid);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (matchingUIDs.length === 0) {
|
|
1309
|
+
return {
|
|
1310
|
+
content: [{
|
|
1311
|
+
type: 'text',
|
|
1312
|
+
text: JSON.stringify({
|
|
1313
|
+
success: false,
|
|
1314
|
+
moved: 0,
|
|
1315
|
+
failed: 0,
|
|
1316
|
+
from_folder,
|
|
1317
|
+
to_folder,
|
|
1318
|
+
message: `IMAP returned results but none matched the exact subject "${normalizedTarget}" after normalization`
|
|
1319
|
+
}, null, 2)
|
|
1320
|
+
}]
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Move each matching UID using the same imap.move() pattern as move_email
|
|
1325
|
+
let moved = 0;
|
|
1326
|
+
const failures = [];
|
|
1327
|
+
for (const uid of matchingUIDs) {
|
|
1328
|
+
try {
|
|
1329
|
+
await new Promise((resolve, reject) => {
|
|
1330
|
+
connection.imap.move(uid, to_folder, (err) => {
|
|
1331
|
+
if (err) reject(err);
|
|
1332
|
+
else resolve();
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
moved++;
|
|
1336
|
+
} catch (moveErr) {
|
|
1337
|
+
failures.push({ uid, error: moveErr.message });
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return {
|
|
1342
|
+
content: [{
|
|
1343
|
+
type: 'text',
|
|
1344
|
+
text: JSON.stringify({
|
|
1345
|
+
success: failures.length === 0,
|
|
1346
|
+
moved,
|
|
1347
|
+
failed: failures.length,
|
|
1348
|
+
total_found: matchingUIDs.length,
|
|
1349
|
+
from_folder,
|
|
1350
|
+
to_folder,
|
|
1351
|
+
subject: normalizedTarget,
|
|
1352
|
+
...(failures.length > 0 && { failures })
|
|
1353
|
+
}, null, 2)
|
|
1354
|
+
}]
|
|
1355
|
+
};
|
|
1356
|
+
} finally {
|
|
1357
|
+
connection.end();
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1111
1361
|
default:
|
|
1112
1362
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
1113
1363
|
}
|
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",
|