@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.
Files changed (2) hide show
  1. package/index.js +122 -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']
@@ -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: inline parts with filenames and parts that have
653
- // 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.
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: attachmentInfo?.filename || args.filename || 'attachment',
1029
- contentType: attachmentInfo?.contentType || 'application/octet-stream',
1030
- encoding: 'base64',
1031
- data: base64Content
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.0",
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",