@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.
Files changed (2) hide show
  1. package/index.js +264 -14
  2. 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({ node, filename, contentType, disposition, partID: node.partID });
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 a specific email attachment as base64-encoded content. Use the partID from get_email response.',
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: inline parts with filenames and parts that have
620
- // no Content-Disposition at all but carry a Content-Type "name" param.
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: attachmentInfo?.filename || args.filename || 'attachment',
996
- contentType: attachmentInfo?.contentType || 'application/octet-stream',
997
- encoding: 'base64',
998
- data: base64Content
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.2.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",