@lengelhard/imap-email-mcp 1.6.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 +24 -4
- package/index.js +378 -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 +11 -2
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 =
|
|
@@ -192,7 +204,7 @@ function validateConfig() {
|
|
|
192
204
|
const server = new Server(
|
|
193
205
|
{
|
|
194
206
|
name: 'imap-email-mcp',
|
|
195
|
-
version:
|
|
207
|
+
version: PKG_VERSION,
|
|
196
208
|
},
|
|
197
209
|
{
|
|
198
210
|
capabilities: {
|
|
@@ -216,7 +228,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
216
228
|
},
|
|
217
229
|
{
|
|
218
230
|
name: 'list_emails',
|
|
219
|
-
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.',
|
|
220
232
|
inputSchema: {
|
|
221
233
|
type: 'object',
|
|
222
234
|
properties: {
|
|
@@ -227,17 +239,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
227
239
|
},
|
|
228
240
|
limit: {
|
|
229
241
|
type: 'number',
|
|
230
|
-
description: 'Maximum number of emails to return (default: 20)',
|
|
242
|
+
description: 'Maximum number of emails to return (default: 20, max: 200)',
|
|
231
243
|
default: 20
|
|
232
244
|
},
|
|
245
|
+
offset: {
|
|
246
|
+
type: 'number',
|
|
247
|
+
description: 'Skip this many of the newest matches, for paging (default: 0)',
|
|
248
|
+
default: 0
|
|
249
|
+
},
|
|
233
250
|
unseen_only: {
|
|
234
251
|
type: 'boolean',
|
|
235
252
|
description: 'Only return unread emails',
|
|
236
253
|
default: false
|
|
237
254
|
},
|
|
255
|
+
flagged_only: {
|
|
256
|
+
type: 'boolean',
|
|
257
|
+
description: 'Only return flagged (starred) emails',
|
|
258
|
+
default: false
|
|
259
|
+
},
|
|
238
260
|
since_date: {
|
|
239
261
|
type: 'string',
|
|
240
|
-
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)'
|
|
241
267
|
}
|
|
242
268
|
},
|
|
243
269
|
required: []
|
|
@@ -257,6 +283,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
257
283
|
type: 'string',
|
|
258
284
|
description: 'Folder name (default: INBOX)',
|
|
259
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
|
|
260
291
|
}
|
|
261
292
|
},
|
|
262
293
|
required: ['uid']
|
|
@@ -264,15 +295,46 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
264
295
|
},
|
|
265
296
|
{
|
|
266
297
|
name: 'search_emails',
|
|
267
|
-
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.',
|
|
268
299
|
inputSchema: {
|
|
269
300
|
type: 'object',
|
|
270
301
|
properties: {
|
|
271
302
|
folder: {
|
|
272
303
|
type: 'string',
|
|
273
|
-
description: 'Folder to search (default: INBOX)',
|
|
304
|
+
description: 'Folder to search (default: INBOX). Ignored when folders is given.',
|
|
274
305
|
default: 'INBOX'
|
|
275
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
|
+
},
|
|
276
338
|
subject: {
|
|
277
339
|
type: 'string',
|
|
278
340
|
description: 'Search in subject line'
|
|
@@ -287,17 +349,113 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
287
349
|
},
|
|
288
350
|
body: {
|
|
289
351
|
type: 'string',
|
|
290
|
-
description: 'Search in body text'
|
|
352
|
+
description: 'Search in body text (server-dependent, see description)'
|
|
291
353
|
},
|
|
292
354
|
limit: {
|
|
293
355
|
type: 'number',
|
|
294
|
-
description: 'Maximum results (default: 20)',
|
|
356
|
+
description: 'Maximum results (default: 20, max: 200)',
|
|
295
357
|
default: 20
|
|
296
358
|
}
|
|
297
359
|
},
|
|
298
360
|
required: []
|
|
299
361
|
}
|
|
300
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
|
+
] : []),
|
|
301
459
|
{
|
|
302
460
|
name: 'list_drafts',
|
|
303
461
|
description: 'List all draft emails',
|
|
@@ -620,12 +778,59 @@ function normalizeSubject(subject) {
|
|
|
620
778
|
return subject.replace(/^(\s*(Re|Fwd|Fw)\s*:\s*)+/i, '').trim();
|
|
621
779
|
}
|
|
622
780
|
|
|
623
|
-
//
|
|
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
|
+
|
|
624
791
|
async function connectIMAP() {
|
|
625
792
|
if (!IMAP_CONFIG.imap.password) {
|
|
626
793
|
throw new Error('IMAP_PASSWORD environment variable is not set');
|
|
627
794
|
}
|
|
628
|
-
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;
|
|
629
834
|
}
|
|
630
835
|
|
|
631
836
|
// Helper to find drafts folder (handles different provider conventions)
|
|
@@ -657,9 +862,23 @@ async function findDraftsFolder(connection) {
|
|
|
657
862
|
}
|
|
658
863
|
|
|
659
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
|
+
|
|
660
871
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
661
|
-
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
|
+
});
|
|
662
880
|
|
|
881
|
+
async function handleTool(name, args) {
|
|
663
882
|
try {
|
|
664
883
|
switch (name) {
|
|
665
884
|
case 'list_folders': {
|
|
@@ -687,39 +906,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
687
906
|
|
|
688
907
|
case 'list_emails': {
|
|
689
908
|
const folder = args.folder || 'INBOX';
|
|
690
|
-
const limit = args.limit
|
|
909
|
+
const limit = clampLimit(args.limit);
|
|
910
|
+
const offset = Math.max(0, Number(args.offset) || 0);
|
|
691
911
|
const connection = await connectIMAP();
|
|
692
912
|
|
|
693
913
|
try {
|
|
694
|
-
await
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (args.since_date) {
|
|
701
|
-
searchCriteria = [['SINCE', args.since_date]];
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
const fetchOptions = {
|
|
705
|
-
bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
|
|
706
|
-
struct: true
|
|
707
|
-
};
|
|
708
|
-
|
|
709
|
-
const messages = await connection.search(searchCriteria, fetchOptions);
|
|
710
|
-
const results = messages.slice(-limit).reverse().map(msg => {
|
|
711
|
-
const header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
|
|
712
|
-
return {
|
|
713
|
-
uid: msg.attributes.uid,
|
|
714
|
-
date: header.date?.[0],
|
|
715
|
-
from: header.from?.[0],
|
|
716
|
-
to: header.to?.[0],
|
|
717
|
-
subject: header.subject?.[0],
|
|
718
|
-
flags: msg.attributes.flags
|
|
719
|
-
};
|
|
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
|
|
720
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);
|
|
721
924
|
|
|
722
|
-
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
|
+
};
|
|
723
931
|
} finally {
|
|
724
932
|
connection.end();
|
|
725
933
|
}
|
|
@@ -730,22 +938,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
730
938
|
const connection = await connectIMAP();
|
|
731
939
|
|
|
732
940
|
try {
|
|
733
|
-
await
|
|
734
|
-
|
|
735
|
-
const fetchOptions = {
|
|
736
|
-
bodies: [''],
|
|
737
|
-
struct: true
|
|
738
|
-
};
|
|
941
|
+
await openBox(connection, folder, true);
|
|
739
942
|
|
|
740
|
-
const
|
|
943
|
+
const uid = Number(args.uid);
|
|
944
|
+
const fetched = await fetchMessages(connection, [uid], { bodies: [''], struct: true });
|
|
945
|
+
const rec = fetched.get(uid);
|
|
741
946
|
|
|
742
|
-
if (
|
|
743
|
-
return {
|
|
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
|
+
};
|
|
744
952
|
}
|
|
745
953
|
|
|
746
|
-
const msg =
|
|
747
|
-
const rawBody =
|
|
954
|
+
const msg = { attributes: rec.attributes };
|
|
955
|
+
const rawBody = rec.parts[''] ?? rec.parts[Object.keys(rec.parts)[0]];
|
|
748
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;
|
|
749
960
|
|
|
750
961
|
return {
|
|
751
962
|
content: [{
|
|
@@ -758,8 +969,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
758
969
|
cc: parsed.cc?.text,
|
|
759
970
|
subject: parsed.subject,
|
|
760
971
|
date: parsed.date,
|
|
761
|
-
text: parsed.text,
|
|
762
|
-
|
|
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) }),
|
|
763
975
|
attachments: (() => {
|
|
764
976
|
// Start with what mailparser found (covers Content-Disposition: attachment
|
|
765
977
|
// and inline+filename cases for content it fully parsed).
|
|
@@ -797,41 +1009,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
797
1009
|
}
|
|
798
1010
|
|
|
799
1011
|
case 'search_emails': {
|
|
800
|
-
const
|
|
801
|
-
|
|
1012
|
+
const folders = Array.isArray(args.folders) && args.folders.length
|
|
1013
|
+
? args.folders
|
|
1014
|
+
: [args.folder || 'INBOX'];
|
|
1015
|
+
const limit = clampLimit(args.limit);
|
|
802
1016
|
const connection = await connectIMAP();
|
|
803
1017
|
|
|
804
1018
|
try {
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
+
}
|
|
815
1032
|
}
|
|
816
1033
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
};
|
|
821
|
-
|
|
822
|
-
const messages = await connection.search(searchCriteria, fetchOptions);
|
|
823
|
-
const results = messages.slice(-limit).reverse().map(msg => {
|
|
824
|
-
const header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
|
|
825
|
-
return {
|
|
826
|
-
uid: msg.attributes.uid,
|
|
827
|
-
date: header.date?.[0],
|
|
828
|
-
from: header.from?.[0],
|
|
829
|
-
to: header.to?.[0],
|
|
830
|
-
subject: header.subject?.[0]
|
|
831
|
-
};
|
|
832
|
-
});
|
|
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);
|
|
833
1037
|
|
|
834
|
-
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
|
+
};
|
|
835
1051
|
} finally {
|
|
836
1052
|
connection.end();
|
|
837
1053
|
}
|
|
@@ -1555,6 +1771,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1555
1771
|
}
|
|
1556
1772
|
}
|
|
1557
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
|
+
|
|
1558
1833
|
default:
|
|
1559
1834
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
1560
1835
|
}
|
|
@@ -1567,11 +1842,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1567
1842
|
isError: true
|
|
1568
1843
|
};
|
|
1569
1844
|
}
|
|
1570
|
-
}
|
|
1845
|
+
}
|
|
1571
1846
|
|
|
1572
1847
|
// Start server
|
|
1573
1848
|
async function main() {
|
|
1574
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
|
+
|
|
1575
1870
|
const transport = new StdioServerTransport();
|
|
1576
1871
|
await server.connect(transport);
|
|
1577
1872
|
console.error('IMAP Email MCP Server running on stdio');
|