@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 +12 -0
- package/README.md +4 -1
- package/index.js +85 -29
- package/lib/local-index.js +71 -37
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
1791
|
-
|
|
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
|
|
1944
|
-
|
|
1945
|
-
const
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
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}`);
|
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) {
|
|
@@ -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,
|
|
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.
|
|
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",
|