@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.
- package/README.md +2 -0
- package/index.js +213 -22
- 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.
|
|
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: '
|
|
424
|
-
|
|
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
|
-
//
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
48
|
+
"mammoth": "^1.8.0",
|
|
49
|
+
"nodemailer": "^6.9.16",
|
|
50
|
+
"unpdf": "^1.8.1",
|
|
51
|
+
"xlsx": "^0.18.5"
|
|
49
52
|
}
|
|
50
53
|
}
|