@lengelhard/imap-email-mcp 1.5.0 → 1.7.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/.env.example +8 -0
- package/CHANGELOG.md +27 -0
- package/README.md +26 -4
- package/index.js +566 -83
- package/lib/imap-session.js +251 -0
- package/lib/local-index.js +484 -0
- package/lib/mime-text.js +131 -0
- package/package.json +15 -3
package/index.js
CHANGED
|
@@ -15,12 +15,24 @@ import {
|
|
|
15
15
|
CallToolRequestSchema,
|
|
16
16
|
ListToolsRequestSchema,
|
|
17
17
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
-
import imaps from 'imap-simple';
|
|
19
18
|
import { simpleParser } from 'mailparser';
|
|
20
19
|
import nodemailer from 'nodemailer';
|
|
21
20
|
import fs from 'fs';
|
|
22
21
|
import path from 'path';
|
|
23
22
|
import os from 'os';
|
|
23
|
+
import { createRequire } from 'module';
|
|
24
|
+
import {
|
|
25
|
+
SessionManager,
|
|
26
|
+
openBox,
|
|
27
|
+
queryUids,
|
|
28
|
+
fetchMessages,
|
|
29
|
+
headerOf,
|
|
30
|
+
buildCriteria
|
|
31
|
+
} from './lib/imap-session.js';
|
|
32
|
+
import { htmlToText, stripDataUris, leafParts } from './lib/mime-text.js';
|
|
33
|
+
import { LocalIndex } from './lib/local-index.js';
|
|
34
|
+
|
|
35
|
+
const PKG_VERSION = createRequire(import.meta.url)('./package.json').version;
|
|
24
36
|
|
|
25
37
|
// Default directory where download_attachment saves files
|
|
26
38
|
const ATTACHMENT_DIR =
|
|
@@ -31,6 +43,48 @@ const ATTACHMENT_DIR =
|
|
|
31
43
|
// 700KB raw ≈ 933KB base64, leaving headroom for the JSON envelope.
|
|
32
44
|
const MAX_INLINE_BYTES = 700 * 1024;
|
|
33
45
|
|
|
46
|
+
// Cap on text returned by extract_text, to stay well under tool-result limits
|
|
47
|
+
const MAX_EXTRACT_CHARS = 500 * 1024;
|
|
48
|
+
|
|
49
|
+
// Parse a binary attachment buffer into plain text, dispatching on content
|
|
50
|
+
// type / filename extension. Parsers are imported lazily so the server starts
|
|
51
|
+
// fast and a missing optional parser only affects extract_text itself.
|
|
52
|
+
async function extractTextFromBuffer(buffer, contentType, filename) {
|
|
53
|
+
const ct = (contentType || '').toLowerCase();
|
|
54
|
+
const ext = (filename || '').toLowerCase().split('.').pop();
|
|
55
|
+
|
|
56
|
+
if (ct.includes('pdf') || ext === 'pdf') {
|
|
57
|
+
const { extractText } = await import('unpdf');
|
|
58
|
+
const { totalPages, text } = await extractText(new Uint8Array(buffer), { mergePages: true });
|
|
59
|
+
return { parser: 'unpdf', pages: totalPages, text };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
ct.includes('spreadsheetml') || ct.includes('ms-excel') ||
|
|
64
|
+
ext === 'xlsx' || ext === 'xlsm' || ext === 'xls'
|
|
65
|
+
) {
|
|
66
|
+
const XLSX = await import('xlsx');
|
|
67
|
+
const wb = XLSX.read(buffer, { type: 'buffer' });
|
|
68
|
+
const parts = wb.SheetNames.map(name => {
|
|
69
|
+
const csv = XLSX.utils.sheet_to_csv(wb.Sheets[name], { blankrows: false });
|
|
70
|
+
return `=== Sheet: ${name} ===\n${csv}`;
|
|
71
|
+
});
|
|
72
|
+
return { parser: 'xlsx', sheets: wb.SheetNames, text: parts.join('\n\n') };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ct.includes('wordprocessingml') || ext === 'docx') {
|
|
76
|
+
const { default: mammoth } = await import('mammoth');
|
|
77
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
78
|
+
return { parser: 'mammoth', text: result.value };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (ct.startsWith('text/') || ['txt', 'csv', 'md', 'json', 'xml', 'html'].includes(ext)) {
|
|
82
|
+
return { parser: 'utf8', text: buffer.toString('utf8') };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null; // unsupported type
|
|
86
|
+
}
|
|
87
|
+
|
|
34
88
|
function sanitizeFilename(name) {
|
|
35
89
|
return (name || 'attachment').replace(/[/\\?%*:|"<>\x00-\x1f]/g, '_').slice(0, 200);
|
|
36
90
|
}
|
|
@@ -150,7 +204,7 @@ function validateConfig() {
|
|
|
150
204
|
const server = new Server(
|
|
151
205
|
{
|
|
152
206
|
name: 'imap-email-mcp',
|
|
153
|
-
version:
|
|
207
|
+
version: PKG_VERSION,
|
|
154
208
|
},
|
|
155
209
|
{
|
|
156
210
|
capabilities: {
|
|
@@ -174,7 +228,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
174
228
|
},
|
|
175
229
|
{
|
|
176
230
|
name: 'list_emails',
|
|
177
|
-
description: 'List emails from a folder
|
|
231
|
+
description: 'List emails from a folder, newest first (by delivery time). Filters can be combined. Returns total_matches so you can tell when results were capped.',
|
|
178
232
|
inputSchema: {
|
|
179
233
|
type: 'object',
|
|
180
234
|
properties: {
|
|
@@ -185,17 +239,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
185
239
|
},
|
|
186
240
|
limit: {
|
|
187
241
|
type: 'number',
|
|
188
|
-
description: 'Maximum number of emails to return (default: 20)',
|
|
242
|
+
description: 'Maximum number of emails to return (default: 20, max: 200)',
|
|
189
243
|
default: 20
|
|
190
244
|
},
|
|
245
|
+
offset: {
|
|
246
|
+
type: 'number',
|
|
247
|
+
description: 'Skip this many of the newest matches, for paging (default: 0)',
|
|
248
|
+
default: 0
|
|
249
|
+
},
|
|
191
250
|
unseen_only: {
|
|
192
251
|
type: 'boolean',
|
|
193
252
|
description: 'Only return unread emails',
|
|
194
253
|
default: false
|
|
195
254
|
},
|
|
255
|
+
flagged_only: {
|
|
256
|
+
type: 'boolean',
|
|
257
|
+
description: 'Only return flagged (starred) emails',
|
|
258
|
+
default: false
|
|
259
|
+
},
|
|
196
260
|
since_date: {
|
|
197
261
|
type: 'string',
|
|
198
|
-
description: 'Only return emails
|
|
262
|
+
description: 'Only return emails on or after this date (YYYY-MM-DD)'
|
|
263
|
+
},
|
|
264
|
+
before_date: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
description: 'Only return emails before this date (YYYY-MM-DD)'
|
|
199
267
|
}
|
|
200
268
|
},
|
|
201
269
|
required: []
|
|
@@ -215,6 +283,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
215
283
|
type: 'string',
|
|
216
284
|
description: 'Folder name (default: INBOX)',
|
|
217
285
|
default: 'INBOX'
|
|
286
|
+
},
|
|
287
|
+
include_html: {
|
|
288
|
+
type: 'boolean',
|
|
289
|
+
description: 'Also return the HTML body, with inline images removed (default: false). The text body is always returned; for HTML-only emails it is converted from the HTML.',
|
|
290
|
+
default: false
|
|
218
291
|
}
|
|
219
292
|
},
|
|
220
293
|
required: ['uid']
|
|
@@ -222,15 +295,46 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
222
295
|
},
|
|
223
296
|
{
|
|
224
297
|
name: 'search_emails',
|
|
225
|
-
description: '
|
|
298
|
+
description: 'Live IMAP search on the server. All given criteria must match. Results are newest first and include folder and uid. Sender, recipient, subject, date and flag criteria are reliable. Body and text criteria depend on the server and can silently miss messages on some providers; for content searches prefer search_index when it is available.',
|
|
226
299
|
inputSchema: {
|
|
227
300
|
type: 'object',
|
|
228
301
|
properties: {
|
|
229
302
|
folder: {
|
|
230
303
|
type: 'string',
|
|
231
|
-
description: 'Folder to search (default: INBOX)',
|
|
304
|
+
description: 'Folder to search (default: INBOX). Ignored when folders is given.',
|
|
232
305
|
default: 'INBOX'
|
|
233
306
|
},
|
|
307
|
+
folders: {
|
|
308
|
+
type: 'array',
|
|
309
|
+
items: { type: 'string' },
|
|
310
|
+
description: 'Search several folders in one call, e.g. ["INBOX", "Action", "Waiting", "Archive"]. Results are merged and sorted by date.'
|
|
311
|
+
},
|
|
312
|
+
cc: {
|
|
313
|
+
type: 'string',
|
|
314
|
+
description: 'Search by CC recipient'
|
|
315
|
+
},
|
|
316
|
+
text: {
|
|
317
|
+
type: 'string',
|
|
318
|
+
description: 'Search headers and body together (server-dependent, see description)'
|
|
319
|
+
},
|
|
320
|
+
since_date: {
|
|
321
|
+
type: 'string',
|
|
322
|
+
description: 'Only emails on or after this date (YYYY-MM-DD)'
|
|
323
|
+
},
|
|
324
|
+
before_date: {
|
|
325
|
+
type: 'string',
|
|
326
|
+
description: 'Only emails before this date (YYYY-MM-DD)'
|
|
327
|
+
},
|
|
328
|
+
unseen_only: {
|
|
329
|
+
type: 'boolean',
|
|
330
|
+
description: 'Only unread emails',
|
|
331
|
+
default: false
|
|
332
|
+
},
|
|
333
|
+
flagged_only: {
|
|
334
|
+
type: 'boolean',
|
|
335
|
+
description: 'Only flagged (starred) emails',
|
|
336
|
+
default: false
|
|
337
|
+
},
|
|
234
338
|
subject: {
|
|
235
339
|
type: 'string',
|
|
236
340
|
description: 'Search in subject line'
|
|
@@ -245,17 +349,113 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
245
349
|
},
|
|
246
350
|
body: {
|
|
247
351
|
type: 'string',
|
|
248
|
-
description: 'Search in body text'
|
|
352
|
+
description: 'Search in body text (server-dependent, see description)'
|
|
249
353
|
},
|
|
250
354
|
limit: {
|
|
251
355
|
type: 'number',
|
|
252
|
-
description: 'Maximum results (default: 20)',
|
|
356
|
+
description: 'Maximum results (default: 20, max: 200)',
|
|
253
357
|
default: 20
|
|
254
358
|
}
|
|
255
359
|
},
|
|
256
360
|
required: []
|
|
257
361
|
}
|
|
258
362
|
},
|
|
363
|
+
...(localIndex ? [
|
|
364
|
+
{
|
|
365
|
+
name: 'search_index',
|
|
366
|
+
description: 'Full-text search of the local read-only mail index (subject, sender, recipients, attachment names and body text). Accent-insensitive: "Loic" matches "Loïc". Use this for content searches. Results carry folder and uid for get_email; a uid can be stale if the message was moved since the last sync, in which case get_email reports it as not found. Check index.complete in the response before treating zero results as proof that nothing exists.',
|
|
367
|
+
inputSchema: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: {
|
|
370
|
+
query: {
|
|
371
|
+
type: 'string',
|
|
372
|
+
description: 'Words to find. By default every word must appear (match: all).'
|
|
373
|
+
},
|
|
374
|
+
match: {
|
|
375
|
+
type: 'string',
|
|
376
|
+
enum: ['all', 'any', 'phrase'],
|
|
377
|
+
description: 'all: every word; any: at least one word; phrase: the exact phrase (default: all)',
|
|
378
|
+
default: 'all'
|
|
379
|
+
},
|
|
380
|
+
prefix: {
|
|
381
|
+
type: 'boolean',
|
|
382
|
+
description: 'Treat each word as a prefix, e.g. "valuat" matches "valuation" (default: false)',
|
|
383
|
+
default: false
|
|
384
|
+
},
|
|
385
|
+
field: {
|
|
386
|
+
type: 'string',
|
|
387
|
+
enum: ['any', 'subject', 'body', 'from', 'to', 'attachments'],
|
|
388
|
+
description: 'Restrict the match to one field (default: any)',
|
|
389
|
+
default: 'any'
|
|
390
|
+
},
|
|
391
|
+
folders: {
|
|
392
|
+
type: 'array',
|
|
393
|
+
items: { type: 'string' },
|
|
394
|
+
description: 'Only these folders (default: all indexed folders)'
|
|
395
|
+
},
|
|
396
|
+
from: {
|
|
397
|
+
type: 'string',
|
|
398
|
+
description: 'Only messages whose sender contains this text'
|
|
399
|
+
},
|
|
400
|
+
since_date: {
|
|
401
|
+
type: 'string',
|
|
402
|
+
description: 'Only messages delivered on or after this date (YYYY-MM-DD)'
|
|
403
|
+
},
|
|
404
|
+
before_date: {
|
|
405
|
+
type: 'string',
|
|
406
|
+
description: 'Only messages delivered before this date (YYYY-MM-DD)'
|
|
407
|
+
},
|
|
408
|
+
sort: {
|
|
409
|
+
type: 'string',
|
|
410
|
+
enum: ['date', 'relevance'],
|
|
411
|
+
description: 'date: newest first; relevance: best match first (default: date)',
|
|
412
|
+
default: 'date'
|
|
413
|
+
},
|
|
414
|
+
limit: {
|
|
415
|
+
type: 'number',
|
|
416
|
+
description: 'Maximum results (default: 20, max: 200)',
|
|
417
|
+
default: 20
|
|
418
|
+
},
|
|
419
|
+
offset: {
|
|
420
|
+
type: 'number',
|
|
421
|
+
description: 'Skip this many results, for paging (default: 0)',
|
|
422
|
+
default: 0
|
|
423
|
+
},
|
|
424
|
+
refresh: {
|
|
425
|
+
type: 'boolean',
|
|
426
|
+
description: 'Pick up new and moved mail before searching (default: true). Skipped while a background sync is running.',
|
|
427
|
+
default: true
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
required: ['query']
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
name: 'index_status',
|
|
435
|
+
description: 'Show the local index state: per-folder message counts on the server and in the index, whether backfill is complete, last sync time and errors.',
|
|
436
|
+
inputSchema: { type: 'object', properties: {}, required: [] }
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: 'sync_index',
|
|
440
|
+
description: 'Run an index sync now and wait for it (up to wait_seconds). The index is read-only: syncing never changes anything on the mail server.',
|
|
441
|
+
inputSchema: {
|
|
442
|
+
type: 'object',
|
|
443
|
+
properties: {
|
|
444
|
+
folders: {
|
|
445
|
+
type: 'array',
|
|
446
|
+
items: { type: 'string' },
|
|
447
|
+
description: 'Only sync these folders (default: all indexed folders)'
|
|
448
|
+
},
|
|
449
|
+
wait_seconds: {
|
|
450
|
+
type: 'number',
|
|
451
|
+
description: 'How long to wait before returning while the sync continues in the background (default: 60)',
|
|
452
|
+
default: 60
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
required: []
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
] : []),
|
|
259
459
|
{
|
|
260
460
|
name: 'list_drafts',
|
|
261
461
|
description: 'List all draft emails',
|
|
@@ -426,6 +626,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
426
626
|
required: ['uid']
|
|
427
627
|
}
|
|
428
628
|
},
|
|
629
|
+
{
|
|
630
|
+
name: 'extract_text',
|
|
631
|
+
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.',
|
|
632
|
+
inputSchema: {
|
|
633
|
+
type: 'object',
|
|
634
|
+
properties: {
|
|
635
|
+
uid: {
|
|
636
|
+
type: 'number',
|
|
637
|
+
description: 'Email UID'
|
|
638
|
+
},
|
|
639
|
+
part_id: {
|
|
640
|
+
type: 'string',
|
|
641
|
+
description: 'MIME part ID of the attachment (from get_email response)'
|
|
642
|
+
},
|
|
643
|
+
filename: {
|
|
644
|
+
type: 'string',
|
|
645
|
+
description: 'Filename of the attachment (used as fallback to find part_id if not provided)'
|
|
646
|
+
},
|
|
647
|
+
folder: {
|
|
648
|
+
type: 'string',
|
|
649
|
+
description: 'Folder name the UID belongs to (default: INBOX). Always pass this explicitly for non-inbox messages.',
|
|
650
|
+
default: 'INBOX'
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
required: ['uid']
|
|
654
|
+
}
|
|
655
|
+
},
|
|
429
656
|
{
|
|
430
657
|
name: 'delete_email',
|
|
431
658
|
description: 'Delete an email by UID',
|
|
@@ -551,12 +778,59 @@ function normalizeSubject(subject) {
|
|
|
551
778
|
return subject.replace(/^(\s*(Re|Fwd|Fw)\s*:\s*)+/i, '').trim();
|
|
552
779
|
}
|
|
553
780
|
|
|
554
|
-
//
|
|
781
|
+
// Shared IMAP session. connectIMAP() returns a lease on one persistent
|
|
782
|
+
// connection; connection.end() releases the lease instead of logging out.
|
|
783
|
+
const sessions = new SessionManager(IMAP_CONFIG, {
|
|
784
|
+
idleCloseMs: parseInt(process.env.IMAP_IDLE_CLOSE_SECONDS || '300') * 1000
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Optional local read-only index (INDEX_ENABLED=true)
|
|
788
|
+
let localIndex = null;
|
|
789
|
+
let localIndexError = null;
|
|
790
|
+
|
|
555
791
|
async function connectIMAP() {
|
|
556
792
|
if (!IMAP_CONFIG.imap.password) {
|
|
557
793
|
throw new Error('IMAP_PASSWORD environment variable is not set');
|
|
558
794
|
}
|
|
559
|
-
return await
|
|
795
|
+
return await sessions.acquire();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE)';
|
|
799
|
+
const MAX_LIMIT = 200;
|
|
800
|
+
|
|
801
|
+
function clampLimit(value, fallback = 20) {
|
|
802
|
+
const n = Number(value);
|
|
803
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
804
|
+
return Math.min(Math.floor(n), MAX_LIMIT);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Fetch compact header summaries for an ordered UID list, keeping that order.
|
|
808
|
+
async function summarize(connection, folder, uids, { withFlags = true } = {}) {
|
|
809
|
+
const fetched = await fetchMessages(connection, uids, { bodies: [LIST_HEADER_FIELDS] });
|
|
810
|
+
const parseHeader = connection.imap.constructor.parseHeader;
|
|
811
|
+
const out = [];
|
|
812
|
+
for (const uid of uids) {
|
|
813
|
+
const rec = fetched.get(uid);
|
|
814
|
+
if (!rec) continue;
|
|
815
|
+
const h = headerOf(rec, parseHeader);
|
|
816
|
+
const item = {
|
|
817
|
+
folder,
|
|
818
|
+
uid,
|
|
819
|
+
date: h.date?.[0],
|
|
820
|
+
from: h.from?.[0],
|
|
821
|
+
to: h.to?.join(', '),
|
|
822
|
+
subject: h.subject?.[0]
|
|
823
|
+
};
|
|
824
|
+
if (h.cc?.length) item.cc = h.cc.join(', ');
|
|
825
|
+
if (withFlags) item.flags = rec.attributes.flags;
|
|
826
|
+
out.push(item);
|
|
827
|
+
}
|
|
828
|
+
return out;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function dateValue(item) {
|
|
832
|
+
const t = Date.parse(item.date || '');
|
|
833
|
+
return Number.isNaN(t) ? 0 : t;
|
|
560
834
|
}
|
|
561
835
|
|
|
562
836
|
// Helper to find drafts folder (handles different provider conventions)
|
|
@@ -588,9 +862,23 @@ async function findDraftsFolder(connection) {
|
|
|
588
862
|
}
|
|
589
863
|
|
|
590
864
|
// Tool handlers
|
|
865
|
+
const INDEX_AFFECTING_TOOLS = {
|
|
866
|
+
move_email: (a) => [a.from_folder || 'INBOX', a.to_folder],
|
|
867
|
+
move_thread: (a) => [a.from_folder, a.to_folder],
|
|
868
|
+
delete_email: (a) => [a.folder || 'INBOX']
|
|
869
|
+
};
|
|
870
|
+
|
|
591
871
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
592
|
-
const { name, arguments:
|
|
872
|
+
const { name, arguments: rawArgs } = request.params;
|
|
873
|
+
const args = rawArgs || {};
|
|
874
|
+
const result = await handleTool(name, args);
|
|
875
|
+
if (localIndex && INDEX_AFFECTING_TOOLS[name] && !result.isError) {
|
|
876
|
+
localIndex.requestSync(INDEX_AFFECTING_TOOLS[name](args));
|
|
877
|
+
}
|
|
878
|
+
return result;
|
|
879
|
+
});
|
|
593
880
|
|
|
881
|
+
async function handleTool(name, args) {
|
|
594
882
|
try {
|
|
595
883
|
switch (name) {
|
|
596
884
|
case 'list_folders': {
|
|
@@ -618,39 +906,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
618
906
|
|
|
619
907
|
case 'list_emails': {
|
|
620
908
|
const folder = args.folder || 'INBOX';
|
|
621
|
-
const limit = args.limit
|
|
909
|
+
const limit = clampLimit(args.limit);
|
|
910
|
+
const offset = Math.max(0, Number(args.offset) || 0);
|
|
622
911
|
const connection = await connectIMAP();
|
|
623
912
|
|
|
624
913
|
try {
|
|
625
|
-
await
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if (args.since_date) {
|
|
632
|
-
searchCriteria = [['SINCE', args.since_date]];
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const fetchOptions = {
|
|
636
|
-
bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
|
|
637
|
-
struct: true
|
|
638
|
-
};
|
|
639
|
-
|
|
640
|
-
const messages = await connection.search(searchCriteria, fetchOptions);
|
|
641
|
-
const results = messages.slice(-limit).reverse().map(msg => {
|
|
642
|
-
const header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
|
|
643
|
-
return {
|
|
644
|
-
uid: msg.attributes.uid,
|
|
645
|
-
date: header.date?.[0],
|
|
646
|
-
from: header.from?.[0],
|
|
647
|
-
to: header.to?.[0],
|
|
648
|
-
subject: header.subject?.[0],
|
|
649
|
-
flags: msg.attributes.flags
|
|
650
|
-
};
|
|
914
|
+
await openBox(connection, folder, true);
|
|
915
|
+
const criteria = buildCriteria({
|
|
916
|
+
unseen_only: args.unseen_only,
|
|
917
|
+
flagged_only: args.flagged_only,
|
|
918
|
+
since_date: args.since_date,
|
|
919
|
+
before_date: args.before_date
|
|
651
920
|
});
|
|
921
|
+
const uids = await queryUids(connection, criteria, { order: 'arrival' });
|
|
922
|
+
const page = uids.slice(offset, offset + limit);
|
|
923
|
+
const results = await summarize(connection, folder, page);
|
|
652
924
|
|
|
653
|
-
return {
|
|
925
|
+
return {
|
|
926
|
+
content: [{
|
|
927
|
+
type: 'text',
|
|
928
|
+
text: JSON.stringify({ folder, total_matches: uids.length, returned: results.length, offset, results }, null, 2)
|
|
929
|
+
}]
|
|
930
|
+
};
|
|
654
931
|
} finally {
|
|
655
932
|
connection.end();
|
|
656
933
|
}
|
|
@@ -661,22 +938,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
661
938
|
const connection = await connectIMAP();
|
|
662
939
|
|
|
663
940
|
try {
|
|
664
|
-
await
|
|
941
|
+
await openBox(connection, folder, true);
|
|
665
942
|
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
};
|
|
943
|
+
const uid = Number(args.uid);
|
|
944
|
+
const fetched = await fetchMessages(connection, [uid], { bodies: [''], struct: true });
|
|
945
|
+
const rec = fetched.get(uid);
|
|
670
946
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
947
|
+
if (!rec) {
|
|
948
|
+
return {
|
|
949
|
+
content: [{ type: 'text', text: `Email not found: uid ${uid} in ${folder}. UIDs change when a message is moved; search again to get the current uid.` }],
|
|
950
|
+
isError: true
|
|
951
|
+
};
|
|
675
952
|
}
|
|
676
953
|
|
|
677
|
-
const msg =
|
|
678
|
-
const rawBody =
|
|
954
|
+
const msg = { attributes: rec.attributes };
|
|
955
|
+
const rawBody = rec.parts[''] ?? rec.parts[Object.keys(rec.parts)[0]];
|
|
679
956
|
const parsed = await simpleParser(rawBody);
|
|
957
|
+
const hasPlainPart = leafParts(rec.attributes.struct || [])
|
|
958
|
+
.some((n) => `${n.type}/${n.subtype}`.toLowerCase() === 'text/plain' && (n.disposition?.type || '').toLowerCase() !== 'attachment');
|
|
959
|
+
const htmlOnly = !!parsed.html && !hasPlainPart;
|
|
680
960
|
|
|
681
961
|
return {
|
|
682
962
|
content: [{
|
|
@@ -689,8 +969,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
689
969
|
cc: parsed.cc?.text,
|
|
690
970
|
subject: parsed.subject,
|
|
691
971
|
date: parsed.date,
|
|
692
|
-
text: parsed.text,
|
|
693
|
-
|
|
972
|
+
text: (htmlOnly || !parsed.text) && parsed.html ? htmlToText(parsed.html) : (parsed.text || ''),
|
|
973
|
+
...(htmlOnly && { text_source: 'converted from HTML' }),
|
|
974
|
+
...(args.include_html && parsed.html && { html: stripDataUris(parsed.html) }),
|
|
694
975
|
attachments: (() => {
|
|
695
976
|
// Start with what mailparser found (covers Content-Disposition: attachment
|
|
696
977
|
// and inline+filename cases for content it fully parsed).
|
|
@@ -728,41 +1009,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
728
1009
|
}
|
|
729
1010
|
|
|
730
1011
|
case 'search_emails': {
|
|
731
|
-
const
|
|
732
|
-
|
|
1012
|
+
const folders = Array.isArray(args.folders) && args.folders.length
|
|
1013
|
+
? args.folders
|
|
1014
|
+
: [args.folder || 'INBOX'];
|
|
1015
|
+
const limit = clampLimit(args.limit);
|
|
733
1016
|
const connection = await connectIMAP();
|
|
734
1017
|
|
|
735
1018
|
try {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1019
|
+
const merged = [];
|
|
1020
|
+
const perFolder = {};
|
|
1021
|
+
const errors = {};
|
|
1022
|
+
for (const folder of folders) {
|
|
1023
|
+
try {
|
|
1024
|
+
await openBox(connection, folder, true);
|
|
1025
|
+
// Fresh criteria per folder: node-imap rewrites date arguments in place.
|
|
1026
|
+
const uids = await queryUids(connection, buildCriteria(args), { order: 'arrival' });
|
|
1027
|
+
perFolder[folder] = uids.length;
|
|
1028
|
+
merged.push(...await summarize(connection, folder, uids.slice(0, limit), { withFlags: true }));
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
errors[folder] = err.message;
|
|
1031
|
+
}
|
|
746
1032
|
}
|
|
747
1033
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
};
|
|
752
|
-
|
|
753
|
-
const messages = await connection.search(searchCriteria, fetchOptions);
|
|
754
|
-
const results = messages.slice(-limit).reverse().map(msg => {
|
|
755
|
-
const header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
|
|
756
|
-
return {
|
|
757
|
-
uid: msg.attributes.uid,
|
|
758
|
-
date: header.date?.[0],
|
|
759
|
-
from: header.from?.[0],
|
|
760
|
-
to: header.to?.[0],
|
|
761
|
-
subject: header.subject?.[0]
|
|
762
|
-
};
|
|
763
|
-
});
|
|
1034
|
+
merged.sort((a, b) => dateValue(b) - dateValue(a));
|
|
1035
|
+
const results = merged.slice(0, limit);
|
|
1036
|
+
const total = Object.values(perFolder).reduce((a, b) => a + b, 0);
|
|
764
1037
|
|
|
765
|
-
return {
|
|
1038
|
+
return {
|
|
1039
|
+
content: [{
|
|
1040
|
+
type: 'text',
|
|
1041
|
+
text: JSON.stringify({
|
|
1042
|
+
total_matches: total,
|
|
1043
|
+
returned: results.length,
|
|
1044
|
+
...(folders.length > 1 && { matches_per_folder: perFolder }),
|
|
1045
|
+
...(Object.keys(errors).length && { errors }),
|
|
1046
|
+
results
|
|
1047
|
+
}, null, 2)
|
|
1048
|
+
}],
|
|
1049
|
+
...(Object.keys(errors).length === folders.length && { isError: true })
|
|
1050
|
+
};
|
|
766
1051
|
} finally {
|
|
767
1052
|
connection.end();
|
|
768
1053
|
}
|
|
@@ -1154,6 +1439,125 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1154
1439
|
}
|
|
1155
1440
|
}
|
|
1156
1441
|
|
|
1442
|
+
case 'extract_text': {
|
|
1443
|
+
const folder = args.folder || 'INBOX';
|
|
1444
|
+
const connection = await connectIMAP();
|
|
1445
|
+
|
|
1446
|
+
try {
|
|
1447
|
+
await connection.openBox(folder);
|
|
1448
|
+
|
|
1449
|
+
const structFetch = await connection.search([['UID', args.uid]], {
|
|
1450
|
+
bodies: [],
|
|
1451
|
+
struct: true
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
if (structFetch.length === 0) {
|
|
1455
|
+
return { content: [{ type: 'text', text: 'Email not found' }] };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const msg = structFetch[0];
|
|
1459
|
+
let partID = args.part_id;
|
|
1460
|
+
|
|
1461
|
+
if (!partID && args.filename) {
|
|
1462
|
+
const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
|
|
1463
|
+
const match = attachmentParts.find(p =>
|
|
1464
|
+
(p.filename || '').toLowerCase() === args.filename.toLowerCase()
|
|
1465
|
+
);
|
|
1466
|
+
if (match) partID = match.partID;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (!partID) {
|
|
1470
|
+
return {
|
|
1471
|
+
content: [{ type: 'text', text: 'Could not determine part ID. Please provide part_id from get_email response.' }],
|
|
1472
|
+
isError: true
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const partFetch = await connection.search([['UID', args.uid]], {
|
|
1477
|
+
bodies: [partID],
|
|
1478
|
+
struct: true
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
if (partFetch.length === 0) {
|
|
1482
|
+
return { content: [{ type: 'text', text: 'Attachment part not found' }] };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const partData = partFetch[0].parts.find(p => p.which === partID);
|
|
1486
|
+
|
|
1487
|
+
if (!partData) {
|
|
1488
|
+
return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
|
|
1492
|
+
const attachmentInfo = attachmentParts.find(p => p.partID === partID);
|
|
1493
|
+
|
|
1494
|
+
const buffer = decodePartBody(partData.body, attachmentInfo?.encoding || 'base64');
|
|
1495
|
+
const filename = sanitizeFilename(attachmentInfo?.filename || args.filename);
|
|
1496
|
+
const contentType = attachmentInfo?.contentType || 'application/octet-stream';
|
|
1497
|
+
|
|
1498
|
+
let extracted;
|
|
1499
|
+
try {
|
|
1500
|
+
extracted = await extractTextFromBuffer(buffer, contentType, filename);
|
|
1501
|
+
} catch (parseErr) {
|
|
1502
|
+
return {
|
|
1503
|
+
content: [{
|
|
1504
|
+
type: 'text',
|
|
1505
|
+
text: JSON.stringify({
|
|
1506
|
+
error: `Parsing failed: ${parseErr.message}`,
|
|
1507
|
+
uid: args.uid,
|
|
1508
|
+
partID,
|
|
1509
|
+
filename,
|
|
1510
|
+
contentType,
|
|
1511
|
+
size_bytes: buffer.length,
|
|
1512
|
+
hint: 'The file may be corrupted, password-protected, or an unusual variant. Use download_attachment to get the raw file.'
|
|
1513
|
+
}, null, 2)
|
|
1514
|
+
}],
|
|
1515
|
+
isError: true
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (!extracted) {
|
|
1520
|
+
return {
|
|
1521
|
+
content: [{
|
|
1522
|
+
type: 'text',
|
|
1523
|
+
text: JSON.stringify({
|
|
1524
|
+
error: `Unsupported type for text extraction: ${contentType} (${filename}). Supported: PDF, xlsx/xlsm/xls, docx, and plain-text types.`,
|
|
1525
|
+
uid: args.uid,
|
|
1526
|
+
partID,
|
|
1527
|
+
hint: 'Use download_attachment to get the raw file.'
|
|
1528
|
+
}, null, 2)
|
|
1529
|
+
}],
|
|
1530
|
+
isError: true
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const truncated = extracted.text.length > MAX_EXTRACT_CHARS;
|
|
1535
|
+
const text = truncated
|
|
1536
|
+
? extracted.text.slice(0, MAX_EXTRACT_CHARS)
|
|
1537
|
+
: extracted.text;
|
|
1538
|
+
|
|
1539
|
+
return {
|
|
1540
|
+
content: [{
|
|
1541
|
+
type: 'text',
|
|
1542
|
+
text: JSON.stringify({
|
|
1543
|
+
uid: args.uid,
|
|
1544
|
+
partID,
|
|
1545
|
+
filename,
|
|
1546
|
+
contentType,
|
|
1547
|
+
size_bytes: buffer.length,
|
|
1548
|
+
parser: extracted.parser,
|
|
1549
|
+
...(extracted.pages !== undefined ? { pages: extracted.pages } : {}),
|
|
1550
|
+
...(extracted.sheets !== undefined ? { sheets: extracted.sheets } : {}),
|
|
1551
|
+
...(truncated ? { note: `Text truncated to ${MAX_EXTRACT_CHARS} characters (full length ${extracted.text.length}).` } : {}),
|
|
1552
|
+
text
|
|
1553
|
+
}, null, 2)
|
|
1554
|
+
}]
|
|
1555
|
+
};
|
|
1556
|
+
} finally {
|
|
1557
|
+
connection.end();
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1157
1561
|
case 'delete_email': {
|
|
1158
1562
|
const folder = args.folder || 'INBOX';
|
|
1159
1563
|
const connection = await connectIMAP();
|
|
@@ -1367,6 +1771,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1367
1771
|
}
|
|
1368
1772
|
}
|
|
1369
1773
|
|
|
1774
|
+
case 'search_index': {
|
|
1775
|
+
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
1776
|
+
let refresh = null;
|
|
1777
|
+
if (args.refresh !== false) {
|
|
1778
|
+
if (localIndex.running) {
|
|
1779
|
+
refresh = 'skipped: background sync in progress';
|
|
1780
|
+
} else {
|
|
1781
|
+
const quick = localIndex.sync({ maxPerFolder: 300, passes: 1 });
|
|
1782
|
+
const timedOut = await Promise.race([
|
|
1783
|
+
quick.then(() => false),
|
|
1784
|
+
new Promise((resolve) => setTimeout(() => resolve(true), 20000))
|
|
1785
|
+
]);
|
|
1786
|
+
refresh = timedOut ? 'partial: refresh still running in background' : 'done';
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
const found = localIndex.search({
|
|
1790
|
+
query: args.query,
|
|
1791
|
+
match: args.match,
|
|
1792
|
+
prefix: args.prefix,
|
|
1793
|
+
field: args.field,
|
|
1794
|
+
folders: args.folders,
|
|
1795
|
+
from: args.from,
|
|
1796
|
+
since_date: args.since_date,
|
|
1797
|
+
before_date: args.before_date,
|
|
1798
|
+
sort: args.sort,
|
|
1799
|
+
limit: args.limit,
|
|
1800
|
+
offset: args.offset
|
|
1801
|
+
});
|
|
1802
|
+
if (refresh) found.index.refresh = refresh;
|
|
1803
|
+
return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
case 'index_status': {
|
|
1807
|
+
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
1808
|
+
return { content: [{ type: 'text', text: JSON.stringify(localIndex.status(), null, 2) }] };
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
case 'sync_index': {
|
|
1812
|
+
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
1813
|
+
const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
|
|
1814
|
+
const alreadyRunning = !!localIndex.running;
|
|
1815
|
+
const run = localIndex.sync(args.folders?.length ? { folders: args.folders } : {});
|
|
1816
|
+
const outcome = await Promise.race([
|
|
1817
|
+
run.then((summary) => ({ finished: true, summary })),
|
|
1818
|
+
new Promise((resolve) => setTimeout(() => resolve({ finished: false }), waitMs))
|
|
1819
|
+
]);
|
|
1820
|
+
return {
|
|
1821
|
+
content: [{
|
|
1822
|
+
type: 'text',
|
|
1823
|
+
text: JSON.stringify({
|
|
1824
|
+
...(alreadyRunning && { note: 'A sync was already running; waited on that one.' }),
|
|
1825
|
+
finished: outcome.finished,
|
|
1826
|
+
...(outcome.summary && { summary: outcome.summary }),
|
|
1827
|
+
status: localIndex.status()
|
|
1828
|
+
}, null, 2)
|
|
1829
|
+
}]
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1370
1833
|
default:
|
|
1371
1834
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
1372
1835
|
}
|
|
@@ -1379,11 +1842,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1379
1842
|
isError: true
|
|
1380
1843
|
};
|
|
1381
1844
|
}
|
|
1382
|
-
}
|
|
1845
|
+
}
|
|
1383
1846
|
|
|
1384
1847
|
// Start server
|
|
1385
1848
|
async function main() {
|
|
1386
1849
|
validateConfig();
|
|
1850
|
+
|
|
1851
|
+
if (/^(1|true|yes)$/i.test(process.env.INDEX_ENABLED || '')) {
|
|
1852
|
+
const list = (v) => (v || '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
1853
|
+
try {
|
|
1854
|
+
localIndex = await new LocalIndex({
|
|
1855
|
+
imapConfig: IMAP_CONFIG,
|
|
1856
|
+
dbPath: process.env.INDEX_DB_PATH || undefined,
|
|
1857
|
+
folders: list(process.env.INDEX_FOLDERS),
|
|
1858
|
+
excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
|
|
1859
|
+
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5')
|
|
1860
|
+
}).open();
|
|
1861
|
+
localIndex.start();
|
|
1862
|
+
console.error(`Local index enabled: ${localIndex.dbPath}`);
|
|
1863
|
+
} catch (err) {
|
|
1864
|
+
localIndex = null;
|
|
1865
|
+
localIndexError = `Local index failed to start: ${err.message}`;
|
|
1866
|
+
console.error(localIndexError);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1387
1870
|
const transport = new StdioServerTransport();
|
|
1388
1871
|
await server.connect(transport);
|
|
1389
1872
|
console.error('IMAP Email MCP Server running on stdio');
|