@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/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: '1.0.0',
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 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.',
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 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)'
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: '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.',
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
- // 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
+
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 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;
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: 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
+ });
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 || 20;
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 connection.openBox(folder);
695
-
696
- let searchCriteria = ['ALL'];
697
- if (args.unseen_only) {
698
- searchCriteria = ['UNSEEN'];
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 { 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
+ };
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 connection.openBox(folder);
734
-
735
- const fetchOptions = {
736
- bodies: [''],
737
- struct: true
738
- };
941
+ await openBox(connection, folder, true);
739
942
 
740
- const messages = await connection.search([['UID', args.uid]], fetchOptions);
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 (messages.length === 0) {
743
- 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
+ };
744
952
  }
745
953
 
746
- const msg = messages[0];
747
- 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]];
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
- 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) }),
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 folder = args.folder || 'INBOX';
801
- 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);
802
1016
  const connection = await connectIMAP();
803
1017
 
804
1018
  try {
805
- await connection.openBox(folder);
806
-
807
- let searchCriteria = [];
808
- if (args.subject) searchCriteria.push(['SUBJECT', args.subject]);
809
- if (args.from) searchCriteria.push(['FROM', args.from]);
810
- if (args.to) searchCriteria.push(['TO', args.to]);
811
- if (args.body) searchCriteria.push(['BODY', args.body]);
812
-
813
- if (searchCriteria.length === 0) {
814
- 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
+ }
815
1032
  }
816
1033
 
817
- const fetchOptions = {
818
- bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
819
- struct: true
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 { 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
+ };
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');