@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/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: '1.0.0',
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 with optional filtering',
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 since this date (YYYY-MM-DD format)'
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: 'Search emails by subject, from, or body text',
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
- // Helper function to connect to IMAP
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 imaps.connect(IMAP_CONFIG);
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: args } = request.params;
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 || 20;
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 connection.openBox(folder);
626
-
627
- let searchCriteria = ['ALL'];
628
- if (args.unseen_only) {
629
- searchCriteria = ['UNSEEN'];
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 { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
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 connection.openBox(folder);
941
+ await openBox(connection, folder, true);
665
942
 
666
- const fetchOptions = {
667
- bodies: [''],
668
- struct: true
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
- const messages = await connection.search([['UID', args.uid]], fetchOptions);
672
-
673
- if (messages.length === 0) {
674
- return { content: [{ type: 'text', text: 'Email not found' }] };
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 = messages[0];
678
- const rawBody = msg.parts.find(p => p.which === '')?.body;
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
- html: parsed.html,
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 folder = args.folder || 'INBOX';
732
- const limit = args.limit || 20;
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
- await connection.openBox(folder);
737
-
738
- let searchCriteria = [];
739
- if (args.subject) searchCriteria.push(['SUBJECT', args.subject]);
740
- if (args.from) searchCriteria.push(['FROM', args.from]);
741
- if (args.to) searchCriteria.push(['TO', args.to]);
742
- if (args.body) searchCriteria.push(['BODY', args.body]);
743
-
744
- if (searchCriteria.length === 0) {
745
- searchCriteria = ['ALL'];
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
- const fetchOptions = {
749
- bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
750
- struct: true
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 { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
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');