@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 +6 -0
- package/README.md +2 -1
- package/index.js +2 -1
- package/lib/local-index.js +70 -35
- package/package.json +1 -1
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
|
|
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}`);
|
package/lib/local-index.js
CHANGED
|
@@ -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
|
|
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,
|
|
639
|
-
*
|
|
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(
|
|
642
|
-
const
|
|
643
|
-
.
|
|
644
|
-
if (!
|
|
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
|
|
648
|
-
for (
|
|
667
|
+
let total = 0;
|
|
668
|
+
for (const { folder } of counts) {
|
|
669
|
+
if (Date.now() >= deadline) break;
|
|
649
670
|
this._assertLock();
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
this.
|
|
667
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|