@lengelhard/imap-email-mcp 1.4.1 → 1.6.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 (3) hide show
  1. package/README.md +2 -0
  2. package/index.js +213 -22
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -121,6 +121,8 @@ Add to your config file:
121
121
  | `update_draft` | Update an existing draft |
122
122
  | `send_email` | Send an email directly |
123
123
  | `delete_email` | Delete an email by UID |
124
+ | `download_attachment` | Download an attachment: ≤700KB returns inline base64, larger saves to disk (ATTACHMENT_DIR) |
125
+ | `extract_text` | Extract plain text from a PDF, xlsx/xlsm/xls, docx, or text attachment in one call, parsed server-side |
124
126
 
125
127
  ## Usage Examples
126
128
 
package/index.js CHANGED
@@ -31,6 +31,48 @@ const ATTACHMENT_DIR =
31
31
  // 700KB raw ≈ 933KB base64, leaving headroom for the JSON envelope.
32
32
  const MAX_INLINE_BYTES = 700 * 1024;
33
33
 
34
+ // Cap on text returned by extract_text, to stay well under tool-result limits
35
+ const MAX_EXTRACT_CHARS = 500 * 1024;
36
+
37
+ // Parse a binary attachment buffer into plain text, dispatching on content
38
+ // type / filename extension. Parsers are imported lazily so the server starts
39
+ // fast and a missing optional parser only affects extract_text itself.
40
+ async function extractTextFromBuffer(buffer, contentType, filename) {
41
+ const ct = (contentType || '').toLowerCase();
42
+ const ext = (filename || '').toLowerCase().split('.').pop();
43
+
44
+ if (ct.includes('pdf') || ext === 'pdf') {
45
+ const { extractText } = await import('unpdf');
46
+ const { totalPages, text } = await extractText(new Uint8Array(buffer), { mergePages: true });
47
+ return { parser: 'unpdf', pages: totalPages, text };
48
+ }
49
+
50
+ if (
51
+ ct.includes('spreadsheetml') || ct.includes('ms-excel') ||
52
+ ext === 'xlsx' || ext === 'xlsm' || ext === 'xls'
53
+ ) {
54
+ const XLSX = await import('xlsx');
55
+ const wb = XLSX.read(buffer, { type: 'buffer' });
56
+ const parts = wb.SheetNames.map(name => {
57
+ const csv = XLSX.utils.sheet_to_csv(wb.Sheets[name], { blankrows: false });
58
+ return `=== Sheet: ${name} ===\n${csv}`;
59
+ });
60
+ return { parser: 'xlsx', sheets: wb.SheetNames, text: parts.join('\n\n') };
61
+ }
62
+
63
+ if (ct.includes('wordprocessingml') || ext === 'docx') {
64
+ const { default: mammoth } = await import('mammoth');
65
+ const result = await mammoth.extractRawText({ buffer });
66
+ return { parser: 'mammoth', text: result.value };
67
+ }
68
+
69
+ if (ct.startsWith('text/') || ['txt', 'csv', 'md', 'json', 'xml', 'html'].includes(ext)) {
70
+ return { parser: 'utf8', text: buffer.toString('utf8') };
71
+ }
72
+
73
+ return null; // unsupported type
74
+ }
75
+
34
76
  function sanitizeFilename(name) {
35
77
  return (name || 'attachment').replace(/[/\\?%*:|"<>\x00-\x1f]/g, '_').slice(0, 200);
36
78
  }
@@ -393,7 +435,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
393
435
  },
394
436
  {
395
437
  name: 'download_attachment',
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).',
438
+ description: 'Download an email attachment. Mode is automatic: attachments up to 700KB are returned inline as base64; larger ones are decoded and saved to disk (ATTACHMENT_DIR, default ~/Downloads/email-attachments) with the file path returned. Every response includes a "mode" field ("base64" or "file"). Set return_base64 explicitly to force a mode; forcing base64 on an oversized attachment falls back to disk with a note instead of erroring.',
397
439
  inputSchema: {
398
440
  type: 'object',
399
441
  properties: {
@@ -420,8 +462,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
420
462
  },
421
463
  return_base64: {
422
464
  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
465
+ description: 'Optional. Omit for automatic mode: attachments up to 700KB return inline base64, larger ones are saved to disk. Set true/false to force a mode; true on an oversized attachment falls back to disk with a note.'
466
+ }
467
+ },
468
+ required: ['uid']
469
+ }
470
+ },
471
+ {
472
+ name: 'extract_text',
473
+ description: 'Extract readable plain text from an email attachment in one call, parsed server-side. Supports PDF (pdf-parse), Excel xlsx/xlsm/xls (sheet-by-sheet CSV), Word docx (mammoth), and plain-text types. Use this instead of download_attachment when the goal is to read or analyze attachment content. Returns text plus metadata (filename, contentType, size_bytes, parser). Output is capped at 500KB with a truncation note. Unsupported types return an error naming the supported set; use download_attachment for those.',
474
+ inputSchema: {
475
+ type: 'object',
476
+ properties: {
477
+ uid: {
478
+ type: 'number',
479
+ description: 'Email UID'
480
+ },
481
+ part_id: {
482
+ type: 'string',
483
+ description: 'MIME part ID of the attachment (from get_email response)'
484
+ },
485
+ filename: {
486
+ type: 'string',
487
+ description: 'Filename of the attachment (used as fallback to find part_id if not provided)'
488
+ },
489
+ folder: {
490
+ type: 'string',
491
+ description: 'Folder name the UID belongs to (default: INBOX). Always pass this explicitly for non-inbox messages.',
492
+ default: 'INBOX'
425
493
  }
426
494
  },
427
495
  required: ['uid']
@@ -1081,23 +1149,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1081
1149
  const filename = sanitizeFilename(attachmentInfo?.filename || args.filename);
1082
1150
  const contentType = attachmentInfo?.contentType || 'application/octet-stream';
1083
1151
 
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
- }
1152
+ // Resolve mode:
1153
+ // - return_base64 omitted -> auto: inline when it fits, disk otherwise
1154
+ // - return_base64: true -> inline, but fall back to disk (with a note)
1155
+ // if oversized instead of erroring
1156
+ // - return_base64: false -> disk
1157
+ const fitsInline = buffer.length <= MAX_INLINE_BYTES;
1158
+ const wantInline = args.return_base64 === true
1159
+ ? true
1160
+ : args.return_base64 === false
1161
+ ? false
1162
+ : fitsInline;
1163
+
1164
+ if (wantInline && fitsInline) {
1101
1165
  return {
1102
1166
  content: [{
1103
1167
  type: 'text',
@@ -1107,6 +1171,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1107
1171
  filename,
1108
1172
  contentType,
1109
1173
  size_bytes: buffer.length,
1174
+ mode: 'base64',
1110
1175
  encoding: 'base64',
1111
1176
  data: buffer.toString('base64')
1112
1177
  }, null, 2)
@@ -1114,7 +1179,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1114
1179
  };
1115
1180
  }
1116
1181
 
1117
- // Default mode: save to disk and return the path
1182
+ // Note attached to the result when inline was requested but not possible
1183
+ const fallbackNote = (wantInline && !fitsInline)
1184
+ ? `Attachment is ${buffer.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit; saved to disk instead.`
1185
+ : undefined;
1186
+
1187
+ // Disk mode: save and return the path
1118
1188
  let target = args.save_path || ATTACHMENT_DIR;
1119
1189
  let filePath;
1120
1190
  if (args.save_path && path.extname(args.save_path)) {
@@ -1142,7 +1212,128 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1142
1212
  filename,
1143
1213
  contentType,
1144
1214
  size_bytes: buffer.length,
1145
- saved_to: filePath
1215
+ mode: 'file',
1216
+ saved_to: filePath,
1217
+ ...(fallbackNote ? { note: fallbackNote } : {})
1218
+ }, null, 2)
1219
+ }]
1220
+ };
1221
+ } finally {
1222
+ connection.end();
1223
+ }
1224
+ }
1225
+
1226
+ case 'extract_text': {
1227
+ const folder = args.folder || 'INBOX';
1228
+ const connection = await connectIMAP();
1229
+
1230
+ try {
1231
+ await connection.openBox(folder);
1232
+
1233
+ const structFetch = await connection.search([['UID', args.uid]], {
1234
+ bodies: [],
1235
+ struct: true
1236
+ });
1237
+
1238
+ if (structFetch.length === 0) {
1239
+ return { content: [{ type: 'text', text: 'Email not found' }] };
1240
+ }
1241
+
1242
+ const msg = structFetch[0];
1243
+ let partID = args.part_id;
1244
+
1245
+ if (!partID && args.filename) {
1246
+ const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
1247
+ const match = attachmentParts.find(p =>
1248
+ (p.filename || '').toLowerCase() === args.filename.toLowerCase()
1249
+ );
1250
+ if (match) partID = match.partID;
1251
+ }
1252
+
1253
+ if (!partID) {
1254
+ return {
1255
+ content: [{ type: 'text', text: 'Could not determine part ID. Please provide part_id from get_email response.' }],
1256
+ isError: true
1257
+ };
1258
+ }
1259
+
1260
+ const partFetch = await connection.search([['UID', args.uid]], {
1261
+ bodies: [partID],
1262
+ struct: true
1263
+ });
1264
+
1265
+ if (partFetch.length === 0) {
1266
+ return { content: [{ type: 'text', text: 'Attachment part not found' }] };
1267
+ }
1268
+
1269
+ const partData = partFetch[0].parts.find(p => p.which === partID);
1270
+
1271
+ if (!partData) {
1272
+ return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
1273
+ }
1274
+
1275
+ const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
1276
+ const attachmentInfo = attachmentParts.find(p => p.partID === partID);
1277
+
1278
+ const buffer = decodePartBody(partData.body, attachmentInfo?.encoding || 'base64');
1279
+ const filename = sanitizeFilename(attachmentInfo?.filename || args.filename);
1280
+ const contentType = attachmentInfo?.contentType || 'application/octet-stream';
1281
+
1282
+ let extracted;
1283
+ try {
1284
+ extracted = await extractTextFromBuffer(buffer, contentType, filename);
1285
+ } catch (parseErr) {
1286
+ return {
1287
+ content: [{
1288
+ type: 'text',
1289
+ text: JSON.stringify({
1290
+ error: `Parsing failed: ${parseErr.message}`,
1291
+ uid: args.uid,
1292
+ partID,
1293
+ filename,
1294
+ contentType,
1295
+ size_bytes: buffer.length,
1296
+ hint: 'The file may be corrupted, password-protected, or an unusual variant. Use download_attachment to get the raw file.'
1297
+ }, null, 2)
1298
+ }],
1299
+ isError: true
1300
+ };
1301
+ }
1302
+
1303
+ if (!extracted) {
1304
+ return {
1305
+ content: [{
1306
+ type: 'text',
1307
+ text: JSON.stringify({
1308
+ error: `Unsupported type for text extraction: ${contentType} (${filename}). Supported: PDF, xlsx/xlsm/xls, docx, and plain-text types.`,
1309
+ uid: args.uid,
1310
+ partID,
1311
+ hint: 'Use download_attachment to get the raw file.'
1312
+ }, null, 2)
1313
+ }],
1314
+ isError: true
1315
+ };
1316
+ }
1317
+
1318
+ const truncated = extracted.text.length > MAX_EXTRACT_CHARS;
1319
+ const text = truncated
1320
+ ? extracted.text.slice(0, MAX_EXTRACT_CHARS)
1321
+ : extracted.text;
1322
+
1323
+ return {
1324
+ content: [{
1325
+ type: 'text',
1326
+ text: JSON.stringify({
1327
+ uid: args.uid,
1328
+ partID,
1329
+ filename,
1330
+ contentType,
1331
+ size_bytes: buffer.length,
1332
+ parser: extracted.parser,
1333
+ ...(extracted.pages !== undefined ? { pages: extracted.pages } : {}),
1334
+ ...(extracted.sheets !== undefined ? { sheets: extracted.sheets } : {}),
1335
+ ...(truncated ? { note: `Text truncated to ${MAX_EXTRACT_CHARS} characters (full length ${extracted.text.length}).` } : {}),
1336
+ text
1146
1337
  }, null, 2)
1147
1338
  }]
1148
1339
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.4.1",
3
+ "version": "1.6.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",
@@ -45,6 +45,9 @@
45
45
  "@modelcontextprotocol/sdk": "^1.0.0",
46
46
  "imap-simple": "^5.1.0",
47
47
  "mailparser": "^3.7.2",
48
- "nodemailer": "^6.9.16"
48
+ "mammoth": "^1.8.0",
49
+ "nodemailer": "^6.9.16",
50
+ "unpdf": "^1.8.1",
51
+ "xlsx": "^0.18.5"
49
52
  }
50
53
  }