@lengelhard/imap-email-mcp 1.8.1 → 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 +16 -0
- package/README.md +12 -0
- package/index.js +86 -4
- package/lib/local-index.js +237 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
9
|
+
## 1.9.0
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `find_split_threads`: lists conversations whose messages sit in more than one folder, threaded on the In-Reply-To and References headers rather than on subject. A reply that was renamed ("Onboarding - Accrete" becoming "Onboarding - Accrete: data room request list") stays in its thread because its References still chain back to the root, and two unrelated conversations that happen to share a subject ("Catch-up call") never merge. `scope: "split"` (the default) reports only threads spread across the searched folders; `scope: "all"` lists every thread in scope. Each thread carries `messages`, `distinct_messages`, `by_folder`, `oldest`, `newest`, `participants` and its `message_list`; a thread whose only spread is the same Message-ID copied into two folders is marked `copies_only`, which is a duplicate for `find_duplicates`, not a split. Paged with `limit` and `offset`. Requires the local index.
|
|
13
|
+
- `in_reply_to` and `references` on the results of `list_emails`, `search_emails`, `search_index` and `get_email`, angle brackets stripped, folded headers unfolded, oldest first.
|
|
14
|
+
- The index now stores In-Reply-To and References. Rows indexed by an earlier version are backfilled by the background sync, up to 3,000 rows per folder per run, without a rebuild. `index_status` reports the number still waiting as `thread_headers_pending`, and `find_split_threads` reports it under `coverage.without_thread_headers` and marks any thread that touches such rows `threading: "partial"`, so a report on a half-backfilled index says so instead of looking complete.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Opening a database written by 1.8.x adds the three threading columns in place. Searches keep working while the backfill runs.
|
|
18
|
+
|
|
3
19
|
## 1.8.1
|
|
4
20
|
|
|
5
21
|
### Fixed
|
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
|
|
|
@@ -129,6 +130,16 @@ Mail clients that file by tag sometimes append a fresh copy of a message instead
|
|
|
129
130
|
- **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
131
|
- **Nothing is deleted.** The tool only reports. Acting on it means calling `delete_email`, which moves to Trash unless you pass `expunge: true`.
|
|
131
132
|
|
|
133
|
+
### Finding split threads
|
|
134
|
+
|
|
135
|
+
A conversation can end up spread across folders without any message being duplicated: the root is filed in one place, a later reply in another. `find_split_threads` finds those by following the In-Reply-To and References headers, which every mail client writes when replying.
|
|
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.
|
|
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.
|
|
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.
|
|
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"`.
|
|
141
|
+
- **Nothing is moved.** The tool only reports. `move_email` or `move_thread` are the tools for acting on it.
|
|
142
|
+
|
|
132
143
|
### Provider Settings
|
|
133
144
|
|
|
134
145
|
| Provider | IMAP_HOST | SMTP_HOST | Notes |
|
|
@@ -151,6 +162,7 @@ Mail clients that file by tag sometimes append a fresh copy of a message instead
|
|
|
151
162
|
| `index_status` | Per-folder index coverage, last sync and errors; `sync_running_seconds` shows how long the current sync has run |
|
|
152
163
|
| `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
|
|
153
164
|
| `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
|
|
165
|
+
| `find_split_threads` | Conversations spread across folders, threaded on In-Reply-To and References so renamed replies stay with their thread |
|
|
154
166
|
| `list_drafts` | List all draft emails |
|
|
155
167
|
| `get_draft` | Get a specific draft by UID |
|
|
156
168
|
| `create_draft` | Create a new email draft |
|
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, normalizeMessageId } from './lib/local-index.js';
|
|
34
|
+
import { LocalIndex, normalizeMessageId, parseMessageIdList } from './lib/local-index.js';
|
|
35
35
|
|
|
36
36
|
const PKG_VERSION = createRequire(import.meta.url)('./package.json').version;
|
|
37
37
|
|
|
@@ -445,9 +445,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
445
445
|
required: ['query']
|
|
446
446
|
}
|
|
447
447
|
},
|
|
448
|
+
{
|
|
449
|
+
name: 'find_split_threads',
|
|
450
|
+
description: 'List conversations whose messages sit in more than one folder, threaded by Message-ID chains (In-Reply-To and References), the same way a mail client threads. Exact where the headers exist: a renamed subject stays in its thread and two conversations that share a subject never merge. A thread flagged copies_only is the same message copied into several folders, a duplicate rather than a split; see find_duplicates for those. Check coverage.without_thread_headers: messages indexed before 1.9.0 thread only by their own Message-ID until the backfill reaches them. Requires the local index.',
|
|
451
|
+
inputSchema: {
|
|
452
|
+
type: 'object',
|
|
453
|
+
properties: {
|
|
454
|
+
folders: {
|
|
455
|
+
type: 'array',
|
|
456
|
+
items: { type: 'string' },
|
|
457
|
+
description: 'Only these folders (default: all indexed folders). A thread counts as split only across folders in this list.'
|
|
458
|
+
},
|
|
459
|
+
scope: {
|
|
460
|
+
type: 'string',
|
|
461
|
+
enum: ['split', 'all'],
|
|
462
|
+
description: 'split: threads spanning more than one folder (default); all: every thread',
|
|
463
|
+
default: 'split'
|
|
464
|
+
},
|
|
465
|
+
since_date: {
|
|
466
|
+
type: 'string',
|
|
467
|
+
description: 'Only messages delivered on or after this date (YYYY-MM-DD)'
|
|
468
|
+
},
|
|
469
|
+
before_date: {
|
|
470
|
+
type: 'string',
|
|
471
|
+
description: 'Only messages delivered before this date (YYYY-MM-DD)'
|
|
472
|
+
},
|
|
473
|
+
limit: {
|
|
474
|
+
type: 'number',
|
|
475
|
+
description: 'Maximum threads to return (default: 50, max: 500). total_threads reports the full count.',
|
|
476
|
+
default: 50
|
|
477
|
+
},
|
|
478
|
+
offset: {
|
|
479
|
+
type: 'number',
|
|
480
|
+
description: 'Skip this many threads, for paging (default: 0). has_more says whether another page exists.',
|
|
481
|
+
default: 0
|
|
482
|
+
},
|
|
483
|
+
refresh: {
|
|
484
|
+
type: 'boolean',
|
|
485
|
+
description: 'Pick up new and moved mail before searching (default: true). Skipped while a background sync is running.',
|
|
486
|
+
default: true
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
required: []
|
|
490
|
+
}
|
|
491
|
+
},
|
|
448
492
|
{
|
|
449
493
|
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. 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.',
|
|
494
|
+
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. thread_headers_pending counts messages still waiting for their threading headers (1.9.0 backfill). 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
495
|
inputSchema: { type: 'object', properties: {}, required: [] }
|
|
452
496
|
},
|
|
453
497
|
{
|
|
@@ -866,7 +910,7 @@ async function connectIMAP() {
|
|
|
866
910
|
return await sessions.acquire();
|
|
867
911
|
}
|
|
868
912
|
|
|
869
|
-
const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
|
|
913
|
+
const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)';
|
|
870
914
|
const MAX_LIMIT = 200;
|
|
871
915
|
|
|
872
916
|
function clampLimit(value, fallback = 20) {
|
|
@@ -895,6 +939,10 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
|
|
|
895
939
|
if (h.cc?.length) item.cc = h.cc.join(', ');
|
|
896
940
|
const messageId = normalizeMessageId(h['message-id']?.[0]);
|
|
897
941
|
if (messageId) item.message_id = messageId;
|
|
942
|
+
const inReplyTo = parseMessageIdList(h['in-reply-to'])[0];
|
|
943
|
+
if (inReplyTo) item.in_reply_to = inReplyTo;
|
|
944
|
+
const references = parseMessageIdList(h.references);
|
|
945
|
+
if (references.length) item.references = references;
|
|
898
946
|
if (withFlags) item.flags = rec.attributes.flags;
|
|
899
947
|
out.push(item);
|
|
900
948
|
}
|
|
@@ -1074,6 +1122,8 @@ async function handleTool(name, args) {
|
|
|
1074
1122
|
subject: parsed.subject,
|
|
1075
1123
|
date: parsed.date,
|
|
1076
1124
|
...(normalizeMessageId(parsed.messageId) && { message_id: normalizeMessageId(parsed.messageId) }),
|
|
1125
|
+
...(parseMessageIdList(parsed.inReplyTo)[0] && { in_reply_to: parseMessageIdList(parsed.inReplyTo)[0] }),
|
|
1126
|
+
...(parseMessageIdList(parsed.references).length && { references: parseMessageIdList(parsed.references) }),
|
|
1077
1127
|
text: (htmlOnly || !parsed.text) && parsed.html ? htmlToText(parsed.html) : (parsed.text || ''),
|
|
1078
1128
|
...(htmlOnly && { text_source: 'converted from HTML' }),
|
|
1079
1129
|
...(args.include_html && parsed.html && { html: stripDataUris(parsed.html) }),
|
|
@@ -1999,6 +2049,37 @@ async function handleTool(name, args) {
|
|
|
1999
2049
|
return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
|
|
2000
2050
|
}
|
|
2001
2051
|
|
|
2052
|
+
case 'find_split_threads': {
|
|
2053
|
+
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
2054
|
+
let refresh = null;
|
|
2055
|
+
if (args.refresh !== false) {
|
|
2056
|
+
if (localIndex.running) {
|
|
2057
|
+
refresh = 'skipped: background sync in progress';
|
|
2058
|
+
} else {
|
|
2059
|
+
const quick = localIndex.sync({ maxPerFolder: 300, passes: 1 });
|
|
2060
|
+
const outcome = await Promise.race([
|
|
2061
|
+
quick.then((r) => (r && r.skipped ? 'skipped' : 'done')),
|
|
2062
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 20000))
|
|
2063
|
+
]);
|
|
2064
|
+
refresh = {
|
|
2065
|
+
done: 'done',
|
|
2066
|
+
skipped: 'skipped: another server process is syncing the index',
|
|
2067
|
+
timeout: 'partial: refresh still running in background'
|
|
2068
|
+
}[outcome];
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
const found = localIndex.findThreads({
|
|
2072
|
+
folders: args.folders || null,
|
|
2073
|
+
scope: args.scope || 'split',
|
|
2074
|
+
since_date: args.since_date || null,
|
|
2075
|
+
before_date: args.before_date || null,
|
|
2076
|
+
limit: args.limit,
|
|
2077
|
+
offset: args.offset
|
|
2078
|
+
});
|
|
2079
|
+
if (refresh) found.index = { ...found.index, refresh };
|
|
2080
|
+
return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2002
2083
|
case 'sync_index': {
|
|
2003
2084
|
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
2004
2085
|
const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
|
|
@@ -2061,7 +2142,8 @@ async function main() {
|
|
|
2061
2142
|
folders: list(process.env.INDEX_FOLDERS),
|
|
2062
2143
|
excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
|
|
2063
2144
|
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5'),
|
|
2064
|
-
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')
|
|
2065
2147
|
}).open();
|
|
2066
2148
|
localIndex.start();
|
|
2067
2149
|
console.error(`Local index enabled: ${localIndex.dbPath}`);
|
package/lib/local-index.js
CHANGED
|
@@ -56,7 +56,10 @@ function pidAlive(pid) {
|
|
|
56
56
|
return err.code === 'EPERM';
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
const HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
|
|
59
|
+
const HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)';
|
|
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;
|
|
60
63
|
const DEFAULT_EXCLUDE = ['trash', 'junk', 'spam', 'deleted items', 'deleted messages', 'bin'];
|
|
61
64
|
const SPECIAL_EXCLUDE = ['\\trash', '\\junk'];
|
|
62
65
|
|
|
@@ -134,8 +137,24 @@ export function normalizeMessageId(value) {
|
|
|
134
137
|
return id || null;
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Every Message-ID in an In-Reply-To or References header, in order, without
|
|
142
|
+
* duplicates. A References header can be folded across lines and can carry
|
|
143
|
+
* several ids; a header with no angle brackets is treated as one bare id.
|
|
144
|
+
*/
|
|
145
|
+
export function parseMessageIdList(value) {
|
|
146
|
+
const text = Array.isArray(value) ? value.join(' ') : String(value ?? '');
|
|
147
|
+
const out = [];
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
const push = (id) => { const n = normalizeMessageId(id); if (n && !seen.has(n)) { seen.add(n); out.push(n); } };
|
|
150
|
+
const bracketed = text.match(/<[^<>]+>/g);
|
|
151
|
+
if (bracketed) bracketed.forEach(push);
|
|
152
|
+
else text.split(/\s+/).forEach(push);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
export class LocalIndex {
|
|
138
|
-
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 }) {
|
|
139
158
|
this.imapConfig = imapConfig;
|
|
140
159
|
this.dbPath = dbPath || defaultDbPath(imapConfig.imap.user, imapConfig.imap.host);
|
|
141
160
|
this.folders = folders && folders.length ? folders : null;
|
|
@@ -144,6 +163,9 @@ export class LocalIndex {
|
|
|
144
163
|
// Wall-clock cap on one sync run. 0 disables it. Fractions are allowed for tests.
|
|
145
164
|
const cap = Number(maxRunMinutes);
|
|
146
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;
|
|
147
169
|
this.batchSize = batchSize;
|
|
148
170
|
this.maxBodyChars = maxBodyChars;
|
|
149
171
|
this.log = log;
|
|
@@ -207,6 +229,13 @@ export class LocalIndex {
|
|
|
207
229
|
tokenize = 'unicode61 remove_diacritics 2'
|
|
208
230
|
);
|
|
209
231
|
`);
|
|
232
|
+
// 1.9.0: threading headers. Rows indexed by an older version have
|
|
233
|
+
// thread_headers = 0 and are backfilled by the next syncs, headers only.
|
|
234
|
+
const columns = new Set(this.db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name));
|
|
235
|
+
if (!columns.has('in_reply_to')) this.db.exec('ALTER TABLE messages ADD COLUMN in_reply_to TEXT');
|
|
236
|
+
if (!columns.has('references_ids')) this.db.exec('ALTER TABLE messages ADD COLUMN references_ids TEXT');
|
|
237
|
+
if (!columns.has('thread_headers')) this.db.exec('ALTER TABLE messages ADD COLUMN thread_headers INTEGER NOT NULL DEFAULT 0');
|
|
238
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS messages_thread_headers ON messages (thread_headers)');
|
|
210
239
|
const version = this.db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version');
|
|
211
240
|
if (!version) this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION);
|
|
212
241
|
return this;
|
|
@@ -224,7 +253,7 @@ export class LocalIndex {
|
|
|
224
253
|
const kick = async () => {
|
|
225
254
|
try {
|
|
226
255
|
const last = Number(this._meta('last_full_sync') || 0);
|
|
227
|
-
if (Date.now() - last >= this.intervalMs / 2) await this.sync();
|
|
256
|
+
if (Date.now() - last >= this.intervalMs / 2) await this.sync({ backfill: true });
|
|
228
257
|
} catch (err) {
|
|
229
258
|
this.log(`[index] sync failed: ${err.message}`);
|
|
230
259
|
} finally {
|
|
@@ -370,7 +399,7 @@ export class LocalIndex {
|
|
|
370
399
|
* Run a sync. Single-flight: concurrent callers share the running sync.
|
|
371
400
|
* maxPerFolder caps new messages per folder per pass; passes caps the loop.
|
|
372
401
|
*/
|
|
373
|
-
sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
|
|
402
|
+
sync({ folders = null, maxPerFolder = 1000, passes = Infinity, backfill = false } = {}) {
|
|
374
403
|
if (this.running) return this.running;
|
|
375
404
|
this.runningSince = Date.now();
|
|
376
405
|
this.running = (async () => {
|
|
@@ -393,7 +422,7 @@ export class LocalIndex {
|
|
|
393
422
|
}, LOCK_HEARTBEAT_MS);
|
|
394
423
|
if (heartbeat.unref) heartbeat.unref();
|
|
395
424
|
try {
|
|
396
|
-
const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes }));
|
|
425
|
+
const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes, backfill }));
|
|
397
426
|
if (this.db) {
|
|
398
427
|
if (!folders && passes === Infinity && !summary.aborted) this._setMeta('last_full_sync', Date.now());
|
|
399
428
|
this._setMeta('last_run', JSON.stringify({
|
|
@@ -403,6 +432,7 @@ export class LocalIndex {
|
|
|
403
432
|
added: summary.added,
|
|
404
433
|
removed: summary.removed,
|
|
405
434
|
duration_ms: summary.duration_ms,
|
|
435
|
+
...(summary.backfilled != null && { thread_headers_backfilled: summary.backfilled }),
|
|
406
436
|
...(summary.aborted && { aborted: summary.aborted })
|
|
407
437
|
}));
|
|
408
438
|
}
|
|
@@ -458,7 +488,7 @@ export class LocalIndex {
|
|
|
458
488
|
return true;
|
|
459
489
|
}
|
|
460
490
|
|
|
461
|
-
async _sync({ folders, maxPerFolder, passes }) {
|
|
491
|
+
async _sync({ folders, maxPerFolder, passes, backfill = false }) {
|
|
462
492
|
const started = Date.now();
|
|
463
493
|
const summary = { added: 0, removed: 0, folders: {} };
|
|
464
494
|
let targets;
|
|
@@ -527,6 +557,21 @@ export class LocalIndex {
|
|
|
527
557
|
}
|
|
528
558
|
}
|
|
529
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
|
+
}
|
|
530
575
|
summary.duration_ms = Date.now() - started;
|
|
531
576
|
return summary;
|
|
532
577
|
}
|
|
@@ -607,6 +652,60 @@ export class LocalIndex {
|
|
|
607
652
|
}
|
|
608
653
|
}
|
|
609
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Fetch In-Reply-To and References for rows indexed before 1.9.0, headers
|
|
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.
|
|
661
|
+
*/
|
|
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;
|
|
666
|
+
const upd = this.db.prepare('UPDATE messages SET in_reply_to = ?, references_ids = ?, thread_headers = 1 WHERE folder = ? AND uid = ?');
|
|
667
|
+
let total = 0;
|
|
668
|
+
for (const { folder } of counts) {
|
|
669
|
+
if (Date.now() >= deadline) break;
|
|
670
|
+
this._assertLock();
|
|
671
|
+
const conn = await this.session.acquire();
|
|
672
|
+
let done = 0;
|
|
673
|
+
try {
|
|
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
|
+
}
|
|
699
|
+
}
|
|
700
|
+
} finally {
|
|
701
|
+
conn.end();
|
|
702
|
+
if (done) this.log(`[index] ${folder}: threading headers backfilled for ${done} message(s)`);
|
|
703
|
+
total += done;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return total;
|
|
707
|
+
}
|
|
708
|
+
|
|
610
709
|
async _indexBatch(conn, folder, uidvalidity, uids) {
|
|
611
710
|
const meta = await fetchMessages(conn, uids, { bodies: [HEADER_FIELDS], struct: true, size: true });
|
|
612
711
|
this._assertLock(); // a run cut loose by the cap must not write after the fact
|
|
@@ -632,8 +731,9 @@ export class LocalIndex {
|
|
|
632
731
|
this._assertLock();
|
|
633
732
|
|
|
634
733
|
const insMsg = this.db.prepare(`INSERT OR IGNORE INTO messages
|
|
635
|
-
(folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars
|
|
636
|
-
|
|
734
|
+
(folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars,
|
|
735
|
+
in_reply_to, references_ids, thread_headers)
|
|
736
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`);
|
|
637
737
|
const insFts = this.db.prepare('INSERT INTO fts (rowid, subject, from_addr, to_addr, attachments, body) VALUES (?, ?, ?, ?, ?, ?)');
|
|
638
738
|
|
|
639
739
|
let added = 0;
|
|
@@ -655,7 +755,8 @@ export class LocalIndex {
|
|
|
655
755
|
const attachments = plan.attachments.join(', ');
|
|
656
756
|
const internal = rec.attributes.date instanceof Date ? rec.attributes.date.getTime() : null;
|
|
657
757
|
const res = insMsg.run(folder, uid, uidvalidity, internal, (h.date || [null])[0], from, addressText(h.to), addressText(h.cc),
|
|
658
|
-
subject, (h['message-id'] || [null])[0], rec.attributes.size ?? null, attachments, body.length
|
|
758
|
+
subject, (h['message-id'] || [null])[0], rec.attributes.size ?? null, attachments, body.length,
|
|
759
|
+
parseMessageIdList(h['in-reply-to'])[0] || null, parseMessageIdList(h.references).join(' ') || null);
|
|
659
760
|
if (res.changes) {
|
|
660
761
|
insFts.run(Number(res.lastInsertRowid), subject, from, to, attachments, body);
|
|
661
762
|
added++;
|
|
@@ -701,6 +802,7 @@ export class LocalIndex {
|
|
|
701
802
|
sync_max_minutes: this.maxRunMs / 60000,
|
|
702
803
|
command_timeout_seconds: getOpTimeout() / 1000,
|
|
703
804
|
complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
|
|
805
|
+
thread_headers_pending: this.db.prepare('SELECT count(*) AS n FROM messages WHERE thread_headers = 0').get().n,
|
|
704
806
|
last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
|
|
705
807
|
last_run: lastRun,
|
|
706
808
|
folders
|
|
@@ -853,6 +955,129 @@ export class LocalIndex {
|
|
|
853
955
|
};
|
|
854
956
|
}
|
|
855
957
|
|
|
958
|
+
/**
|
|
959
|
+
* Group messages into conversations by their Message-ID chains.
|
|
960
|
+
*
|
|
961
|
+
* Two messages are in the same thread when their Message-ID, In-Reply-To
|
|
962
|
+
* and References ids overlap, transitively. That is how mail clients thread,
|
|
963
|
+
* and it is exact: a renamed subject stays in its thread, two conversations
|
|
964
|
+
* that share a subject do not merge. A message with no chain at all (no
|
|
965
|
+
* In-Reply-To, no References, and nothing pointing at it) is a thread of one.
|
|
966
|
+
*
|
|
967
|
+
* scope:
|
|
968
|
+
* split threads whose messages sit in more than one of the searched
|
|
969
|
+
* folders (default). A thread whose spread is only the same
|
|
970
|
+
* message copied into two folders is flagged copies_only, since
|
|
971
|
+
* that is a duplicate, not a split.
|
|
972
|
+
* all every thread in scope.
|
|
973
|
+
*/
|
|
974
|
+
findThreads({ folders = null, scope = 'split', since_date = null, before_date = null, limit = 50, offset = 0 } = {}) {
|
|
975
|
+
if (!['split', 'all'].includes(scope)) throw new Error(`Unknown scope: ${scope}. Use split or all.`);
|
|
976
|
+
const folderList = Array.isArray(folders) ? folders.filter(Boolean) : (folders ? [folders] : []);
|
|
977
|
+
const where = [];
|
|
978
|
+
const params = [];
|
|
979
|
+
if (folderList.length) {
|
|
980
|
+
where.push(`folder IN (${folderList.map(() => '?').join(', ')})`);
|
|
981
|
+
params.push(...folderList);
|
|
982
|
+
}
|
|
983
|
+
if (since_date) { where.push('internal_date >= ?'); params.push(localDate(since_date).getTime()); }
|
|
984
|
+
if (before_date) { where.push('internal_date < ?'); params.push(localDate(before_date).getTime()); }
|
|
985
|
+
const scopeSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
986
|
+
const rows = this.db.prepare(`
|
|
987
|
+
SELECT id, folder, uid, message_id, in_reply_to, references_ids, subject, from_addr, internal_date, sent_date, thread_headers
|
|
988
|
+
FROM messages ${scopeSql}`).all(...params);
|
|
989
|
+
|
|
990
|
+
// Union-find over ids. Each message joins its own Message-ID with every id
|
|
991
|
+
// it references; a message without a Message-ID gets a private key.
|
|
992
|
+
const parent = new Map();
|
|
993
|
+
const find = (k) => {
|
|
994
|
+
let r = k;
|
|
995
|
+
while (parent.get(r) !== r) r = parent.get(r);
|
|
996
|
+
let c = k;
|
|
997
|
+
while (parent.get(c) !== r) { const n = parent.get(c); parent.set(c, r); c = n; }
|
|
998
|
+
return r;
|
|
999
|
+
};
|
|
1000
|
+
const add = (k) => { if (!parent.has(k)) parent.set(k, k); };
|
|
1001
|
+
const union = (a, b) => { add(a); add(b); const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(ra, rb); };
|
|
1002
|
+
const keyOf = new Map();
|
|
1003
|
+
let withHeaders = 0;
|
|
1004
|
+
for (const r of rows) {
|
|
1005
|
+
const own = normalizeMessageId(r.message_id) || `row:${r.id}`;
|
|
1006
|
+
keyOf.set(r.id, own);
|
|
1007
|
+
add(own);
|
|
1008
|
+
if (r.thread_headers) withHeaders++;
|
|
1009
|
+
if (r.in_reply_to) union(own, r.in_reply_to);
|
|
1010
|
+
if (r.references_ids) for (const ref of r.references_ids.split(' ')) if (ref) union(own, ref);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const threads = new Map();
|
|
1014
|
+
for (const r of rows) {
|
|
1015
|
+
const root = find(keyOf.get(r.id));
|
|
1016
|
+
if (!threads.has(root)) threads.set(root, []);
|
|
1017
|
+
threads.get(root).push(r);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const dateOf = (r) => Number(r.internal_date) || Date.parse(r.sent_date || '') || 0;
|
|
1021
|
+
const out = [];
|
|
1022
|
+
for (const [root, msgs] of threads) {
|
|
1023
|
+
const byFolder = {};
|
|
1024
|
+
for (const m of msgs) byFolder[m.folder] = (byFolder[m.folder] || 0) + 1;
|
|
1025
|
+
const folderCount = Object.keys(byFolder).length;
|
|
1026
|
+
if (scope === 'split' && folderCount < 2) continue;
|
|
1027
|
+
const sorted = [...msgs].sort((a, b) => dateOf(a) - dateOf(b));
|
|
1028
|
+
const distinctIds = new Set(sorted.map((m) => normalizeMessageId(m.message_id) || `row:${m.id}`));
|
|
1029
|
+
// Spread that exists only because the same messages sit in two folders.
|
|
1030
|
+
let copiesOnly = false;
|
|
1031
|
+
if (folderCount > 1) {
|
|
1032
|
+
const perId = new Map();
|
|
1033
|
+
for (const m of sorted) {
|
|
1034
|
+
const id = normalizeMessageId(m.message_id) || `row:${m.id}`;
|
|
1035
|
+
if (!perId.has(id)) perId.set(id, new Set());
|
|
1036
|
+
perId.get(id).add(m.folder);
|
|
1037
|
+
}
|
|
1038
|
+
copiesOnly = [...perId.values()].every((set) => set.size === folderCount);
|
|
1039
|
+
}
|
|
1040
|
+
const newest = sorted[sorted.length - 1];
|
|
1041
|
+
const oldest = sorted[0];
|
|
1042
|
+
const participants = [...new Set(sorted.map((m) => m.from_addr).filter(Boolean))].slice(0, 12);
|
|
1043
|
+
out.push({
|
|
1044
|
+
thread_key: root.startsWith('row:') ? null : root,
|
|
1045
|
+
subject: oldest.subject || '',
|
|
1046
|
+
latest_subject: newest.subject !== oldest.subject ? newest.subject : undefined,
|
|
1047
|
+
messages: msgs.length,
|
|
1048
|
+
distinct_messages: distinctIds.size,
|
|
1049
|
+
by_folder: byFolder,
|
|
1050
|
+
...(folderCount > 1 && { copies_only: copiesOnly }),
|
|
1051
|
+
oldest: { folder: oldest.folder, uid: Number(oldest.uid), date: oldest.sent_date || new Date(dateOf(oldest)).toISOString(), from: oldest.from_addr },
|
|
1052
|
+
newest: { folder: newest.folder, uid: Number(newest.uid), date: newest.sent_date || new Date(dateOf(newest)).toISOString(), from: newest.from_addr },
|
|
1053
|
+
participants,
|
|
1054
|
+
threading: sorted.every((m) => m.thread_headers) ? 'headers' : 'partial: some messages still lack threading headers',
|
|
1055
|
+
message_list: sorted.slice(-50).map((m) => ({ folder: m.folder, uid: Number(m.uid), date: m.sent_date || new Date(dateOf(m)).toISOString(), from: m.from_addr }))
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
out.sort((a, b) => String(b.newest.date).localeCompare(String(a.newest.date)));
|
|
1059
|
+
const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
|
|
1060
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
1061
|
+
const page = out.slice(skip, skip + cap);
|
|
1062
|
+
return {
|
|
1063
|
+
scope,
|
|
1064
|
+
folders_searched: folderList.length ? folderList : 'all indexed folders',
|
|
1065
|
+
coverage: {
|
|
1066
|
+
messages_in_scope: rows.length,
|
|
1067
|
+
with_thread_headers: withHeaders,
|
|
1068
|
+
without_thread_headers: rows.length - withHeaders,
|
|
1069
|
+
...(rows.length > withHeaders && { note: 'messages without threading headers are still being backfilled; they thread only by their own Message-ID until then' }),
|
|
1070
|
+
threads_in_scope: threads.size
|
|
1071
|
+
},
|
|
1072
|
+
total_threads: out.length,
|
|
1073
|
+
returned: page.length,
|
|
1074
|
+
offset: skip,
|
|
1075
|
+
has_more: skip + page.length < out.length,
|
|
1076
|
+
threads: page,
|
|
1077
|
+
index: this._indexHealth(folderList)
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
856
1081
|
search({ query, folders = null, from = null, since_date = null, before_date = null, field = 'any', match = 'all', prefix = false, sort = 'date', limit = 20, offset = 0 }) {
|
|
857
1082
|
const where = ['fts MATCH ?'];
|
|
858
1083
|
const params = [ftsQuery(query, { match, prefix, field })];
|
|
@@ -881,6 +1106,7 @@ export class LocalIndex {
|
|
|
881
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;
|
|
882
1107
|
const rows = this.db.prepare(`
|
|
883
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,
|
|
1109
|
+
m.in_reply_to, m.references_ids,
|
|
884
1110
|
snippet(fts, -1, '[', ']', '...', 16) AS snippet
|
|
885
1111
|
FROM fts JOIN messages m ON m.id = fts.rowid
|
|
886
1112
|
WHERE ${whereSql}
|
|
@@ -900,6 +1126,8 @@ export class LocalIndex {
|
|
|
900
1126
|
to: r.to_addr,
|
|
901
1127
|
subject: r.subject,
|
|
902
1128
|
message_id: normalizeMessageId(r.message_id) || undefined,
|
|
1129
|
+
in_reply_to: r.in_reply_to || undefined,
|
|
1130
|
+
references: r.references_ids ? r.references_ids.split(' ') : undefined,
|
|
903
1131
|
attachments: r.attachments || undefined,
|
|
904
1132
|
snippet: r.snippet
|
|
905
1133
|
}))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengelhard/imap-email-mcp",
|
|
3
|
-
"version": "1.
|
|
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",
|