@lengelhard/imap-email-mcp 1.9.0 → 1.9.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.2
4
+
5
+ ### Fixed
6
+ - `move_email` and `move_thread` no longer create a duplicate when the destination already holds the message. A mail client that files by label can leave the same message in INBOX and in the label's folder at once; moving the INBOX copy then held it twice in the destination (seen on 25 Sep 2026 after a scheduled inbox sweep). The move now checks the destination for the same Message-ID first: if found, the source copy is removed instead of copied, and the response says `action: "already_present"` (`move_email`) or lists `already_present` (`move_thread`). A message with no Message-ID is moved the plain way.
7
+ - `references` is no longer returned by `list_emails`, `search_emails` and `search_index`. On long threads it was 20 to 30 Message-IDs per message, several times the size of the rest of the result, and the tasks that read listings never use it. It stays on `get_email`, and `find_split_threads` threads on it as before. `in_reply_to` stays everywhere.
8
+
9
+ ## 1.9.1
10
+
11
+ ### Fixed
12
+ - The threading-header backfill no longer runs inside each folder's sync. On a server that reads In-Reply-To and References from disk at a few messages per second (seen on Migadu: about 3 per second on a large Archive), the 3,000-row slice took longer than the run cap, so every scheduled run was cut off in Archive and the folders after it were never synced. The backfill now runs once per scheduled run, after every folder has had its normal sync and `last_full_sync` has been stamped, under its own time budget (`INDEX_BACKFILL_SECONDS`, default 90, `0` disables). Folders with the fewest pending rows go first, so the working folders reach full coverage long before a large Archive. `sync_index` and refresh-triggered syncs never backfill, so they stay quick.
13
+ - `last_run` records `thread_headers_backfilled` for each scheduled run.
14
+
3
15
  ## 1.9.0
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -107,6 +107,7 @@ Add to your config file:
107
107
  | `INDEX_EXCLUDE_FOLDERS` | none | Comma-separated folders to skip, on top of Trash/Junk |
108
108
  | `INDEX_SYNC_INTERVAL_MINUTES` | `5` | Background sync interval, measured from the end of the previous run |
109
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) |
110
+ | `INDEX_BACKFILL_SECONDS` | `90` | Time budget per scheduled run for backfilling threading headers on rows indexed before 1.9.0 (`0` disables) |
110
111
 
111
112
  ### Local search index
112
113
 
@@ -136,7 +137,7 @@ A conversation can end up spread across folders without any message being duplic
136
137
  - **Why headers, not subject.** References lists the Message-IDs of every earlier message in the conversation, so a reply keeps its thread even after someone renames the subject, and two unrelated conversations with the same subject line stay apart. Subject matching gets both of those wrong.
137
138
  - **Scope.** `scope: "split"` (default) reports only threads present in more than one of the searched folders; `scope: "all"` lists every thread. The folders you pass decide what counts as split: a thread with three messages in Delegated and two in Hold is split when both folders are searched and not when only Delegated is.
138
139
  - **Copies are not splits.** A thread whose only spread is the same Message-ID present in two folders is marked `copies_only`. That is a duplicate, and `find_duplicates` is the tool for it.
139
- - **Honest coverage.** Rows indexed before 1.9.0 have no threading headers until the background sync backfills them (3,000 rows per folder per run). `coverage.without_thread_headers` and `index_status.thread_headers_pending` show how many are still waiting, and a thread touching such rows is marked `threading: "partial"`.
140
+ - **Honest coverage.** Rows indexed before 1.9.0 have no threading headers until the background sync backfills them, which it does at the end of each scheduled run for up to `INDEX_BACKFILL_SECONDS`, smallest folders first, without ever delaying the indexing of new mail. `coverage.without_thread_headers` and `index_status.thread_headers_pending` show how many are still waiting, and a thread touching such rows is marked `threading: "partial"`.
140
141
  - **Nothing is moved.** The tool only reports. `move_email` or `move_thread` are the tools for acting on it.
141
142
 
142
143
  ### Provider Settings
@@ -162,6 +163,8 @@ A conversation can end up spread across folders without any message being duplic
162
163
  | `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
163
164
  | `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
164
165
  | `find_split_threads` | Conversations spread across folders, threaded on In-Reply-To and References so renamed replies stay with their thread |
166
+ | `move_email` | Move one message by UID. If the destination already holds it (same Message-ID) the source copy is removed instead, never duplicated |
167
+ | `move_thread` | Move every message of a thread by subject in one call, with the same duplicate-safe behaviour |
165
168
  | `list_drafts` | List all draft emails |
166
169
  | `get_draft` | Get a specific draft by UID |
167
170
  | `create_draft` | Create a new email draft |
package/index.js CHANGED
@@ -23,6 +23,7 @@ import os from 'os';
23
23
  import { createRequire } from 'module';
24
24
  import {
25
25
  setOpTimeout,
26
+ withTimeout,
26
27
  SessionManager,
27
28
  openBox,
28
29
  queryUids,
@@ -787,7 +788,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
787
788
  },
788
789
  {
789
790
  name: 'move_email',
790
- description: 'Move an email from one folder to another (e.g. Inbox → Action, Waiting, Delegated, Hold)',
791
+ description: 'Move an email from one folder to another (e.g. Inbox → Action, Waiting, Delegated, Hold). If the destination already holds the same message (same Message-ID, as when a mail client filed it there by label), the source copy is removed instead of copied and the response says action: "already_present", so a move never creates a duplicate.',
791
792
  inputSchema: {
792
793
  type: 'object',
793
794
  properties: {
@@ -856,7 +857,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
856
857
  },
857
858
  {
858
859
  name: 'move_thread',
859
- description: 'Move an entire email thread (all messages matching a subject) from one folder to another in a single call. Strips Re:/Fwd: prefixes for matching. Use move_email for single-message precision moves.',
860
+ description: 'Move an entire email thread (all messages matching a subject) from one folder to another in a single call. Strips Re:/Fwd: prefixes for matching. Messages the destination already holds (same Message-ID) are removed from the source instead of copied, reported under already_present, so a move never creates a duplicate. Use move_email for single-message precision moves.',
860
861
  inputSchema: {
861
862
  type: 'object',
862
863
  properties: {
@@ -910,7 +911,67 @@ async function connectIMAP() {
910
911
  return await sessions.acquire();
911
912
  }
912
913
 
913
- const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)';
914
+ /**
915
+ * Move UIDs from the open folder `fromFolder` to `toFolder` without creating a
916
+ * second copy. A mail client that files by label can leave the same message in
917
+ * both folders already (the INBOX copy of a message also tagged "Deal Flow"),
918
+ * and a plain MOVE would then hold it twice in the destination. When the
919
+ * destination already has a message with the same Message-ID, the source copy
920
+ * is removed instead of copied, which is what a move means anyway.
921
+ * Returns one entry per uid: { uid, action: 'moved' | 'already_present' | 'failed', ... }.
922
+ * Leaves `fromFolder` open when done.
923
+ */
924
+ async function moveWithoutDuplicates(connection, fromFolder, uids, toFolder) {
925
+ const parseHeader = connection.imap.constructor.parseHeader;
926
+ const idsByUid = new Map();
927
+ try {
928
+ const fetched = await fetchMessages(connection, uids, { bodies: ['HEADER.FIELDS (MESSAGE-ID)'] });
929
+ for (const [uid, rec] of fetched) {
930
+ const id = normalizeMessageId(headerOf(rec, parseHeader)['message-id']?.[0]);
931
+ if (id) idsByUid.set(Number(uid), id);
932
+ }
933
+ } catch { /* no ids: every uid is moved the plain way */ }
934
+
935
+ // Which of these Message-IDs does the destination already hold?
936
+ const present = new Map();
937
+ if (idsByUid.size) {
938
+ try {
939
+ await openBox(connection, toFolder, true);
940
+ for (const [uid, id] of idsByUid) {
941
+ const found = await queryUids(connection, [['HEADER', 'MESSAGE-ID', id]]);
942
+ if (found.length) present.set(uid, found[0]);
943
+ }
944
+ } catch { /* destination unreadable: fall through to plain moves, the MOVE itself will report */ }
945
+ await openBox(connection, fromFolder, false);
946
+ }
947
+
948
+ const results = [];
949
+ for (const uid of uids) {
950
+ try {
951
+ if (present.has(uid)) {
952
+ await withTimeout(connection, `remove ${uid}`, new Promise((resolve, reject) => {
953
+ connection.imap.addFlags(uid, ['\\Deleted'], (err) => {
954
+ if (err) return reject(err);
955
+ connection.imap.expunge(uid, (e) => (e ? reject(e) : resolve()));
956
+ });
957
+ }));
958
+ results.push({ uid, action: 'already_present', existing_uid: present.get(uid), message_id: idsByUid.get(uid) });
959
+ } else {
960
+ await withTimeout(connection, `move ${uid}`, new Promise((resolve, reject) => {
961
+ connection.imap.move(uid, toFolder, (err) => (err ? reject(err) : resolve()));
962
+ }));
963
+ results.push({ uid, action: 'moved' });
964
+ }
965
+ } catch (err) {
966
+ results.push({ uid, action: 'failed', error: err.message });
967
+ }
968
+ }
969
+ return results;
970
+ }
971
+
972
+ // References is not fetched here: on long threads it is 20 to 30 ids per message and
973
+ // listings are read by tasks that never use it. get_email and find_split_threads carry it.
974
+ const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO)';
914
975
  const MAX_LIMIT = 200;
915
976
 
916
977
  function clampLimit(value, fallback = 20) {
@@ -941,8 +1002,6 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
941
1002
  if (messageId) item.message_id = messageId;
942
1003
  const inReplyTo = parseMessageIdList(h['in-reply-to'])[0];
943
1004
  if (inReplyTo) item.in_reply_to = inReplyTo;
944
- const references = parseMessageIdList(h.references);
945
- if (references.length) item.references = references;
946
1005
  if (withFlags) item.flags = rec.attributes.flags;
947
1006
  out.push(item);
948
1007
  }
@@ -1787,12 +1846,8 @@ async function handleTool(name, args) {
1787
1846
  try {
1788
1847
  await connection.openBox(fromFolder);
1789
1848
 
1790
- await new Promise((resolve, reject) => {
1791
- connection.imap.move(args.uid, toFolder, (err) => {
1792
- if (err) reject(err);
1793
- else resolve();
1794
- });
1795
- });
1849
+ const [r] = await moveWithoutDuplicates(connection, fromFolder, [Number(args.uid)], toFolder);
1850
+ if (r.action === 'failed') throw new Error(r.error);
1796
1851
 
1797
1852
  return {
1798
1853
  content: [{
@@ -1801,7 +1856,12 @@ async function handleTool(name, args) {
1801
1856
  success: true,
1802
1857
  uid: args.uid,
1803
1858
  from: fromFolder,
1804
- to: toFolder
1859
+ to: toFolder,
1860
+ action: r.action,
1861
+ ...(r.action === 'already_present' && {
1862
+ existing_uid: r.existing_uid,
1863
+ note: `${toFolder} already held this message (same Message-ID) as uid ${r.existing_uid}; the ${fromFolder} copy was removed instead of copied, so it is not duplicated.`
1864
+ })
1805
1865
  }, null, 2)
1806
1866
  }]
1807
1867
  };
@@ -1940,22 +2000,12 @@ async function handleTool(name, args) {
1940
2000
  };
1941
2001
  }
1942
2002
 
1943
- // Move each matching UID using the same imap.move() pattern as move_email
1944
- let moved = 0;
1945
- const failures = [];
1946
- for (const uid of matchingUIDs) {
1947
- try {
1948
- await new Promise((resolve, reject) => {
1949
- connection.imap.move(uid, to_folder, (err) => {
1950
- if (err) reject(err);
1951
- else resolve();
1952
- });
1953
- });
1954
- moved++;
1955
- } catch (moveErr) {
1956
- failures.push({ uid, error: moveErr.message });
1957
- }
1958
- }
2003
+ // Move each matching UID; a message the destination already holds is
2004
+ // removed from the source instead of copied a second time.
2005
+ const results = await moveWithoutDuplicates(connection, from_folder, matchingUIDs.map(Number), to_folder);
2006
+ const moved = results.filter((r) => r.action === 'moved').length;
2007
+ const alreadyPresent = results.filter((r) => r.action === 'already_present');
2008
+ const failures = results.filter((r) => r.action === 'failed').map((r) => ({ uid: r.uid, error: r.error }));
1959
2009
 
1960
2010
  return {
1961
2011
  content: [{
@@ -1963,11 +2013,16 @@ async function handleTool(name, args) {
1963
2013
  text: JSON.stringify({
1964
2014
  success: failures.length === 0,
1965
2015
  moved,
2016
+ already_present: alreadyPresent.length,
1966
2017
  failed: failures.length,
1967
2018
  total_found: matchingUIDs.length,
1968
2019
  from_folder,
1969
2020
  to_folder,
1970
2021
  subject: normalizedTarget,
2022
+ ...(alreadyPresent.length > 0 && {
2023
+ already_present_uids: alreadyPresent.map((r) => ({ uid: r.uid, existing_uid: r.existing_uid })),
2024
+ note: `${alreadyPresent.length} message(s) were already in ${to_folder} (same Message-ID); their ${from_folder} copies were removed instead of copied, so nothing is duplicated.`
2025
+ }),
1971
2026
  ...(failures.length > 0 && { failures })
1972
2027
  }, null, 2)
1973
2028
  }]
@@ -2142,7 +2197,8 @@ async function main() {
2142
2197
  folders: list(process.env.INDEX_FOLDERS),
2143
2198
  excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
2144
2199
  intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5'),
2145
- maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15')
2200
+ maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15'),
2201
+ backfillSeconds: parseFloat(process.env.INDEX_BACKFILL_SECONDS || '90')
2146
2202
  }).open();
2147
2203
  localIndex.start();
2148
2204
  console.error(`Local index enabled: ${localIndex.dbPath}`);
@@ -57,7 +57,9 @@ function pidAlive(pid) {
57
57
  }
58
58
  }
59
59
  const HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)';
60
- const THREAD_BACKFILL_PER_FOLDER = 3000;
60
+ const DEFAULT_BACKFILL_SECONDS = 90;
61
+ // Small batches for the backfill: on a slow server a 100-row header fetch has taken longer than the command timeout.
62
+ const BACKFILL_BATCH = 25;
61
63
  const DEFAULT_EXCLUDE = ['trash', 'junk', 'spam', 'deleted items', 'deleted messages', 'bin'];
62
64
  const SPECIAL_EXCLUDE = ['\\trash', '\\junk'];
63
65
 
@@ -152,7 +154,7 @@ export function parseMessageIdList(value) {
152
154
  }
153
155
 
154
156
  export class LocalIndex {
155
- constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, maxRunMinutes = DEFAULT_MAX_RUN_MINUTES, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
157
+ constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, maxRunMinutes = DEFAULT_MAX_RUN_MINUTES, backfillSeconds = DEFAULT_BACKFILL_SECONDS, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
156
158
  this.imapConfig = imapConfig;
157
159
  this.dbPath = dbPath || defaultDbPath(imapConfig.imap.user, imapConfig.imap.host);
158
160
  this.folders = folders && folders.length ? folders : null;
@@ -161,6 +163,9 @@ export class LocalIndex {
161
163
  // Wall-clock cap on one sync run. 0 disables it. Fractions are allowed for tests.
162
164
  const cap = Number(maxRunMinutes);
163
165
  this.maxRunMs = Number.isFinite(cap) && cap >= 0 ? cap * 60 * 1000 : DEFAULT_MAX_RUN_MINUTES * 60 * 1000;
166
+ // Time budget for the threading-header backfill at the end of each scheduled run. 0 disables it.
167
+ const bf = Number(backfillSeconds);
168
+ this.backfillMs = Number.isFinite(bf) && bf >= 0 ? bf * 1000 : DEFAULT_BACKFILL_SECONDS * 1000;
164
169
  this.batchSize = batchSize;
165
170
  this.maxBodyChars = maxBodyChars;
166
171
  this.log = log;
@@ -248,7 +253,7 @@ export class LocalIndex {
248
253
  const kick = async () => {
249
254
  try {
250
255
  const last = Number(this._meta('last_full_sync') || 0);
251
- if (Date.now() - last >= this.intervalMs / 2) await this.sync();
256
+ if (Date.now() - last >= this.intervalMs / 2) await this.sync({ backfill: true });
252
257
  } catch (err) {
253
258
  this.log(`[index] sync failed: ${err.message}`);
254
259
  } finally {
@@ -394,7 +399,7 @@ export class LocalIndex {
394
399
  * Run a sync. Single-flight: concurrent callers share the running sync.
395
400
  * maxPerFolder caps new messages per folder per pass; passes caps the loop.
396
401
  */
397
- sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
402
+ sync({ folders = null, maxPerFolder = 1000, passes = Infinity, backfill = false } = {}) {
398
403
  if (this.running) return this.running;
399
404
  this.runningSince = Date.now();
400
405
  this.running = (async () => {
@@ -417,7 +422,7 @@ export class LocalIndex {
417
422
  }, LOCK_HEARTBEAT_MS);
418
423
  if (heartbeat.unref) heartbeat.unref();
419
424
  try {
420
- const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes }));
425
+ const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes, backfill }));
421
426
  if (this.db) {
422
427
  if (!folders && passes === Infinity && !summary.aborted) this._setMeta('last_full_sync', Date.now());
423
428
  this._setMeta('last_run', JSON.stringify({
@@ -427,6 +432,7 @@ export class LocalIndex {
427
432
  added: summary.added,
428
433
  removed: summary.removed,
429
434
  duration_ms: summary.duration_ms,
435
+ ...(summary.backfilled != null && { thread_headers_backfilled: summary.backfilled }),
430
436
  ...(summary.aborted && { aborted: summary.aborted })
431
437
  }));
432
438
  }
@@ -482,7 +488,7 @@ export class LocalIndex {
482
488
  return true;
483
489
  }
484
490
 
485
- async _sync({ folders, maxPerFolder, passes }) {
491
+ async _sync({ folders, maxPerFolder, passes, backfill = false }) {
486
492
  const started = Date.now();
487
493
  const summary = { added: 0, removed: 0, folders: {} };
488
494
  let targets;
@@ -551,6 +557,21 @@ export class LocalIndex {
551
557
  }
552
558
  }
553
559
  }
560
+ // The index is current at this point; last_full_sync is stamped on it by
561
+ // the caller. The threading-header backfill runs after that, under its
562
+ // own time budget, so it can never hold up the indexing of new mail.
563
+ if (backfill && !summary.aborted && this.backfillMs > 0) {
564
+ try {
565
+ summary.backfilled = await this._backfillThreadHeaders(targets, Date.now() + this.backfillMs);
566
+ } catch (err) {
567
+ if (err.code === 'IMAP_TIMEOUT' || err.code === 'LOCK_LOST' || !this.db) {
568
+ summary.aborted = err.message;
569
+ this.log(`[index] backfill stopped: ${err.message}`);
570
+ } else {
571
+ this.log(`[index] backfill: ${err.message}`);
572
+ }
573
+ }
574
+ }
554
575
  summary.duration_ms = Date.now() - started;
555
576
  return summary;
556
577
  }
@@ -617,8 +638,6 @@ export class LocalIndex {
617
638
  added += await this._indexBatch(conn, folder, uidvalidity, todo.slice(i, i + this.batchSize));
618
639
  }
619
640
 
620
- await this._backfillThreadHeaders(conn, folder);
621
-
622
641
  const pending = missing.length - todo.length;
623
642
  const indexed = this.db.prepare('SELECT count(*) AS n FROM messages WHERE folder = ?').get(folder).n;
624
643
  this.db.prepare(`INSERT INTO folders (name, uidvalidity, server_count, indexed_count, backfill_complete, last_sync, last_error)
@@ -635,40 +654,56 @@ export class LocalIndex {
635
654
 
636
655
  /**
637
656
  * Fetch In-Reply-To and References for rows indexed before 1.9.0, headers
638
- * only, in batches, up to THREAD_BACKFILL_PER_FOLDER rows per run so a large
639
- * Archive is spread over a few syncs rather than one long one.
657
+ * only, in batches, until the deadline. Some servers read these headers
658
+ * from disk at a few messages per second, so the work is spread over many
659
+ * scheduled runs. Folders with the fewest pending rows go first, so the
660
+ * small working folders reach full coverage long before a large Archive.
640
661
  */
641
- async _backfillThreadHeaders(conn, folder) {
642
- const rows = this.db.prepare('SELECT uid FROM messages WHERE folder = ? AND thread_headers = 0 ORDER BY uid DESC LIMIT ?')
643
- .all(folder, THREAD_BACKFILL_PER_FOLDER);
644
- if (!rows.length) return 0;
645
- const parseHeader = conn.imap.constructor.parseHeader;
662
+ async _backfillThreadHeaders(folders, deadline) {
663
+ const counts = this.db.prepare('SELECT folder, count(*) AS n FROM messages WHERE thread_headers = 0 GROUP BY folder ORDER BY n ASC').all()
664
+ .filter((r) => folders.includes(r.folder));
665
+ if (!counts.length) return 0;
646
666
  const upd = this.db.prepare('UPDATE messages SET in_reply_to = ?, references_ids = ?, thread_headers = 1 WHERE folder = ? AND uid = ?');
647
- let done = 0;
648
- for (let i = 0; i < rows.length; i += this.batchSize) {
667
+ let total = 0;
668
+ for (const { folder } of counts) {
669
+ if (Date.now() >= deadline) break;
649
670
  this._assertLock();
650
- this.progressAt = Date.now();
651
- const uids = rows.slice(i, i + this.batchSize).map((r) => Number(r.uid));
652
- const fetched = await fetchMessages(conn, uids, { bodies: ['HEADER.FIELDS (IN-REPLY-TO REFERENCES)'] });
653
- this._assertLock();
654
- this.db.exec('BEGIN');
671
+ const conn = await this.session.acquire();
672
+ let done = 0;
655
673
  try {
656
- for (const uid of uids) {
657
- const rec = fetched.get(uid);
658
- // A uid the server no longer has is marked done; the next set
659
- // difference removes the row.
660
- const h = rec ? headerOf(rec, parseHeader) : {};
661
- upd.run(parseMessageIdList(h['in-reply-to'])[0] || null, parseMessageIdList(h.references).join(' ') || null, folder, uid);
662
- done++;
674
+ await openBox(conn, folder, true);
675
+ const parseHeader = conn.imap.constructor.parseHeader;
676
+ while (Date.now() < deadline) {
677
+ this._assertLock();
678
+ this.progressAt = Date.now();
679
+ const uids = this.db.prepare('SELECT uid FROM messages WHERE folder = ? AND thread_headers = 0 ORDER BY uid DESC LIMIT ?')
680
+ .all(folder, Math.min(this.batchSize, BACKFILL_BATCH)).map((r) => Number(r.uid));
681
+ if (!uids.length) break;
682
+ const fetched = await fetchMessages(conn, uids, { bodies: ['HEADER.FIELDS (IN-REPLY-TO REFERENCES)'] });
683
+ this._assertLock();
684
+ this.db.exec('BEGIN');
685
+ try {
686
+ for (const uid of uids) {
687
+ const rec = fetched.get(uid);
688
+ // A uid the server no longer has is marked done; the next set
689
+ // difference removes the row.
690
+ const h = rec ? headerOf(rec, parseHeader) : {};
691
+ upd.run(parseMessageIdList(h['in-reply-to'])[0] || null, parseMessageIdList(h.references).join(' ') || null, folder, uid);
692
+ done++;
693
+ }
694
+ this.db.exec('COMMIT');
695
+ } catch (err) {
696
+ this.db.exec('ROLLBACK');
697
+ throw err;
698
+ }
663
699
  }
664
- this.db.exec('COMMIT');
665
- } catch (err) {
666
- this.db.exec('ROLLBACK');
667
- throw err;
700
+ } finally {
701
+ conn.end();
702
+ if (done) this.log(`[index] ${folder}: threading headers backfilled for ${done} message(s)`);
703
+ total += done;
668
704
  }
669
705
  }
670
- if (done) this.log(`[index] ${folder}: threading headers backfilled for ${done} message(s)`);
671
- return done;
706
+ return total;
672
707
  }
673
708
 
674
709
  async _indexBatch(conn, folder, uidvalidity, uids) {
@@ -1071,7 +1106,7 @@ export class LocalIndex {
1071
1106
  const total = this.db.prepare(`SELECT count(*) AS n FROM fts JOIN messages m ON m.id = fts.rowid WHERE ${whereSql}`).get(...params).n;
1072
1107
  const rows = this.db.prepare(`
1073
1108
  SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments, m.message_id,
1074
- m.in_reply_to, m.references_ids,
1109
+ m.in_reply_to,
1075
1110
  snippet(fts, -1, '[', ']', '...', 16) AS snippet
1076
1111
  FROM fts JOIN messages m ON m.id = fts.rowid
1077
1112
  WHERE ${whereSql}
@@ -1092,7 +1127,6 @@ export class LocalIndex {
1092
1127
  subject: r.subject,
1093
1128
  message_id: normalizeMessageId(r.message_id) || undefined,
1094
1129
  in_reply_to: r.in_reply_to || undefined,
1095
- references: r.references_ids ? r.references_ids.split(' ') : undefined,
1096
1130
  attachments: r.attachments || undefined,
1097
1131
  snippet: r.snippet
1098
1132
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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",