@lengelhard/imap-email-mcp 1.5.0 → 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 +188 -0
  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
  }
@@ -426,6 +468,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
426
468
  required: ['uid']
427
469
  }
428
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'
493
+ }
494
+ },
495
+ required: ['uid']
496
+ }
497
+ },
429
498
  {
430
499
  name: 'delete_email',
431
500
  description: 'Delete an email by UID',
@@ -1154,6 +1223,125 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1154
1223
  }
1155
1224
  }
1156
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
1337
+ }, null, 2)
1338
+ }]
1339
+ };
1340
+ } finally {
1341
+ connection.end();
1342
+ }
1343
+ }
1344
+
1157
1345
  case 'delete_email': {
1158
1346
  const folder = args.folder || 'INBOX';
1159
1347
  const connection = await connectIMAP();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.5.0",
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
  }