@lengelhard/imap-email-mcp 1.9.0 → 1.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.1
4
+
5
+ ### Fixed
6
+ - 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.
7
+ - `last_run` records `thread_headers_backfilled` for each scheduled run.
8
+
3
9
  ## 1.9.0
4
10
 
5
11
  ### 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
package/index.js CHANGED
@@ -2142,7 +2142,8 @@ async function main() {
2142
2142
  folders: list(process.env.INDEX_FOLDERS),
2143
2143
  excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
2144
2144
  intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5'),
2145
- maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15')
2145
+ maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15'),
2146
+ backfillSeconds: parseFloat(process.env.INDEX_BACKFILL_SECONDS || '90')
2146
2147
  }).open();
2147
2148
  localIndex.start();
2148
2149
  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) {
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.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",