@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.
Files changed (2) hide show
  1. package/index.js +132 -18
  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
 
@@ -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 a specific email attachment as base64-encoded content. Use the partID from get_email response.',
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
- (a.filename || '').toLowerCase(),
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: inline parts with filenames and parts that have
653
- // no Content-Disposition at all but carry a Content-Type "name" param.
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: attachmentInfo?.filename || args.filename || 'attachment',
1029
- contentType: attachmentInfo?.contentType || 'application/octet-stream',
1030
- encoding: 'base64',
1031
- data: base64Content
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.0",
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",