@lengelhard/imap-email-mcp 1.7.4 → 1.8.1

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 CHANGED
@@ -21,6 +21,7 @@ IMAP_HOST=imap.example.com
21
21
  # INDEX_FOLDERS= (comma list; default: all folders except Trash and Junk)
22
22
  # INDEX_EXCLUDE_FOLDERS= (comma list, added to the default exclusions)
23
23
  # INDEX_SYNC_INTERVAL_MINUTES=5
24
+ # INDEX_SYNC_MAX_MINUTES=15 (wall-clock cap on one sync run; 0 disables)
24
25
 
25
26
  # Optional - SMTP Settings (defaults to IMAP values if not set)
26
27
  # SMTP_HOST=smtp.example.com
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.1
4
+
5
+ ### Fixed
6
+ - A sync could still hang forever after a connection dropped. The folder LIST at the start of every run was the one IMAP command not under the 1.7.3 time limit, and node-imap never fails a command whose socket has died, so a run that started on a silent connection (a sleeping laptop, a dropped session) waited indefinitely with `sync_running: true`, blocking every later sync and every `sync_index` call. Seen on 18, 20 and 21 Sep 2026. The LIST now runs under the same limit as every other command.
7
+ - As a backstop, every sync run has a wall-clock cap (`INDEX_SYNC_MAX_MINUTES`, default 15). A run that outlives it is aborted, its connection is dropped and its lease released, and the next scheduled sync starts clean. A run cut loose can no longer write to the index.
8
+ - The next background sync is scheduled from the end of the previous run instead of a fixed timer, so a slow run cannot queue a pile-up behind itself.
9
+
10
+ ### Added
11
+ - `index_status` reports `sync_running_since`, `sync_running_seconds` and `sync_max_minutes`, so a stuck sync is a visible number rather than a flag.
12
+ - `sync_index` takes `force: true`, which aborts a running sync before starting a fresh one. The index is read-only against the server, so this is always safe.
13
+ - `find_duplicates` takes `offset` and returns `has_more`, so more than 500 groups can be paged through instead of partitioned by date.
14
+
15
+ ## 1.8.0
16
+
17
+ ### Added
18
+ - `find_duplicates`: lists messages that exist more than once, matched on the Message-ID header. `scope: "same_folder"` reports a folder holding the same message twice, a filing error, and names the extra copies under `redundant_copies` (the lowest UID is kept). `scope: "cross_folder"` reports a message present in several folders, usually a split thread, and never marks anything redundant, because deleting one copy of those only relocates the problem. Messages with no Message-ID are counted in `coverage.without_message_id` instead of being silently skipped. Requires the local index.
19
+ - `message_id` on the results of `list_emails`, `search_emails`, `search_index` and `get_email`, with the angle brackets stripped. It was already fetched and stored by the indexer but never exposed, which left callers matching on subject, sender and timestamp instead. That heuristic is unreliable: in a fast thread two genuinely different replies can share a subject, a sender and a minute, and on 19 Sep 2026 it produced five candidate pairs of which none were duplicates.
20
+ - `messages.message_id` is now indexed in SQLite.
21
+
22
+ ### Changed
23
+ - **`delete_email` moves to Trash instead of erasing.** It previously flagged `\Deleted` and expunged, so a wrong UID was unrecoverable and nothing appeared in Trash. It now moves the message to the folder the server flags as `\Trash` (falling back to the usual names), and the response says where it went. Pass `expunge: true` for the old behaviour, which is permanent. A message already in Trash is still erased. On an account with no Trash folder the call now fails and says so, rather than erasing a message when the recoverable path was the one asked for.
24
+
25
+ ### Fixed
26
+ - `normalizeMessageId` returned the literal `<>` for an empty Message-ID header instead of treating it as absent, which would have grouped every such message together as duplicates of one another.
27
+
3
28
  ## 1.7.4
4
29
 
5
30
  ### Added
package/README.md CHANGED
@@ -75,7 +75,8 @@ Add to your config file:
75
75
  - **Compose** - Create and save email drafts
76
76
  - **Send** - Send emails directly via SMTP
77
77
  - **Manage drafts** - List, read, update, and delete drafts
78
- - **Delete emails** - Remove unwanted messages
78
+ - **Find duplicates** - Spot messages filed twice in one folder, or the same message sitting in several folders, matched on Message-ID
79
+ - **Delete emails** - Move to Trash by default, with an explicit opt-in to erase permanently
79
80
  - **Multi-provider support** - Works with Gmail, Outlook, Yahoo, Fastmail, and any standard IMAP provider
80
81
 
81
82
  ## Configuration
@@ -104,7 +105,8 @@ Add to your config file:
104
105
  | `INDEX_DB_PATH` | see below | Location of the index database |
105
106
  | `INDEX_FOLDERS` | all but Trash/Junk | Comma-separated folders to index |
106
107
  | `INDEX_EXCLUDE_FOLDERS` | none | Comma-separated folders to skip, on top of Trash/Junk |
107
- | `INDEX_SYNC_INTERVAL_MINUTES` | `5` | Background sync interval |
108
+ | `INDEX_SYNC_INTERVAL_MINUTES` | `5` | Background sync interval, measured from the end of the previous run |
109
+ | `INDEX_SYNC_MAX_MINUTES` | `15` | Wall-clock cap on one sync run; a run that outlives it is aborted and its connection dropped (`0` disables) |
108
110
 
109
111
  ### Local search index
110
112
 
@@ -118,6 +120,15 @@ Some IMAP servers return incomplete results for body searches without reporting
118
120
  - **Recovery.** If the connection dies silently, the running sync gives up at the command time limit, records the error and releases its lock. The next sync reconnects on its own.
119
121
  - **Requirements.** Node 22.13 or later (uses the built-in `node:sqlite`, no native modules).
120
122
 
123
+ ### Finding duplicates
124
+
125
+ Mail clients that file by tag sometimes append a fresh copy of a message instead of moving the one already on the server, which leaves the same message in a folder twice. `find_duplicates` reports those, using the Message-ID header as the identity of a message.
126
+
127
+ - **Why Message-ID.** It is set by the sending server, it is unique, and it does not change when mail is moved. Matching on subject, sender and timestamp instead produces false pairs: in a fast thread two different replies can share a subject, a sender and a minute.
128
+ - **Two shapes.** `scope: "same_folder"` finds a folder holding one message more than once. Those extra copies carry nothing unique, and the tool lists them under `redundant_copies` (lowest UID kept). `scope: "cross_folder"` finds a message present in several folders, usually a split thread. Do not resolve those by deleting: removing one copy only moves the problem.
129
+ - **Honest coverage.** A message with no Message-ID header cannot be compared. It is counted in `coverage.without_message_id` rather than quietly skipped, and `index.complete` says whether the index had seen everything.
130
+ - **Nothing is deleted.** The tool only reports. Acting on it means calling `delete_email`, which moves to Trash unless you pass `expunge: true`.
131
+
121
132
  ### Provider Settings
122
133
 
123
134
  | Provider | IMAP_HOST | SMTP_HOST | Notes |
@@ -137,14 +148,15 @@ Some IMAP servers return incomplete results for body searches without reporting
137
148
  | `get_email` | Get an email by UID. Text by default; HTML (without inline images) on request |
138
149
  | `search_emails` | Live IMAP search across one or more folders: subject, from, to, cc, body, text, dates, flags |
139
150
  | `search_index` | Full-text search of the local index (when `INDEX_ENABLED=true`); `index.warning` flags a stale index or folder errors |
140
- | `index_status` | Per-folder index coverage, last sync and errors |
141
- | `sync_index` | Run an index sync now |
151
+ | `index_status` | Per-folder index coverage, last sync and errors; `sync_running_seconds` shows how long the current sync has run |
152
+ | `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
153
+ | `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
142
154
  | `list_drafts` | List all draft emails |
143
155
  | `get_draft` | Get a specific draft by UID |
144
156
  | `create_draft` | Create a new email draft |
145
157
  | `update_draft` | Update an existing draft |
146
158
  | `send_email` | Send an email directly |
147
- | `delete_email` | Delete an email by UID |
159
+ | `delete_email` | Delete an email by UID. Moves to Trash unless `expunge: true` |
148
160
  | `download_attachment` | Download an attachment: ≤700KB returns inline base64, larger saves to disk (ATTACHMENT_DIR) |
149
161
  | `extract_text` | Extract plain text from a PDF, xlsx/xlsm/xls, docx, or text attachment in one call, parsed server-side |
150
162
 
package/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  buildCriteria
32
32
  } from './lib/imap-session.js';
33
33
  import { htmlToText, stripDataUris, leafParts } from './lib/mime-text.js';
34
- import { LocalIndex } from './lib/local-index.js';
34
+ import { LocalIndex, normalizeMessageId } from './lib/local-index.js';
35
35
 
36
36
  const PKG_VERSION = createRequire(import.meta.url)('./package.json').version;
37
37
 
@@ -447,12 +447,56 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
447
447
  },
448
448
  {
449
449
  name: 'index_status',
450
- 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.',
450
+ 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. sync_running_seconds says how long the current sync has been going; a value far beyond sync_max_minutes means it is stuck and sync_index with force: true will cut it loose.',
451
451
  inputSchema: { type: 'object', properties: {}, required: [] }
452
452
  },
453
+ {
454
+ name: 'find_duplicates',
455
+ description: 'List messages that exist more than once, matched on their Message-ID header. Message-ID is assigned by the sending server and survives moves, so it identifies a message reliably; matching on subject, sender and timestamp instead pairs up genuinely different messages in fast threads. scope "same_folder" finds a folder holding the same message twice, which is a filing error and whose extra copies are listed under redundant_copies. scope "cross_folder" finds a message sitting in several folders, usually a split thread; never resolve those by deleting, since that only relocates the problem. Check index.complete and coverage.without_message_id before treating the result as exhaustive.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ scope: {
460
+ type: 'string',
461
+ enum: ['all', 'same_folder', 'cross_folder'],
462
+ description: 'same_folder: one folder holds the message more than once; cross_folder: the message is in several folders; all: either (default)',
463
+ default: 'all'
464
+ },
465
+ folders: {
466
+ type: 'array',
467
+ items: { type: 'string' },
468
+ description: 'Only these folders (default: all indexed folders). A copy outside this list is not counted.'
469
+ },
470
+ since_date: {
471
+ type: 'string',
472
+ description: 'Only messages delivered on or after this date (YYYY-MM-DD)'
473
+ },
474
+ before_date: {
475
+ type: 'string',
476
+ description: 'Only messages delivered before this date (YYYY-MM-DD)'
477
+ },
478
+ limit: {
479
+ type: 'number',
480
+ description: 'Maximum groups to return (default: 50, max: 500). total_groups reports the full count.',
481
+ default: 50
482
+ },
483
+ offset: {
484
+ type: 'number',
485
+ description: 'Skip this many groups, for paging (default: 0). has_more says whether another page exists.',
486
+ default: 0
487
+ },
488
+ refresh: {
489
+ type: 'boolean',
490
+ description: 'Pick up new and moved mail before searching (default: true). Skipped while a background sync is running.',
491
+ default: true
492
+ }
493
+ },
494
+ required: []
495
+ }
496
+ },
453
497
  {
454
498
  name: 'sync_index',
455
- 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.',
499
+ 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. If a sync is already running the call waits on that one; pass force: true to abort it first and start fresh, which is the remedy for a sync that index_status shows as running far longer than sync_max_minutes.',
456
500
  inputSchema: {
457
501
  type: 'object',
458
502
  properties: {
@@ -465,6 +509,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
465
509
  type: 'number',
466
510
  description: 'How long to wait before returning while the sync continues in the background (default: 60)',
467
511
  default: 60
512
+ },
513
+ force: {
514
+ type: 'boolean',
515
+ description: 'Abort a sync that is already running before starting this one (default: false). Safe: the index is read-only against the server. Use when index_status shows sync_running_seconds far beyond sync_max_minutes.',
516
+ default: false
468
517
  }
469
518
  },
470
519
  required: []
@@ -670,7 +719,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
670
719
  },
671
720
  {
672
721
  name: 'delete_email',
673
- description: 'Delete an email by UID',
722
+ description: 'Delete an email by UID. By default the message is moved to the Trash folder, so it can still be recovered; the response says where it went. Pass expunge: true to erase it from the server instead, which cannot be undone. A message already in Trash is always erased.',
674
723
  inputSchema: {
675
724
  type: 'object',
676
725
  properties: {
@@ -682,6 +731,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
682
731
  type: 'string',
683
732
  description: 'Folder name (default: INBOX)',
684
733
  default: 'INBOX'
734
+ },
735
+ expunge: {
736
+ type: 'boolean',
737
+ description: 'Erase the message from the server instead of moving it to Trash. Permanent and not recoverable (default: false).',
738
+ default: false
685
739
  }
686
740
  },
687
741
  required: ['uid']
@@ -812,7 +866,7 @@ async function connectIMAP() {
812
866
  return await sessions.acquire();
813
867
  }
814
868
 
815
- const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE)';
869
+ const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
816
870
  const MAX_LIMIT = 200;
817
871
 
818
872
  function clampLimit(value, fallback = 20) {
@@ -839,6 +893,8 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
839
893
  subject: h.subject?.[0]
840
894
  };
841
895
  if (h.cc?.length) item.cc = h.cc.join(', ');
896
+ const messageId = normalizeMessageId(h['message-id']?.[0]);
897
+ if (messageId) item.message_id = messageId;
842
898
  if (withFlags) item.flags = rec.attributes.flags;
843
899
  out.push(item);
844
900
  }
@@ -850,6 +906,37 @@ function dateValue(item) {
850
906
  return Number.isNaN(t) ? 0 : t;
851
907
  }
852
908
 
909
+ /**
910
+ * Find the mailbox that holds deleted mail.
911
+ *
912
+ * Prefers the folder the server itself flags as \Trash, since that is what the
913
+ * user's other mail clients will treat as the bin, and falls back to the usual
914
+ * names. Returns null when nothing plausible exists, which the caller reports
915
+ * rather than guessing and writing mail somewhere unexpected.
916
+ */
917
+ async function findTrashFolder(connection) {
918
+ const boxes = await connection.getBoxes();
919
+ const walk = (node, prefix = '', out = []) => {
920
+ for (const [name, box] of Object.entries(node || {})) {
921
+ const full = prefix ? `${prefix}${box.delimiter || '/'}${name}` : name;
922
+ const attribs = (box.attribs || []).map((a) => String(a).toLowerCase());
923
+ if (box.special_use_attrib) attribs.push(String(box.special_use_attrib).toLowerCase());
924
+ out.push({ name: full, attribs, leaf: name });
925
+ if (box.children) walk(box.children, full, out);
926
+ }
927
+ return out;
928
+ };
929
+ const all = walk(boxes);
930
+ const selectable = all.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
931
+
932
+ const flagged = selectable.find((b) => b.attribs.includes('\\trash'));
933
+ if (flagged) return flagged.name;
934
+
935
+ const known = ['trash', 'deleted items', 'deleted messages', 'bin', 'papierkorb', 'corbeille'];
936
+ const byName = selectable.find((b) => known.includes(b.leaf.toLowerCase()));
937
+ return byName ? byName.name : null;
938
+ }
939
+
853
940
  // Helper to find drafts folder (handles different provider conventions)
854
941
  async function findDraftsFolder(connection) {
855
942
  const boxes = await connection.getBoxes();
@@ -986,6 +1073,7 @@ async function handleTool(name, args) {
986
1073
  cc: parsed.cc?.text,
987
1074
  subject: parsed.subject,
988
1075
  date: parsed.date,
1076
+ ...(normalizeMessageId(parsed.messageId) && { message_id: normalizeMessageId(parsed.messageId) }),
989
1077
  text: (htmlOnly || !parsed.text) && parsed.html ? htmlToText(parsed.html) : (parsed.text || ''),
990
1078
  ...(htmlOnly && { text_source: 'converted from HTML' }),
991
1079
  ...(args.include_html && parsed.html && { html: stripDataUris(parsed.html) }),
@@ -1582,11 +1670,60 @@ async function handleTool(name, args) {
1582
1670
  const connection = await connectIMAP();
1583
1671
 
1584
1672
  try {
1673
+ // Default to the recoverable path: move to Trash. Erasing is opt-in,
1674
+ // because an expunge cannot be undone and a wrong uid is silent.
1675
+ const trash = args.expunge ? null : await findTrashFolder(connection);
1676
+ const alreadyInTrash = trash && trash.toLowerCase() === folder.toLowerCase();
1677
+
1585
1678
  await connection.openBox(folder);
1679
+
1680
+ if (trash && !alreadyInTrash) {
1681
+ await new Promise((resolve, reject) => {
1682
+ connection.imap.move(args.uid, trash, (err) => (err ? reject(err) : resolve()));
1683
+ });
1684
+ return {
1685
+ content: [{
1686
+ type: 'text',
1687
+ text: JSON.stringify({
1688
+ success: true,
1689
+ uid: args.uid,
1690
+ folder,
1691
+ action: 'moved_to_trash',
1692
+ trash_folder: trash,
1693
+ recoverable: true,
1694
+ note: `Moved to ${trash}. Pass expunge: true to erase permanently instead.`
1695
+ }, null, 2)
1696
+ }]
1697
+ };
1698
+ }
1699
+
1700
+ if (!args.expunge && !trash) {
1701
+ // The caller asked for the recoverable path and it is not available.
1702
+ // Erasing anyway would be a permanent action nobody asked for.
1703
+ return {
1704
+ content: [{
1705
+ type: 'text',
1706
+ text: `No Trash folder found on this server, so the message was left where it is. Pass expunge: true to erase uid ${args.uid} from ${folder} permanently, or create a Trash folder first.`
1707
+ }],
1708
+ isError: true
1709
+ };
1710
+ }
1711
+
1586
1712
  await connection.addFlags(args.uid, ['\\Deleted']);
1587
1713
  await connection.closeBox(true); // Expunge
1588
-
1589
- return { content: [{ type: 'text', text: 'Email deleted successfully' }] };
1714
+ return {
1715
+ content: [{
1716
+ type: 'text',
1717
+ text: JSON.stringify({
1718
+ success: true,
1719
+ uid: args.uid,
1720
+ folder,
1721
+ action: 'expunged',
1722
+ recoverable: false,
1723
+ ...(alreadyInTrash && { reason: 'message was already in Trash' })
1724
+ }, null, 2)
1725
+ }]
1726
+ };
1590
1727
  } finally {
1591
1728
  connection.end();
1592
1729
  }
@@ -1831,10 +1968,50 @@ async function handleTool(name, args) {
1831
1968
  return { content: [{ type: 'text', text: JSON.stringify(localIndex.status(), null, 2) }] };
1832
1969
  }
1833
1970
 
1971
+ case 'find_duplicates': {
1972
+ if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
1973
+ let refresh = null;
1974
+ if (args.refresh !== false) {
1975
+ if (localIndex.running) {
1976
+ refresh = 'skipped: background sync in progress';
1977
+ } else {
1978
+ const quick = localIndex.sync({ maxPerFolder: 300, passes: 1 });
1979
+ const outcome = await Promise.race([
1980
+ quick.then((r) => (r && r.skipped ? 'skipped' : 'done')),
1981
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), 20000))
1982
+ ]);
1983
+ refresh = {
1984
+ done: 'done',
1985
+ skipped: 'skipped: another server process is syncing the index',
1986
+ timeout: 'partial: refresh still running in background'
1987
+ }[outcome];
1988
+ }
1989
+ }
1990
+ const found = localIndex.findDuplicates({
1991
+ folders: args.folders || null,
1992
+ scope: args.scope || 'all',
1993
+ since_date: args.since_date || null,
1994
+ before_date: args.before_date || null,
1995
+ limit: args.limit,
1996
+ offset: args.offset
1997
+ });
1998
+ if (refresh) found.index = { ...found.index, refresh };
1999
+ return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
2000
+ }
2001
+
1834
2002
  case 'sync_index': {
1835
2003
  if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
1836
2004
  const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
1837
- const alreadyRunning = !!localIndex.running;
2005
+ let alreadyRunning = !!localIndex.running;
2006
+ let forced = false;
2007
+ if (alreadyRunning && args.force) {
2008
+ const stuckFor = localIndex.runningSince ? Math.round((Date.now() - localIndex.runningSince) / 1000) : null;
2009
+ forced = await Promise.race([
2010
+ localIndex.abortSync(`forced by sync_index after ${stuckFor}s`),
2011
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), 15000))
2012
+ ]);
2013
+ alreadyRunning = !!localIndex.running;
2014
+ }
1838
2015
  const run = localIndex.sync(args.folders?.length ? { folders: args.folders } : {});
1839
2016
  const outcome = await Promise.race([
1840
2017
  run.then((summary) => ({ finished: true, summary })),
@@ -1845,7 +2022,9 @@ async function handleTool(name, args) {
1845
2022
  content: [{
1846
2023
  type: 'text',
1847
2024
  text: JSON.stringify({
1848
- ...(alreadyRunning && { note: 'A sync was already running; waited on that one.' }),
2025
+ ...(forced === true && { note: 'A running sync was aborted first (force: true) and a fresh one started.' }),
2026
+ ...(forced === 'timeout' && { note: 'force: true was requested but the running sync did not release within 15s; waited on it instead.' }),
2027
+ ...(alreadyRunning && !forced && { note: 'A sync was already running; waited on that one.' }),
1849
2028
  ...(skipped && { note: `Not started: ${outcome.summary.reason}. It keeps the index current; check index_status.` }),
1850
2029
  finished: skipped ? false : outcome.finished,
1851
2030
  ...(outcome.summary && !skipped && { summary: outcome.summary }),
@@ -1881,7 +2060,8 @@ async function main() {
1881
2060
  dbPath: process.env.INDEX_DB_PATH || undefined,
1882
2061
  folders: list(process.env.INDEX_FOLDERS),
1883
2062
  excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
1884
- intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5')
2063
+ intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5'),
2064
+ maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15')
1885
2065
  }).open();
1886
2066
  localIndex.start();
1887
2067
  console.error(`Local index enabled: ${localIndex.dbPath}`);
@@ -89,6 +89,9 @@ export class SessionManager {
89
89
  this.lastUsedAt = 0;
90
90
  this.tail = Promise.resolve();
91
91
  this.idleTimer = null;
92
+ this.activeRelease = null;
93
+ this.generation = 0;
94
+ this.abortConnect = null;
92
95
  }
93
96
 
94
97
  _isUsable(conn) {
@@ -113,15 +116,34 @@ export class SessionManager {
113
116
  async _connect() {
114
117
  if (this._isUsable(this.conn)) return this.conn;
115
118
  if (!this.connecting) {
116
- this.connecting = (async () => {
119
+ const generation = this.generation;
120
+ // dropCurrent() can reject this attempt at once, so a caller queued on a
121
+ // socket that will never answer does not wait out the connect timeout.
122
+ let abort;
123
+ const aborted = new Promise((_, reject) => { abort = reject; });
124
+ aborted.catch(() => {});
125
+ this.abortConnect = abort;
126
+ const dial = (async () => {
117
127
  const conn = await imaps.connect(this.config);
128
+ if (generation !== this.generation) {
129
+ // The caller that wanted this socket is gone; do not hand it to anyone.
130
+ try { conn.imap.destroy(); } catch { /* ignore */ }
131
+ throw new Error('connection attempt superseded');
132
+ }
118
133
  // Without an 'error' listener an idle socket error would crash the process.
119
134
  conn.on('error', (err) => this._drop(conn, `error: ${err && err.message}`));
120
135
  conn.imap.once('close', () => { if (this.conn === conn) this.conn = null; });
121
136
  conn.imap.once('end', () => { if (this.conn === conn) this.conn = null; });
122
137
  this.conn = conn;
123
138
  return conn;
124
- })().finally(() => { this.connecting = null; });
139
+ })();
140
+ dial.catch(() => {});
141
+ const attempt = Promise.race([dial, aborted]);
142
+ attempt.then(() => {}, () => {}).then(() => {
143
+ if (this.connecting === attempt) this.connecting = null;
144
+ if (this.abortConnect === abort) this.abortConnect = null;
145
+ });
146
+ this.connecting = attempt;
125
147
  }
126
148
  return this.connecting;
127
149
  }
@@ -172,6 +194,7 @@ export class SessionManager {
172
194
  const release = () => {
173
195
  if (released) return;
174
196
  released = true;
197
+ if (this.activeRelease === release) this.activeRelease = null;
175
198
  this.lastUsedAt = Date.now();
176
199
  clearTimeout(watchdog);
177
200
  if (!this._isUsable(conn)) this._drop(conn, 'unusable after call');
@@ -185,6 +208,7 @@ export class SessionManager {
185
208
  release();
186
209
  }, this.leaseTimeoutMs);
187
210
  if (watchdog.unref) watchdog.unref();
211
+ this.activeRelease = release;
188
212
 
189
213
  return new Proxy(conn, {
190
214
  get(target, prop) {
@@ -196,12 +220,41 @@ export class SessionManager {
196
220
  });
197
221
  }
198
222
 
223
+ /**
224
+ * Drop the current connection (if any) so the next acquire() opens a fresh
225
+ * one. Used to cut a sync loose from a socket that has gone silent.
226
+ */
227
+ dropCurrent(reason) {
228
+ if (this.conn) this._drop(this.conn, reason);
229
+ // A connect attempt still pending belongs to the run being cut loose:
230
+ // fail it now and detach it, so the next acquire() starts its own.
231
+ this.generation++;
232
+ const abort = this.abortConnect;
233
+ this.abortConnect = null;
234
+ this.connecting = null;
235
+ if (abort) abort(new Error(`connection attempt aborted: ${reason}`));
236
+ // Release the lease the dropped connection was handed out under, so the
237
+ // next acquire() does not wait behind an owner that will never return.
238
+ if (this.activeRelease) this.activeRelease();
239
+ }
240
+
199
241
  close() {
200
242
  clearTimeout(this.idleTimer);
201
243
  if (this.conn) this._drop(this.conn, null);
202
244
  }
203
245
  }
204
246
 
247
+ /** LIST all mailboxes, under the command time limit. */
248
+ export function listBoxes(connection) {
249
+ return withTimeout(connection, 'list folders', new Promise((resolve, reject) => {
250
+ try {
251
+ connection.imap.getBoxes((err, boxes) => (err ? reject(err) : resolve(boxes)));
252
+ } catch (err) {
253
+ reject(err);
254
+ }
255
+ }));
256
+ }
257
+
205
258
  /** Open a mailbox. readOnly uses EXAMINE, which never changes flags. */
206
259
  export function openBox(connection, name, readOnly = false) {
207
260
  return withTimeout(connection, `open ${name}`, new Promise((resolve, reject) => {
@@ -19,7 +19,7 @@ import fs from 'fs';
19
19
  import os from 'os';
20
20
  import path from 'path';
21
21
  import crypto from 'crypto';
22
- import { SessionManager, openBox, fetchMessages, headerOf, localDate, uidSearchAll, getOpTimeout } from './imap-session.js';
22
+ import { SessionManager, openBox, fetchMessages, headerOf, localDate, uidSearchAll, listBoxes, getOpTimeout } from './imap-session.js';
23
23
  import { planParts, partsToText } from './mime-text.js';
24
24
 
25
25
  const SCHEMA_VERSION = '1';
@@ -29,6 +29,7 @@ const REQUEST_RETRY_MS = 15 * 1000;
29
29
  const STALL_MS = 5 * 60 * 1000;
30
30
  const REQUEST_MAX_ATTEMPTS = 6;
31
31
  const STALE_AFTER_MS = 15 * 60 * 1000;
32
+ const DEFAULT_MAX_RUN_MINUTES = 15;
32
33
 
33
34
  function describeAge(ms) {
34
35
  const minutes = Math.round(ms / 60000);
@@ -116,18 +117,41 @@ function ftsQuery(query, { match = 'all', prefix = false, field = 'any' } = {})
116
117
  return expr;
117
118
  }
118
119
 
120
+ /**
121
+ * Compare form for a Message-ID header.
122
+ *
123
+ * Strips the surrounding angle brackets and whitespace and nothing else.
124
+ * Case is preserved on purpose: the local part of a Message-ID is
125
+ * case-sensitive per RFC 5322, and this value decides whether two messages
126
+ * are reported as copies of each other. A missed duplicate is a nuisance; a
127
+ * wrongly matched pair could cost someone a message, so this errs strict.
128
+ */
129
+ export function normalizeMessageId(value) {
130
+ const s = String(value ?? '').trim();
131
+ if (!s) return null;
132
+ const m = s.match(/<([^<>]+)>/);
133
+ const id = (m ? m[1] : s.replace(/^<+|>+$/g, '')).trim();
134
+ return id || null;
135
+ }
136
+
119
137
  export class LocalIndex {
120
- constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
138
+ constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, maxRunMinutes = DEFAULT_MAX_RUN_MINUTES, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
121
139
  this.imapConfig = imapConfig;
122
140
  this.dbPath = dbPath || defaultDbPath(imapConfig.imap.user, imapConfig.imap.host);
123
141
  this.folders = folders && folders.length ? folders : null;
124
142
  this.excludeFolders = excludeFolders.map((f) => f.toLowerCase());
125
143
  this.intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
144
+ // Wall-clock cap on one sync run. 0 disables it. Fractions are allowed for tests.
145
+ const cap = Number(maxRunMinutes);
146
+ this.maxRunMs = Number.isFinite(cap) && cap >= 0 ? cap * 60 * 1000 : DEFAULT_MAX_RUN_MINUTES * 60 * 1000;
126
147
  this.batchSize = batchSize;
127
148
  this.maxBodyChars = maxBodyChars;
128
149
  this.log = log;
129
150
  this.db = null;
130
151
  this.running = null;
152
+ this.runningSince = 0;
153
+ this.abortRun = null;
154
+ this.stopped = false;
131
155
  this.timer = null;
132
156
  this.progressAt = 0;
133
157
  this.debounce = null;
@@ -177,6 +201,7 @@ export class LocalIndex {
177
201
  );
178
202
  CREATE INDEX IF NOT EXISTS messages_date ON messages (internal_date);
179
203
  CREATE INDEX IF NOT EXISTS messages_folder_date ON messages (folder, internal_date);
204
+ CREATE INDEX IF NOT EXISTS messages_message_id ON messages (message_id);
180
205
  CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5 (
181
206
  subject, from_addr, to_addr, attachments, body,
182
207
  tokenize = 'unicode61 remove_diacritics 2'
@@ -188,19 +213,31 @@ export class LocalIndex {
188
213
  }
189
214
 
190
215
  start() {
191
- const kick = () => {
192
- const last = Number(this._meta('last_full_sync') || 0);
193
- if (Date.now() - last < this.intervalMs / 2) return;
194
- this.sync().catch((err) => this.log(`[index] sync failed: ${err.message}`));
216
+ // The next scheduled sync is armed when the previous one ends, so a slow
217
+ // or stuck run can never queue a pile-up behind itself.
218
+ const arm = (delay) => {
219
+ clearTimeout(this.timer);
220
+ if (this.stopped) return;
221
+ this.timer = setTimeout(kick, delay);
222
+ if (this.timer.unref) this.timer.unref();
223
+ };
224
+ const kick = async () => {
225
+ try {
226
+ const last = Number(this._meta('last_full_sync') || 0);
227
+ if (Date.now() - last >= this.intervalMs / 2) await this.sync();
228
+ } catch (err) {
229
+ this.log(`[index] sync failed: ${err.message}`);
230
+ } finally {
231
+ arm(this.intervalMs);
232
+ }
195
233
  };
196
- const first = setTimeout(kick, 5000);
197
- if (first.unref) first.unref();
198
- this.timer = setInterval(kick, this.intervalMs);
199
- if (this.timer.unref) this.timer.unref();
234
+ this.stopped = false;
235
+ arm(5000);
200
236
  }
201
237
 
202
238
  stop() {
203
- clearInterval(this.timer);
239
+ this.stopped = true;
240
+ clearTimeout(this.timer);
204
241
  clearTimeout(this.debounce);
205
242
  this.releaseLock();
206
243
  this.session.close();
@@ -314,7 +351,7 @@ export class LocalIndex {
314
351
  }
315
352
 
316
353
  async _targetFolders(connection) {
317
- const boxes = flattenBoxes(await connection.getBoxes());
354
+ const boxes = flattenBoxes(await listBoxes(connection));
318
355
  const selectable = boxes.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
319
356
  if (this.folders) {
320
357
  const wanted = new Set(this.folders.map((f) => f.toLowerCase()));
@@ -335,6 +372,7 @@ export class LocalIndex {
335
372
  */
336
373
  sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
337
374
  if (this.running) return this.running;
375
+ this.runningSince = Date.now();
338
376
  this.running = (async () => {
339
377
  if (!this._acquireLock()) {
340
378
  return { skipped: true, reason: `another server process (pid ${this.lockHolder}) is syncing the index` };
@@ -355,7 +393,7 @@ export class LocalIndex {
355
393
  }, LOCK_HEARTBEAT_MS);
356
394
  if (heartbeat.unref) heartbeat.unref();
357
395
  try {
358
- const summary = await this._sync({ folders, maxPerFolder, passes });
396
+ const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes }));
359
397
  if (this.db) {
360
398
  if (!folders && passes === Infinity && !summary.aborted) this._setMeta('last_full_sync', Date.now());
361
399
  this._setMeta('last_run', JSON.stringify({
@@ -373,15 +411,58 @@ export class LocalIndex {
373
411
  clearInterval(heartbeat);
374
412
  this.releaseLock();
375
413
  }
376
- })().finally(() => { this.running = null; });
414
+ })().finally(() => { this.running = null; this.runningSince = 0; this.abortRun = null; });
377
415
  return this.running;
378
416
  }
379
417
 
418
+ /**
419
+ * Run one sync body under a wall-clock cap and an abort handle. A run that
420
+ * outlives the cap, or is aborted through abortSync(), is cut loose: its
421
+ * connection is dropped so any command it is waiting on fails, the caller
422
+ * releases the lock, and the orphaned body cannot touch the index again
423
+ * because _assertLock() throws once the lock is gone.
424
+ */
425
+ _runCapped(body) {
426
+ const started = Date.now();
427
+ let capTimer = null;
428
+ const cut = (reason) => ({ added: 0, removed: 0, folders: {}, aborted: reason, duration_ms: Date.now() - started, cut: true });
429
+ const stop = new Promise((resolve) => {
430
+ this.abortRun = (reason) => resolve(cut(reason));
431
+ if (this.maxRunMs > 0) {
432
+ const minutes = this.maxRunMs / 60000;
433
+ capTimer = setTimeout(() => this.abortRun(`sync exceeded ${minutes} minute${minutes === 1 ? '' : 's'}, aborted`), this.maxRunMs);
434
+ if (capTimer.unref) capTimer.unref();
435
+ }
436
+ });
437
+ const guarded = body().catch((err) => ({ error: err }));
438
+ return Promise.race([guarded, stop]).then((summary) => {
439
+ clearTimeout(capTimer);
440
+ if (summary.error) throw summary.error;
441
+ if (summary.cut) {
442
+ delete summary.cut;
443
+ this.session.dropCurrent(summary.aborted);
444
+ this.log(`[index] sync stopped: ${summary.aborted}`);
445
+ }
446
+ return summary;
447
+ });
448
+ }
449
+
450
+ /**
451
+ * Abort a running sync so a fresh one can start. Resolves once the run has
452
+ * been released. Returns false when nothing was running.
453
+ */
454
+ async abortSync(reason = 'aborted') {
455
+ if (!this.running) return false;
456
+ if (this.abortRun) this.abortRun(reason);
457
+ try { await this.running; } catch { /* the aborted run's own error is not interesting */ }
458
+ return true;
459
+ }
460
+
380
461
  async _sync({ folders, maxPerFolder, passes }) {
381
462
  const started = Date.now();
382
463
  const summary = { added: 0, removed: 0, folders: {} };
383
464
  let targets;
384
- {
465
+ try {
385
466
  const conn = await this.session.acquire();
386
467
  try {
387
468
  const all = await this._targetFolders(conn);
@@ -390,6 +471,14 @@ export class LocalIndex {
390
471
  } finally {
391
472
  conn.end();
392
473
  }
474
+ } catch (err) {
475
+ if (err.code !== 'IMAP_TIMEOUT') throw err;
476
+ // The folder LIST on a dead connection: the run ends here, recorded like
477
+ // any other timed-out command, and the next scheduled sync reconnects.
478
+ summary.aborted = err.message;
479
+ summary.duration_ms = Date.now() - started;
480
+ this.log(`[index] sync stopped: ${err.message}`);
481
+ return summary;
393
482
  }
394
483
 
395
484
  let pass = 0;
@@ -520,6 +609,7 @@ export class LocalIndex {
520
609
 
521
610
  async _indexBatch(conn, folder, uidvalidity, uids) {
522
611
  const meta = await fetchMessages(conn, uids, { bodies: [HEADER_FIELDS], struct: true, size: true });
612
+ this._assertLock(); // a run cut loose by the cap must not write after the fact
523
613
  const parseHeader = conn.imap.constructor.parseHeader;
524
614
 
525
615
  // Group messages by the exact set of text parts they need, so each group
@@ -539,6 +629,7 @@ export class LocalIndex {
539
629
  const fetched = await fetchMessages(conn, groupUids, { bodies: key.split(',') });
540
630
  for (const [uid, rec] of fetched) bodies.set(uid, rec.parts);
541
631
  }
632
+ this._assertLock();
542
633
 
543
634
  const insMsg = this.db.prepare(`INSERT OR IGNORE INTO messages
544
635
  (folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars)
@@ -601,8 +692,13 @@ export class LocalIndex {
601
692
  db_size_mb: Math.round(sizeBytes / 1024 / 102.4) / 10,
602
693
  this_pid: process.pid,
603
694
  sync_running: !!this.running || lockLive,
695
+ ...(this.running && this.runningSince && {
696
+ sync_running_since: new Date(this.runningSince).toISOString(),
697
+ sync_running_seconds: Math.round((Date.now() - this.runningSince) / 1000)
698
+ }),
604
699
  ...(lockLive && lock.pid !== process.pid && { sync_running_in_pid: lock.pid }),
605
700
  sync_interval_minutes: this.intervalMs / 60000,
701
+ sync_max_minutes: this.maxRunMs / 60000,
606
702
  command_timeout_seconds: getOpTimeout() / 1000,
607
703
  complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
608
704
  last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
@@ -611,6 +707,152 @@ export class LocalIndex {
611
707
  };
612
708
  }
613
709
 
710
+ /** Sync health for a set of folders, shared by search() and findDuplicates(). */
711
+ _indexHealth(folderList = []) {
712
+ const st = this.status();
713
+ const scope = folderList.length ? st.folders.filter((f) => folderList.includes(f.folder)) : st.folders;
714
+ const warnings = [];
715
+ const lastFullMs = Number(this._meta('last_full_sync') || 0);
716
+ if (!lastFullMs) {
717
+ warnings.push('index has never completed a full sync, results may be missing mail');
718
+ } else if (Date.now() - lastFullMs > STALE_AFTER_MS) {
719
+ warnings.push(`index last synced ${describeAge(Date.now() - lastFullMs)} ago, results may be missing recent mail`);
720
+ }
721
+ const errored = scope.filter((f) => f.last_error);
722
+ if (errored.length) {
723
+ warnings.push(`folder error${errored.length === 1 ? '' : 's'}: ${errored.map((f) => `${f.folder} (${f.last_error})`).join(', ')}; results from ${errored.length === 1 ? 'that folder' : 'those folders'} may be incomplete`);
724
+ }
725
+ return {
726
+ complete: scope.length > 0 && scope.every((f) => f.complete && !f.last_error),
727
+ sync_running: st.sync_running,
728
+ oldest_folder_sync: scope.reduce((min, f) => (f.last_sync && (!min || f.last_sync < min) ? f.last_sync : min), null),
729
+ incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder),
730
+ ...(warnings.length && { warning: warnings.join('; ') })
731
+ };
732
+ }
733
+
734
+ /**
735
+ * Find messages that exist more than once, keyed on Message-ID.
736
+ *
737
+ * Message-ID is assigned by the sending server and does not change when mail
738
+ * is moved, so it is the only dependable identity for a message across
739
+ * folders. Matching on subject, sender and timestamp instead produces false
740
+ * pairs on fast back-and-forth threads, where two genuinely different
741
+ * messages can share everything but the second.
742
+ *
743
+ * scope:
744
+ * same_folder one folder holds the message more than once. A filing
745
+ * error: the extra copies carry nothing unique.
746
+ * cross_folder the message sits in more than one folder. Usually a split
747
+ * thread or a stray archive copy. Never resolve these by
748
+ * deleting; deleting one copy only relocates the problem.
749
+ * all either of the above (default).
750
+ *
751
+ * Messages with no Message-ID header cannot be compared and are reported
752
+ * under coverage rather than silently ignored.
753
+ */
754
+ findDuplicates({ folders = null, scope = 'all', since_date = null, before_date = null, limit = 50, offset = 0 } = {}) {
755
+ if (!['all', 'same_folder', 'cross_folder'].includes(scope)) {
756
+ throw new Error(`Unknown scope: ${scope}. Use all, same_folder or cross_folder.`);
757
+ }
758
+ const folderList = Array.isArray(folders) ? folders.filter(Boolean) : (folders ? [folders] : []);
759
+ const where = [];
760
+ const params = [];
761
+ if (folderList.length) {
762
+ where.push(`folder IN (${folderList.map(() => '?').join(', ')})`);
763
+ params.push(...folderList);
764
+ }
765
+ if (since_date) {
766
+ where.push('internal_date >= ?');
767
+ params.push(localDate(since_date).getTime());
768
+ }
769
+ if (before_date) {
770
+ where.push('internal_date < ?');
771
+ params.push(localDate(before_date).getTime());
772
+ }
773
+ const scopeSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
774
+ const hasId = "message_id IS NOT NULL AND trim(message_id) <> ''";
775
+ const andHasId = where.length ? `${scopeSql} AND ${hasId}` : `WHERE ${hasId}`;
776
+
777
+ const inScope = this.db.prepare(`SELECT count(*) AS n FROM messages ${scopeSql}`).get(...params).n;
778
+ const withId = this.db.prepare(`SELECT count(*) AS n FROM messages ${andHasId}`).get(...params).n;
779
+
780
+ // Pre-filter in SQL to the ids that occur more than once, then regroup in
781
+ // JS on the normalized id so bracket differences cannot split a group.
782
+ const key = "trim(replace(replace(message_id, '<', ''), '>', ''))";
783
+ const rows = this.db.prepare(`
784
+ SELECT folder, uid, message_id, subject, from_addr, internal_date, sent_date
785
+ FROM messages
786
+ ${andHasId} AND ${key} IN (
787
+ SELECT ${key} FROM messages ${andHasId} GROUP BY ${key} HAVING count(*) > 1
788
+ )
789
+ ORDER BY internal_date DESC, folder, uid`).all(...params, ...params);
790
+
791
+ const groups = new Map();
792
+ for (const r of rows) {
793
+ const id = normalizeMessageId(r.message_id);
794
+ if (!id) continue;
795
+ if (!groups.has(id)) groups.set(id, []);
796
+ groups.get(id).push(r);
797
+ }
798
+
799
+ const out = [];
800
+ for (const [message_id, copies] of groups) {
801
+ if (copies.length < 2) continue;
802
+ const byFolder = {};
803
+ for (const c of copies) byFolder[c.folder] = (byFolder[c.folder] || 0) + 1;
804
+ const sameFolder = Object.values(byFolder).some((n) => n > 1);
805
+ const crossFolder = Object.keys(byFolder).length > 1;
806
+ if (scope === 'same_folder' && !sameFolder) continue;
807
+ if (scope === 'cross_folder' && !crossFolder) continue;
808
+ const first = copies[0];
809
+ // Redundant copies only ever come from a folder holding the message more
810
+ // than once: within that folder keep the lowest uid and list the rest.
811
+ const redundant = [];
812
+ for (const [folder, n] of Object.entries(byFolder)) {
813
+ if (n < 2) continue;
814
+ const inFolder = copies.filter((c) => c.folder === folder).sort((a, b) => Number(a.uid) - Number(b.uid));
815
+ for (const c of inFolder.slice(1)) redundant.push({ folder, uid: Number(c.uid) });
816
+ }
817
+ out.push({
818
+ message_id,
819
+ subject: first.subject,
820
+ from: first.from_addr,
821
+ date: first.sent_date || (first.internal_date ? new Date(Number(first.internal_date)).toISOString() : null),
822
+ copies: copies.length,
823
+ same_folder_duplicates: sameFolder,
824
+ cross_folder: crossFolder,
825
+ by_folder: byFolder,
826
+ ...(redundant.length && { redundant_copies: redundant }),
827
+ messages: copies.map((c) => ({
828
+ folder: c.folder,
829
+ uid: Number(c.uid),
830
+ date: c.sent_date || (c.internal_date ? new Date(Number(c.internal_date)).toISOString() : null)
831
+ }))
832
+ });
833
+ }
834
+ out.sort((a, b) => (b.copies - a.copies) || String(b.date || '').localeCompare(String(a.date || '')));
835
+ const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
836
+ const skip = Math.max(0, Number(offset) || 0);
837
+ const page = out.slice(skip, skip + cap);
838
+ return {
839
+ scope,
840
+ folders_searched: folderList.length ? folderList : 'all indexed folders',
841
+ coverage: {
842
+ messages_in_scope: inScope,
843
+ with_message_id: withId,
844
+ without_message_id: inScope - withId,
845
+ ...(inScope > withId && { note: 'messages without a Message-ID header cannot be compared and are not included' })
846
+ },
847
+ total_groups: out.length,
848
+ returned: page.length,
849
+ offset: skip,
850
+ has_more: skip + page.length < out.length,
851
+ groups: page,
852
+ index: this._indexHealth(folderList)
853
+ };
854
+ }
855
+
614
856
  search({ query, folders = null, from = null, since_date = null, before_date = null, field = 'any', match = 'all', prefix = false, sort = 'date', limit = 20, offset = 0 }) {
615
857
  const where = ['fts MATCH ?'];
616
858
  const params = [ftsQuery(query, { match, prefix, field })];
@@ -638,37 +880,18 @@ export class LocalIndex {
638
880
 
639
881
  const total = this.db.prepare(`SELECT count(*) AS n FROM fts JOIN messages m ON m.id = fts.rowid WHERE ${whereSql}`).get(...params).n;
640
882
  const rows = this.db.prepare(`
641
- SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments,
883
+ SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments, m.message_id,
642
884
  snippet(fts, -1, '[', ']', '...', 16) AS snippet
643
885
  FROM fts JOIN messages m ON m.id = fts.rowid
644
886
  WHERE ${whereSql}
645
887
  ORDER BY ${order}
646
888
  LIMIT ? OFFSET ?`).all(...params, cap, skip);
647
889
 
648
- const st = this.status();
649
- const scope = folderList.length ? st.folders.filter((f) => folderList.includes(f.folder)) : st.folders;
650
- const warnings = [];
651
- const lastFullMs = Number(this._meta('last_full_sync') || 0);
652
- if (!lastFullMs) {
653
- warnings.push('index has never completed a full sync, results may be missing mail');
654
- } else if (Date.now() - lastFullMs > STALE_AFTER_MS) {
655
- warnings.push(`index last synced ${describeAge(Date.now() - lastFullMs)} ago, results may be missing recent mail`);
656
- }
657
- const errored = scope.filter((f) => f.last_error);
658
- if (errored.length) {
659
- warnings.push(`folder error${errored.length === 1 ? '' : 's'}: ${errored.map((f) => `${f.folder} (${f.last_error})`).join(', ')}; results from ${errored.length === 1 ? 'that folder' : 'those folders'} may be incomplete`);
660
- }
661
890
  return {
662
891
  total_matches: total,
663
892
  returned: rows.length,
664
893
  offset: skip,
665
- index: {
666
- complete: scope.length > 0 && scope.every((f) => f.complete && !f.last_error),
667
- sync_running: st.sync_running,
668
- oldest_folder_sync: scope.reduce((min, f) => (f.last_sync && (!min || f.last_sync < min) ? f.last_sync : min), null),
669
- incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder),
670
- ...(warnings.length && { warning: warnings.join('; ') })
671
- },
894
+ index: this._indexHealth(folderList),
672
895
  results: rows.map((r) => ({
673
896
  folder: r.folder,
674
897
  uid: Number(r.uid),
@@ -676,6 +899,7 @@ export class LocalIndex {
676
899
  from: r.from_addr,
677
900
  to: r.to_addr,
678
901
  subject: r.subject,
902
+ message_id: normalizeMessageId(r.message_id) || undefined,
679
903
  attachments: r.attachments || undefined,
680
904
  snippet: r.snippet
681
905
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.7.4",
3
+ "version": "1.8.1",
4
4
  "description": "MCP server for Claude Code that provides email capabilities through IMAP/SMTP. Read, search, compose, and manage emails from any IMAP provider.",
5
5
  "type": "module",
6
6
  "main": "index.js",