@lengelhard/imap-email-mcp 1.8.0 → 1.9.0
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/.env.example +1 -0
- package/CHANGELOG.md +22 -0
- package/README.md +15 -3
- package/index.js +112 -8
- package/lib/imap-session.js +55 -2
- package/lib/local-index.js +297 -22
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -21,6 +21,7 @@ IMAP_HOST=imap.example.com
|
|
|
21
21
|
# INDEX_FOLDERS= (comma list; default: all folders except Trash and Junk)
|
|
22
22
|
# INDEX_EXCLUDE_FOLDERS= (comma list, added to the default exclusions)
|
|
23
23
|
# INDEX_SYNC_INTERVAL_MINUTES=5
|
|
24
|
+
# INDEX_SYNC_MAX_MINUTES=15 (wall-clock cap on one sync run; 0 disables)
|
|
24
25
|
|
|
25
26
|
# Optional - SMTP Settings (defaults to IMAP values if not set)
|
|
26
27
|
# SMTP_HOST=smtp.example.com
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `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.
|
|
7
|
+
- `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.
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Opening a database written by 1.8.x adds the three threading columns in place. Searches keep working while the backfill runs.
|
|
12
|
+
|
|
13
|
+
## 1.8.1
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- A sync could still hang forever after a connection dropped. The folder LIST at the start of every run was the one IMAP command not under the 1.7.3 time limit, and node-imap never fails a command whose socket has died, so a run that started on a silent connection (a sleeping laptop, a dropped session) waited indefinitely with `sync_running: true`, blocking every later sync and every `sync_index` call. Seen on 18, 20 and 21 Sep 2026. The LIST now runs under the same limit as every other command.
|
|
17
|
+
- As a backstop, every sync run has a wall-clock cap (`INDEX_SYNC_MAX_MINUTES`, default 15). A run that outlives it is aborted, its connection is dropped and its lease released, and the next scheduled sync starts clean. A run cut loose can no longer write to the index.
|
|
18
|
+
- The next background sync is scheduled from the end of the previous run instead of a fixed timer, so a slow run cannot queue a pile-up behind itself.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- `index_status` reports `sync_running_since`, `sync_running_seconds` and `sync_max_minutes`, so a stuck sync is a visible number rather than a flag.
|
|
22
|
+
- `sync_index` takes `force: true`, which aborts a running sync before starting a fresh one. The index is read-only against the server, so this is always safe.
|
|
23
|
+
- `find_duplicates` takes `offset` and returns `has_more`, so more than 500 groups can be paged through instead of partitioned by date.
|
|
24
|
+
|
|
3
25
|
## 1.8.0
|
|
4
26
|
|
|
5
27
|
### Added
|
package/README.md
CHANGED
|
@@ -105,7 +105,8 @@ Add to your config file:
|
|
|
105
105
|
| `INDEX_DB_PATH` | see below | Location of the index database |
|
|
106
106
|
| `INDEX_FOLDERS` | all but Trash/Junk | Comma-separated folders to index |
|
|
107
107
|
| `INDEX_EXCLUDE_FOLDERS` | none | Comma-separated folders to skip, on top of Trash/Junk |
|
|
108
|
-
| `INDEX_SYNC_INTERVAL_MINUTES` | `5` | Background sync interval |
|
|
108
|
+
| `INDEX_SYNC_INTERVAL_MINUTES` | `5` | Background sync interval, measured from the end of the previous run |
|
|
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) |
|
|
109
110
|
|
|
110
111
|
### Local search index
|
|
111
112
|
|
|
@@ -128,6 +129,16 @@ Mail clients that file by tag sometimes append a fresh copy of a message instead
|
|
|
128
129
|
- **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.
|
|
129
130
|
- **Nothing is deleted.** The tool only reports. Acting on it means calling `delete_email`, which moves to Trash unless you pass `expunge: true`.
|
|
130
131
|
|
|
132
|
+
### Finding split threads
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **Nothing is moved.** The tool only reports. `move_email` or `move_thread` are the tools for acting on it.
|
|
141
|
+
|
|
131
142
|
### Provider Settings
|
|
132
143
|
|
|
133
144
|
| Provider | IMAP_HOST | SMTP_HOST | Notes |
|
|
@@ -147,9 +158,10 @@ Mail clients that file by tag sometimes append a fresh copy of a message instead
|
|
|
147
158
|
| `get_email` | Get an email by UID. Text by default; HTML (without inline images) on request |
|
|
148
159
|
| `search_emails` | Live IMAP search across one or more folders: subject, from, to, cc, body, text, dates, flags |
|
|
149
160
|
| `search_index` | Full-text search of the local index (when `INDEX_ENABLED=true`); `index.warning` flags a stale index or folder errors |
|
|
150
|
-
| `index_status` | Per-folder index coverage, last sync and errors |
|
|
151
|
-
| `sync_index` | Run an index sync now |
|
|
161
|
+
| `index_status` | Per-folder index coverage, last sync and errors; `sync_running_seconds` shows how long the current sync has run |
|
|
162
|
+
| `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
|
|
152
163
|
| `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
|
|
164
|
+
| `find_split_threads` | Conversations spread across folders, threaded on In-Reply-To and References so renamed replies stay with their thread |
|
|
153
165
|
| `list_drafts` | List all draft emails |
|
|
154
166
|
| `get_draft` | Get a specific draft by UID |
|
|
155
167
|
| `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.',
|
|
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
|
{
|
|
@@ -480,6 +524,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
480
524
|
description: 'Maximum groups to return (default: 50, max: 500). total_groups reports the full count.',
|
|
481
525
|
default: 50
|
|
482
526
|
},
|
|
527
|
+
offset: {
|
|
528
|
+
type: 'number',
|
|
529
|
+
description: 'Skip this many groups, for paging (default: 0). has_more says whether another page exists.',
|
|
530
|
+
default: 0
|
|
531
|
+
},
|
|
483
532
|
refresh: {
|
|
484
533
|
type: 'boolean',
|
|
485
534
|
description: 'Pick up new and moved mail before searching (default: true). Skipped while a background sync is running.',
|
|
@@ -491,7 +540,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
491
540
|
},
|
|
492
541
|
{
|
|
493
542
|
name: 'sync_index',
|
|
494
|
-
description: 'Run an index sync now and wait for it (up to wait_seconds). The index is read-only: syncing never changes anything on the mail server.',
|
|
543
|
+
description: 'Run an index sync now and wait for it (up to wait_seconds). The index is read-only: syncing never changes anything on the mail server. If a sync is already running the call waits on that one; pass force: true to abort it first and start fresh, which is the remedy for a sync that index_status shows as running far longer than sync_max_minutes.',
|
|
495
544
|
inputSchema: {
|
|
496
545
|
type: 'object',
|
|
497
546
|
properties: {
|
|
@@ -504,6 +553,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
504
553
|
type: 'number',
|
|
505
554
|
description: 'How long to wait before returning while the sync continues in the background (default: 60)',
|
|
506
555
|
default: 60
|
|
556
|
+
},
|
|
557
|
+
force: {
|
|
558
|
+
type: 'boolean',
|
|
559
|
+
description: 'Abort a sync that is already running before starting this one (default: false). Safe: the index is read-only against the server. Use when index_status shows sync_running_seconds far beyond sync_max_minutes.',
|
|
560
|
+
default: false
|
|
507
561
|
}
|
|
508
562
|
},
|
|
509
563
|
required: []
|
|
@@ -856,7 +910,7 @@ async function connectIMAP() {
|
|
|
856
910
|
return await sessions.acquire();
|
|
857
911
|
}
|
|
858
912
|
|
|
859
|
-
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)';
|
|
860
914
|
const MAX_LIMIT = 200;
|
|
861
915
|
|
|
862
916
|
function clampLimit(value, fallback = 20) {
|
|
@@ -885,6 +939,10 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
|
|
|
885
939
|
if (h.cc?.length) item.cc = h.cc.join(', ');
|
|
886
940
|
const messageId = normalizeMessageId(h['message-id']?.[0]);
|
|
887
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;
|
|
888
946
|
if (withFlags) item.flags = rec.attributes.flags;
|
|
889
947
|
out.push(item);
|
|
890
948
|
}
|
|
@@ -1064,6 +1122,8 @@ async function handleTool(name, args) {
|
|
|
1064
1122
|
subject: parsed.subject,
|
|
1065
1123
|
date: parsed.date,
|
|
1066
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) }),
|
|
1067
1127
|
text: (htmlOnly || !parsed.text) && parsed.html ? htmlToText(parsed.html) : (parsed.text || ''),
|
|
1068
1128
|
...(htmlOnly && { text_source: 'converted from HTML' }),
|
|
1069
1129
|
...(args.include_html && parsed.html && { html: stripDataUris(parsed.html) }),
|
|
@@ -1982,7 +2042,39 @@ async function handleTool(name, args) {
|
|
|
1982
2042
|
scope: args.scope || 'all',
|
|
1983
2043
|
since_date: args.since_date || null,
|
|
1984
2044
|
before_date: args.before_date || null,
|
|
1985
|
-
limit: args.limit
|
|
2045
|
+
limit: args.limit,
|
|
2046
|
+
offset: args.offset
|
|
2047
|
+
});
|
|
2048
|
+
if (refresh) found.index = { ...found.index, refresh };
|
|
2049
|
+
return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
|
|
2050
|
+
}
|
|
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
|
|
1986
2078
|
});
|
|
1987
2079
|
if (refresh) found.index = { ...found.index, refresh };
|
|
1988
2080
|
return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
|
|
@@ -1991,7 +2083,16 @@ async function handleTool(name, args) {
|
|
|
1991
2083
|
case 'sync_index': {
|
|
1992
2084
|
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
1993
2085
|
const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
|
|
1994
|
-
|
|
2086
|
+
let alreadyRunning = !!localIndex.running;
|
|
2087
|
+
let forced = false;
|
|
2088
|
+
if (alreadyRunning && args.force) {
|
|
2089
|
+
const stuckFor = localIndex.runningSince ? Math.round((Date.now() - localIndex.runningSince) / 1000) : null;
|
|
2090
|
+
forced = await Promise.race([
|
|
2091
|
+
localIndex.abortSync(`forced by sync_index after ${stuckFor}s`),
|
|
2092
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 15000))
|
|
2093
|
+
]);
|
|
2094
|
+
alreadyRunning = !!localIndex.running;
|
|
2095
|
+
}
|
|
1995
2096
|
const run = localIndex.sync(args.folders?.length ? { folders: args.folders } : {});
|
|
1996
2097
|
const outcome = await Promise.race([
|
|
1997
2098
|
run.then((summary) => ({ finished: true, summary })),
|
|
@@ -2002,7 +2103,9 @@ async function handleTool(name, args) {
|
|
|
2002
2103
|
content: [{
|
|
2003
2104
|
type: 'text',
|
|
2004
2105
|
text: JSON.stringify({
|
|
2005
|
-
...(
|
|
2106
|
+
...(forced === true && { note: 'A running sync was aborted first (force: true) and a fresh one started.' }),
|
|
2107
|
+
...(forced === 'timeout' && { note: 'force: true was requested but the running sync did not release within 15s; waited on it instead.' }),
|
|
2108
|
+
...(alreadyRunning && !forced && { note: 'A sync was already running; waited on that one.' }),
|
|
2006
2109
|
...(skipped && { note: `Not started: ${outcome.summary.reason}. It keeps the index current; check index_status.` }),
|
|
2007
2110
|
finished: skipped ? false : outcome.finished,
|
|
2008
2111
|
...(outcome.summary && !skipped && { summary: outcome.summary }),
|
|
@@ -2038,7 +2141,8 @@ async function main() {
|
|
|
2038
2141
|
dbPath: process.env.INDEX_DB_PATH || undefined,
|
|
2039
2142
|
folders: list(process.env.INDEX_FOLDERS),
|
|
2040
2143
|
excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
|
|
2041
|
-
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5')
|
|
2144
|
+
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5'),
|
|
2145
|
+
maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15')
|
|
2042
2146
|
}).open();
|
|
2043
2147
|
localIndex.start();
|
|
2044
2148
|
console.error(`Local index enabled: ${localIndex.dbPath}`);
|
package/lib/imap-session.js
CHANGED
|
@@ -89,6 +89,9 @@ export class SessionManager {
|
|
|
89
89
|
this.lastUsedAt = 0;
|
|
90
90
|
this.tail = Promise.resolve();
|
|
91
91
|
this.idleTimer = null;
|
|
92
|
+
this.activeRelease = null;
|
|
93
|
+
this.generation = 0;
|
|
94
|
+
this.abortConnect = null;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
_isUsable(conn) {
|
|
@@ -113,15 +116,34 @@ export class SessionManager {
|
|
|
113
116
|
async _connect() {
|
|
114
117
|
if (this._isUsable(this.conn)) return this.conn;
|
|
115
118
|
if (!this.connecting) {
|
|
116
|
-
|
|
119
|
+
const generation = this.generation;
|
|
120
|
+
// dropCurrent() can reject this attempt at once, so a caller queued on a
|
|
121
|
+
// socket that will never answer does not wait out the connect timeout.
|
|
122
|
+
let abort;
|
|
123
|
+
const aborted = new Promise((_, reject) => { abort = reject; });
|
|
124
|
+
aborted.catch(() => {});
|
|
125
|
+
this.abortConnect = abort;
|
|
126
|
+
const dial = (async () => {
|
|
117
127
|
const conn = await imaps.connect(this.config);
|
|
128
|
+
if (generation !== this.generation) {
|
|
129
|
+
// The caller that wanted this socket is gone; do not hand it to anyone.
|
|
130
|
+
try { conn.imap.destroy(); } catch { /* ignore */ }
|
|
131
|
+
throw new Error('connection attempt superseded');
|
|
132
|
+
}
|
|
118
133
|
// Without an 'error' listener an idle socket error would crash the process.
|
|
119
134
|
conn.on('error', (err) => this._drop(conn, `error: ${err && err.message}`));
|
|
120
135
|
conn.imap.once('close', () => { if (this.conn === conn) this.conn = null; });
|
|
121
136
|
conn.imap.once('end', () => { if (this.conn === conn) this.conn = null; });
|
|
122
137
|
this.conn = conn;
|
|
123
138
|
return conn;
|
|
124
|
-
})()
|
|
139
|
+
})();
|
|
140
|
+
dial.catch(() => {});
|
|
141
|
+
const attempt = Promise.race([dial, aborted]);
|
|
142
|
+
attempt.then(() => {}, () => {}).then(() => {
|
|
143
|
+
if (this.connecting === attempt) this.connecting = null;
|
|
144
|
+
if (this.abortConnect === abort) this.abortConnect = null;
|
|
145
|
+
});
|
|
146
|
+
this.connecting = attempt;
|
|
125
147
|
}
|
|
126
148
|
return this.connecting;
|
|
127
149
|
}
|
|
@@ -172,6 +194,7 @@ export class SessionManager {
|
|
|
172
194
|
const release = () => {
|
|
173
195
|
if (released) return;
|
|
174
196
|
released = true;
|
|
197
|
+
if (this.activeRelease === release) this.activeRelease = null;
|
|
175
198
|
this.lastUsedAt = Date.now();
|
|
176
199
|
clearTimeout(watchdog);
|
|
177
200
|
if (!this._isUsable(conn)) this._drop(conn, 'unusable after call');
|
|
@@ -185,6 +208,7 @@ export class SessionManager {
|
|
|
185
208
|
release();
|
|
186
209
|
}, this.leaseTimeoutMs);
|
|
187
210
|
if (watchdog.unref) watchdog.unref();
|
|
211
|
+
this.activeRelease = release;
|
|
188
212
|
|
|
189
213
|
return new Proxy(conn, {
|
|
190
214
|
get(target, prop) {
|
|
@@ -196,12 +220,41 @@ export class SessionManager {
|
|
|
196
220
|
});
|
|
197
221
|
}
|
|
198
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Drop the current connection (if any) so the next acquire() opens a fresh
|
|
225
|
+
* one. Used to cut a sync loose from a socket that has gone silent.
|
|
226
|
+
*/
|
|
227
|
+
dropCurrent(reason) {
|
|
228
|
+
if (this.conn) this._drop(this.conn, reason);
|
|
229
|
+
// A connect attempt still pending belongs to the run being cut loose:
|
|
230
|
+
// fail it now and detach it, so the next acquire() starts its own.
|
|
231
|
+
this.generation++;
|
|
232
|
+
const abort = this.abortConnect;
|
|
233
|
+
this.abortConnect = null;
|
|
234
|
+
this.connecting = null;
|
|
235
|
+
if (abort) abort(new Error(`connection attempt aborted: ${reason}`));
|
|
236
|
+
// Release the lease the dropped connection was handed out under, so the
|
|
237
|
+
// next acquire() does not wait behind an owner that will never return.
|
|
238
|
+
if (this.activeRelease) this.activeRelease();
|
|
239
|
+
}
|
|
240
|
+
|
|
199
241
|
close() {
|
|
200
242
|
clearTimeout(this.idleTimer);
|
|
201
243
|
if (this.conn) this._drop(this.conn, null);
|
|
202
244
|
}
|
|
203
245
|
}
|
|
204
246
|
|
|
247
|
+
/** LIST all mailboxes, under the command time limit. */
|
|
248
|
+
export function listBoxes(connection) {
|
|
249
|
+
return withTimeout(connection, 'list folders', new Promise((resolve, reject) => {
|
|
250
|
+
try {
|
|
251
|
+
connection.imap.getBoxes((err, boxes) => (err ? reject(err) : resolve(boxes)));
|
|
252
|
+
} catch (err) {
|
|
253
|
+
reject(err);
|
|
254
|
+
}
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
205
258
|
/** Open a mailbox. readOnly uses EXAMINE, which never changes flags. */
|
|
206
259
|
export function openBox(connection, name, readOnly = false) {
|
|
207
260
|
return withTimeout(connection, `open ${name}`, new Promise((resolve, reject) => {
|
package/lib/local-index.js
CHANGED
|
@@ -19,7 +19,7 @@ import fs from 'fs';
|
|
|
19
19
|
import os from 'os';
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import crypto from 'crypto';
|
|
22
|
-
import { SessionManager, openBox, fetchMessages, headerOf, localDate, uidSearchAll, getOpTimeout } from './imap-session.js';
|
|
22
|
+
import { SessionManager, openBox, fetchMessages, headerOf, localDate, uidSearchAll, listBoxes, getOpTimeout } from './imap-session.js';
|
|
23
23
|
import { planParts, partsToText } from './mime-text.js';
|
|
24
24
|
|
|
25
25
|
const SCHEMA_VERSION = '1';
|
|
@@ -29,6 +29,7 @@ const REQUEST_RETRY_MS = 15 * 1000;
|
|
|
29
29
|
const STALL_MS = 5 * 60 * 1000;
|
|
30
30
|
const REQUEST_MAX_ATTEMPTS = 6;
|
|
31
31
|
const STALE_AFTER_MS = 15 * 60 * 1000;
|
|
32
|
+
const DEFAULT_MAX_RUN_MINUTES = 15;
|
|
32
33
|
|
|
33
34
|
function describeAge(ms) {
|
|
34
35
|
const minutes = Math.round(ms / 60000);
|
|
@@ -55,7 +56,8 @@ function pidAlive(pid) {
|
|
|
55
56
|
return err.code === 'EPERM';
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
|
-
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 THREAD_BACKFILL_PER_FOLDER = 3000;
|
|
59
61
|
const DEFAULT_EXCLUDE = ['trash', 'junk', 'spam', 'deleted items', 'deleted messages', 'bin'];
|
|
60
62
|
const SPECIAL_EXCLUDE = ['\\trash', '\\junk'];
|
|
61
63
|
|
|
@@ -133,18 +135,40 @@ export function normalizeMessageId(value) {
|
|
|
133
135
|
return id || null;
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Every Message-ID in an In-Reply-To or References header, in order, without
|
|
140
|
+
* duplicates. A References header can be folded across lines and can carry
|
|
141
|
+
* several ids; a header with no angle brackets is treated as one bare id.
|
|
142
|
+
*/
|
|
143
|
+
export function parseMessageIdList(value) {
|
|
144
|
+
const text = Array.isArray(value) ? value.join(' ') : String(value ?? '');
|
|
145
|
+
const out = [];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const push = (id) => { const n = normalizeMessageId(id); if (n && !seen.has(n)) { seen.add(n); out.push(n); } };
|
|
148
|
+
const bracketed = text.match(/<[^<>]+>/g);
|
|
149
|
+
if (bracketed) bracketed.forEach(push);
|
|
150
|
+
else text.split(/\s+/).forEach(push);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
export class LocalIndex {
|
|
137
|
-
constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
|
|
155
|
+
constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, maxRunMinutes = DEFAULT_MAX_RUN_MINUTES, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
|
|
138
156
|
this.imapConfig = imapConfig;
|
|
139
157
|
this.dbPath = dbPath || defaultDbPath(imapConfig.imap.user, imapConfig.imap.host);
|
|
140
158
|
this.folders = folders && folders.length ? folders : null;
|
|
141
159
|
this.excludeFolders = excludeFolders.map((f) => f.toLowerCase());
|
|
142
160
|
this.intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
|
|
161
|
+
// Wall-clock cap on one sync run. 0 disables it. Fractions are allowed for tests.
|
|
162
|
+
const cap = Number(maxRunMinutes);
|
|
163
|
+
this.maxRunMs = Number.isFinite(cap) && cap >= 0 ? cap * 60 * 1000 : DEFAULT_MAX_RUN_MINUTES * 60 * 1000;
|
|
143
164
|
this.batchSize = batchSize;
|
|
144
165
|
this.maxBodyChars = maxBodyChars;
|
|
145
166
|
this.log = log;
|
|
146
167
|
this.db = null;
|
|
147
168
|
this.running = null;
|
|
169
|
+
this.runningSince = 0;
|
|
170
|
+
this.abortRun = null;
|
|
171
|
+
this.stopped = false;
|
|
148
172
|
this.timer = null;
|
|
149
173
|
this.progressAt = 0;
|
|
150
174
|
this.debounce = null;
|
|
@@ -200,25 +224,44 @@ export class LocalIndex {
|
|
|
200
224
|
tokenize = 'unicode61 remove_diacritics 2'
|
|
201
225
|
);
|
|
202
226
|
`);
|
|
227
|
+
// 1.9.0: threading headers. Rows indexed by an older version have
|
|
228
|
+
// thread_headers = 0 and are backfilled by the next syncs, headers only.
|
|
229
|
+
const columns = new Set(this.db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name));
|
|
230
|
+
if (!columns.has('in_reply_to')) this.db.exec('ALTER TABLE messages ADD COLUMN in_reply_to TEXT');
|
|
231
|
+
if (!columns.has('references_ids')) this.db.exec('ALTER TABLE messages ADD COLUMN references_ids TEXT');
|
|
232
|
+
if (!columns.has('thread_headers')) this.db.exec('ALTER TABLE messages ADD COLUMN thread_headers INTEGER NOT NULL DEFAULT 0');
|
|
233
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS messages_thread_headers ON messages (thread_headers)');
|
|
203
234
|
const version = this.db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version');
|
|
204
235
|
if (!version) this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION);
|
|
205
236
|
return this;
|
|
206
237
|
}
|
|
207
238
|
|
|
208
239
|
start() {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
240
|
+
// The next scheduled sync is armed when the previous one ends, so a slow
|
|
241
|
+
// or stuck run can never queue a pile-up behind itself.
|
|
242
|
+
const arm = (delay) => {
|
|
243
|
+
clearTimeout(this.timer);
|
|
244
|
+
if (this.stopped) return;
|
|
245
|
+
this.timer = setTimeout(kick, delay);
|
|
246
|
+
if (this.timer.unref) this.timer.unref();
|
|
247
|
+
};
|
|
248
|
+
const kick = async () => {
|
|
249
|
+
try {
|
|
250
|
+
const last = Number(this._meta('last_full_sync') || 0);
|
|
251
|
+
if (Date.now() - last >= this.intervalMs / 2) await this.sync();
|
|
252
|
+
} catch (err) {
|
|
253
|
+
this.log(`[index] sync failed: ${err.message}`);
|
|
254
|
+
} finally {
|
|
255
|
+
arm(this.intervalMs);
|
|
256
|
+
}
|
|
213
257
|
};
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
this.timer = setInterval(kick, this.intervalMs);
|
|
217
|
-
if (this.timer.unref) this.timer.unref();
|
|
258
|
+
this.stopped = false;
|
|
259
|
+
arm(5000);
|
|
218
260
|
}
|
|
219
261
|
|
|
220
262
|
stop() {
|
|
221
|
-
|
|
263
|
+
this.stopped = true;
|
|
264
|
+
clearTimeout(this.timer);
|
|
222
265
|
clearTimeout(this.debounce);
|
|
223
266
|
this.releaseLock();
|
|
224
267
|
this.session.close();
|
|
@@ -332,7 +375,7 @@ export class LocalIndex {
|
|
|
332
375
|
}
|
|
333
376
|
|
|
334
377
|
async _targetFolders(connection) {
|
|
335
|
-
const boxes = flattenBoxes(await connection
|
|
378
|
+
const boxes = flattenBoxes(await listBoxes(connection));
|
|
336
379
|
const selectable = boxes.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
|
|
337
380
|
if (this.folders) {
|
|
338
381
|
const wanted = new Set(this.folders.map((f) => f.toLowerCase()));
|
|
@@ -353,6 +396,7 @@ export class LocalIndex {
|
|
|
353
396
|
*/
|
|
354
397
|
sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
|
|
355
398
|
if (this.running) return this.running;
|
|
399
|
+
this.runningSince = Date.now();
|
|
356
400
|
this.running = (async () => {
|
|
357
401
|
if (!this._acquireLock()) {
|
|
358
402
|
return { skipped: true, reason: `another server process (pid ${this.lockHolder}) is syncing the index` };
|
|
@@ -373,7 +417,7 @@ export class LocalIndex {
|
|
|
373
417
|
}, LOCK_HEARTBEAT_MS);
|
|
374
418
|
if (heartbeat.unref) heartbeat.unref();
|
|
375
419
|
try {
|
|
376
|
-
const summary = await this._sync({ folders, maxPerFolder, passes });
|
|
420
|
+
const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes }));
|
|
377
421
|
if (this.db) {
|
|
378
422
|
if (!folders && passes === Infinity && !summary.aborted) this._setMeta('last_full_sync', Date.now());
|
|
379
423
|
this._setMeta('last_run', JSON.stringify({
|
|
@@ -391,15 +435,58 @@ export class LocalIndex {
|
|
|
391
435
|
clearInterval(heartbeat);
|
|
392
436
|
this.releaseLock();
|
|
393
437
|
}
|
|
394
|
-
})().finally(() => { this.running = null; });
|
|
438
|
+
})().finally(() => { this.running = null; this.runningSince = 0; this.abortRun = null; });
|
|
395
439
|
return this.running;
|
|
396
440
|
}
|
|
397
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Run one sync body under a wall-clock cap and an abort handle. A run that
|
|
444
|
+
* outlives the cap, or is aborted through abortSync(), is cut loose: its
|
|
445
|
+
* connection is dropped so any command it is waiting on fails, the caller
|
|
446
|
+
* releases the lock, and the orphaned body cannot touch the index again
|
|
447
|
+
* because _assertLock() throws once the lock is gone.
|
|
448
|
+
*/
|
|
449
|
+
_runCapped(body) {
|
|
450
|
+
const started = Date.now();
|
|
451
|
+
let capTimer = null;
|
|
452
|
+
const cut = (reason) => ({ added: 0, removed: 0, folders: {}, aborted: reason, duration_ms: Date.now() - started, cut: true });
|
|
453
|
+
const stop = new Promise((resolve) => {
|
|
454
|
+
this.abortRun = (reason) => resolve(cut(reason));
|
|
455
|
+
if (this.maxRunMs > 0) {
|
|
456
|
+
const minutes = this.maxRunMs / 60000;
|
|
457
|
+
capTimer = setTimeout(() => this.abortRun(`sync exceeded ${minutes} minute${minutes === 1 ? '' : 's'}, aborted`), this.maxRunMs);
|
|
458
|
+
if (capTimer.unref) capTimer.unref();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
const guarded = body().catch((err) => ({ error: err }));
|
|
462
|
+
return Promise.race([guarded, stop]).then((summary) => {
|
|
463
|
+
clearTimeout(capTimer);
|
|
464
|
+
if (summary.error) throw summary.error;
|
|
465
|
+
if (summary.cut) {
|
|
466
|
+
delete summary.cut;
|
|
467
|
+
this.session.dropCurrent(summary.aborted);
|
|
468
|
+
this.log(`[index] sync stopped: ${summary.aborted}`);
|
|
469
|
+
}
|
|
470
|
+
return summary;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Abort a running sync so a fresh one can start. Resolves once the run has
|
|
476
|
+
* been released. Returns false when nothing was running.
|
|
477
|
+
*/
|
|
478
|
+
async abortSync(reason = 'aborted') {
|
|
479
|
+
if (!this.running) return false;
|
|
480
|
+
if (this.abortRun) this.abortRun(reason);
|
|
481
|
+
try { await this.running; } catch { /* the aborted run's own error is not interesting */ }
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
398
485
|
async _sync({ folders, maxPerFolder, passes }) {
|
|
399
486
|
const started = Date.now();
|
|
400
487
|
const summary = { added: 0, removed: 0, folders: {} };
|
|
401
488
|
let targets;
|
|
402
|
-
{
|
|
489
|
+
try {
|
|
403
490
|
const conn = await this.session.acquire();
|
|
404
491
|
try {
|
|
405
492
|
const all = await this._targetFolders(conn);
|
|
@@ -408,6 +495,14 @@ export class LocalIndex {
|
|
|
408
495
|
} finally {
|
|
409
496
|
conn.end();
|
|
410
497
|
}
|
|
498
|
+
} catch (err) {
|
|
499
|
+
if (err.code !== 'IMAP_TIMEOUT') throw err;
|
|
500
|
+
// The folder LIST on a dead connection: the run ends here, recorded like
|
|
501
|
+
// any other timed-out command, and the next scheduled sync reconnects.
|
|
502
|
+
summary.aborted = err.message;
|
|
503
|
+
summary.duration_ms = Date.now() - started;
|
|
504
|
+
this.log(`[index] sync stopped: ${err.message}`);
|
|
505
|
+
return summary;
|
|
411
506
|
}
|
|
412
507
|
|
|
413
508
|
let pass = 0;
|
|
@@ -522,6 +617,8 @@ export class LocalIndex {
|
|
|
522
617
|
added += await this._indexBatch(conn, folder, uidvalidity, todo.slice(i, i + this.batchSize));
|
|
523
618
|
}
|
|
524
619
|
|
|
620
|
+
await this._backfillThreadHeaders(conn, folder);
|
|
621
|
+
|
|
525
622
|
const pending = missing.length - todo.length;
|
|
526
623
|
const indexed = this.db.prepare('SELECT count(*) AS n FROM messages WHERE folder = ?').get(folder).n;
|
|
527
624
|
this.db.prepare(`INSERT INTO folders (name, uidvalidity, server_count, indexed_count, backfill_complete, last_sync, last_error)
|
|
@@ -536,8 +633,47 @@ export class LocalIndex {
|
|
|
536
633
|
}
|
|
537
634
|
}
|
|
538
635
|
|
|
636
|
+
/**
|
|
637
|
+
* 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.
|
|
640
|
+
*/
|
|
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;
|
|
646
|
+
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) {
|
|
649
|
+
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');
|
|
655
|
+
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++;
|
|
663
|
+
}
|
|
664
|
+
this.db.exec('COMMIT');
|
|
665
|
+
} catch (err) {
|
|
666
|
+
this.db.exec('ROLLBACK');
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (done) this.log(`[index] ${folder}: threading headers backfilled for ${done} message(s)`);
|
|
671
|
+
return done;
|
|
672
|
+
}
|
|
673
|
+
|
|
539
674
|
async _indexBatch(conn, folder, uidvalidity, uids) {
|
|
540
675
|
const meta = await fetchMessages(conn, uids, { bodies: [HEADER_FIELDS], struct: true, size: true });
|
|
676
|
+
this._assertLock(); // a run cut loose by the cap must not write after the fact
|
|
541
677
|
const parseHeader = conn.imap.constructor.parseHeader;
|
|
542
678
|
|
|
543
679
|
// Group messages by the exact set of text parts they need, so each group
|
|
@@ -557,10 +693,12 @@ export class LocalIndex {
|
|
|
557
693
|
const fetched = await fetchMessages(conn, groupUids, { bodies: key.split(',') });
|
|
558
694
|
for (const [uid, rec] of fetched) bodies.set(uid, rec.parts);
|
|
559
695
|
}
|
|
696
|
+
this._assertLock();
|
|
560
697
|
|
|
561
698
|
const insMsg = this.db.prepare(`INSERT OR IGNORE INTO messages
|
|
562
|
-
(folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars
|
|
563
|
-
|
|
699
|
+
(folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars,
|
|
700
|
+
in_reply_to, references_ids, thread_headers)
|
|
701
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`);
|
|
564
702
|
const insFts = this.db.prepare('INSERT INTO fts (rowid, subject, from_addr, to_addr, attachments, body) VALUES (?, ?, ?, ?, ?, ?)');
|
|
565
703
|
|
|
566
704
|
let added = 0;
|
|
@@ -582,7 +720,8 @@ export class LocalIndex {
|
|
|
582
720
|
const attachments = plan.attachments.join(', ');
|
|
583
721
|
const internal = rec.attributes.date instanceof Date ? rec.attributes.date.getTime() : null;
|
|
584
722
|
const res = insMsg.run(folder, uid, uidvalidity, internal, (h.date || [null])[0], from, addressText(h.to), addressText(h.cc),
|
|
585
|
-
subject, (h['message-id'] || [null])[0], rec.attributes.size ?? null, attachments, body.length
|
|
723
|
+
subject, (h['message-id'] || [null])[0], rec.attributes.size ?? null, attachments, body.length,
|
|
724
|
+
parseMessageIdList(h['in-reply-to'])[0] || null, parseMessageIdList(h.references).join(' ') || null);
|
|
586
725
|
if (res.changes) {
|
|
587
726
|
insFts.run(Number(res.lastInsertRowid), subject, from, to, attachments, body);
|
|
588
727
|
added++;
|
|
@@ -619,10 +758,16 @@ export class LocalIndex {
|
|
|
619
758
|
db_size_mb: Math.round(sizeBytes / 1024 / 102.4) / 10,
|
|
620
759
|
this_pid: process.pid,
|
|
621
760
|
sync_running: !!this.running || lockLive,
|
|
761
|
+
...(this.running && this.runningSince && {
|
|
762
|
+
sync_running_since: new Date(this.runningSince).toISOString(),
|
|
763
|
+
sync_running_seconds: Math.round((Date.now() - this.runningSince) / 1000)
|
|
764
|
+
}),
|
|
622
765
|
...(lockLive && lock.pid !== process.pid && { sync_running_in_pid: lock.pid }),
|
|
623
766
|
sync_interval_minutes: this.intervalMs / 60000,
|
|
767
|
+
sync_max_minutes: this.maxRunMs / 60000,
|
|
624
768
|
command_timeout_seconds: getOpTimeout() / 1000,
|
|
625
769
|
complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
|
|
770
|
+
thread_headers_pending: this.db.prepare('SELECT count(*) AS n FROM messages WHERE thread_headers = 0').get().n,
|
|
626
771
|
last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
|
|
627
772
|
last_run: lastRun,
|
|
628
773
|
folders
|
|
@@ -673,7 +818,7 @@ export class LocalIndex {
|
|
|
673
818
|
* Messages with no Message-ID header cannot be compared and are reported
|
|
674
819
|
* under coverage rather than silently ignored.
|
|
675
820
|
*/
|
|
676
|
-
findDuplicates({ folders = null, scope = 'all', since_date = null, before_date = null, limit = 50 } = {}) {
|
|
821
|
+
findDuplicates({ folders = null, scope = 'all', since_date = null, before_date = null, limit = 50, offset = 0 } = {}) {
|
|
677
822
|
if (!['all', 'same_folder', 'cross_folder'].includes(scope)) {
|
|
678
823
|
throw new Error(`Unknown scope: ${scope}. Use all, same_folder or cross_folder.`);
|
|
679
824
|
}
|
|
@@ -755,6 +900,8 @@ export class LocalIndex {
|
|
|
755
900
|
}
|
|
756
901
|
out.sort((a, b) => (b.copies - a.copies) || String(b.date || '').localeCompare(String(a.date || '')));
|
|
757
902
|
const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
|
|
903
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
904
|
+
const page = out.slice(skip, skip + cap);
|
|
758
905
|
return {
|
|
759
906
|
scope,
|
|
760
907
|
folders_searched: folderList.length ? folderList : 'all indexed folders',
|
|
@@ -765,8 +912,133 @@ export class LocalIndex {
|
|
|
765
912
|
...(inScope > withId && { note: 'messages without a Message-ID header cannot be compared and are not included' })
|
|
766
913
|
},
|
|
767
914
|
total_groups: out.length,
|
|
768
|
-
returned:
|
|
769
|
-
|
|
915
|
+
returned: page.length,
|
|
916
|
+
offset: skip,
|
|
917
|
+
has_more: skip + page.length < out.length,
|
|
918
|
+
groups: page,
|
|
919
|
+
index: this._indexHealth(folderList)
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Group messages into conversations by their Message-ID chains.
|
|
925
|
+
*
|
|
926
|
+
* Two messages are in the same thread when their Message-ID, In-Reply-To
|
|
927
|
+
* and References ids overlap, transitively. That is how mail clients thread,
|
|
928
|
+
* and it is exact: a renamed subject stays in its thread, two conversations
|
|
929
|
+
* that share a subject do not merge. A message with no chain at all (no
|
|
930
|
+
* In-Reply-To, no References, and nothing pointing at it) is a thread of one.
|
|
931
|
+
*
|
|
932
|
+
* scope:
|
|
933
|
+
* split threads whose messages sit in more than one of the searched
|
|
934
|
+
* folders (default). A thread whose spread is only the same
|
|
935
|
+
* message copied into two folders is flagged copies_only, since
|
|
936
|
+
* that is a duplicate, not a split.
|
|
937
|
+
* all every thread in scope.
|
|
938
|
+
*/
|
|
939
|
+
findThreads({ folders = null, scope = 'split', since_date = null, before_date = null, limit = 50, offset = 0 } = {}) {
|
|
940
|
+
if (!['split', 'all'].includes(scope)) throw new Error(`Unknown scope: ${scope}. Use split or all.`);
|
|
941
|
+
const folderList = Array.isArray(folders) ? folders.filter(Boolean) : (folders ? [folders] : []);
|
|
942
|
+
const where = [];
|
|
943
|
+
const params = [];
|
|
944
|
+
if (folderList.length) {
|
|
945
|
+
where.push(`folder IN (${folderList.map(() => '?').join(', ')})`);
|
|
946
|
+
params.push(...folderList);
|
|
947
|
+
}
|
|
948
|
+
if (since_date) { where.push('internal_date >= ?'); params.push(localDate(since_date).getTime()); }
|
|
949
|
+
if (before_date) { where.push('internal_date < ?'); params.push(localDate(before_date).getTime()); }
|
|
950
|
+
const scopeSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
951
|
+
const rows = this.db.prepare(`
|
|
952
|
+
SELECT id, folder, uid, message_id, in_reply_to, references_ids, subject, from_addr, internal_date, sent_date, thread_headers
|
|
953
|
+
FROM messages ${scopeSql}`).all(...params);
|
|
954
|
+
|
|
955
|
+
// Union-find over ids. Each message joins its own Message-ID with every id
|
|
956
|
+
// it references; a message without a Message-ID gets a private key.
|
|
957
|
+
const parent = new Map();
|
|
958
|
+
const find = (k) => {
|
|
959
|
+
let r = k;
|
|
960
|
+
while (parent.get(r) !== r) r = parent.get(r);
|
|
961
|
+
let c = k;
|
|
962
|
+
while (parent.get(c) !== r) { const n = parent.get(c); parent.set(c, r); c = n; }
|
|
963
|
+
return r;
|
|
964
|
+
};
|
|
965
|
+
const add = (k) => { if (!parent.has(k)) parent.set(k, k); };
|
|
966
|
+
const union = (a, b) => { add(a); add(b); const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(ra, rb); };
|
|
967
|
+
const keyOf = new Map();
|
|
968
|
+
let withHeaders = 0;
|
|
969
|
+
for (const r of rows) {
|
|
970
|
+
const own = normalizeMessageId(r.message_id) || `row:${r.id}`;
|
|
971
|
+
keyOf.set(r.id, own);
|
|
972
|
+
add(own);
|
|
973
|
+
if (r.thread_headers) withHeaders++;
|
|
974
|
+
if (r.in_reply_to) union(own, r.in_reply_to);
|
|
975
|
+
if (r.references_ids) for (const ref of r.references_ids.split(' ')) if (ref) union(own, ref);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const threads = new Map();
|
|
979
|
+
for (const r of rows) {
|
|
980
|
+
const root = find(keyOf.get(r.id));
|
|
981
|
+
if (!threads.has(root)) threads.set(root, []);
|
|
982
|
+
threads.get(root).push(r);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const dateOf = (r) => Number(r.internal_date) || Date.parse(r.sent_date || '') || 0;
|
|
986
|
+
const out = [];
|
|
987
|
+
for (const [root, msgs] of threads) {
|
|
988
|
+
const byFolder = {};
|
|
989
|
+
for (const m of msgs) byFolder[m.folder] = (byFolder[m.folder] || 0) + 1;
|
|
990
|
+
const folderCount = Object.keys(byFolder).length;
|
|
991
|
+
if (scope === 'split' && folderCount < 2) continue;
|
|
992
|
+
const sorted = [...msgs].sort((a, b) => dateOf(a) - dateOf(b));
|
|
993
|
+
const distinctIds = new Set(sorted.map((m) => normalizeMessageId(m.message_id) || `row:${m.id}`));
|
|
994
|
+
// Spread that exists only because the same messages sit in two folders.
|
|
995
|
+
let copiesOnly = false;
|
|
996
|
+
if (folderCount > 1) {
|
|
997
|
+
const perId = new Map();
|
|
998
|
+
for (const m of sorted) {
|
|
999
|
+
const id = normalizeMessageId(m.message_id) || `row:${m.id}`;
|
|
1000
|
+
if (!perId.has(id)) perId.set(id, new Set());
|
|
1001
|
+
perId.get(id).add(m.folder);
|
|
1002
|
+
}
|
|
1003
|
+
copiesOnly = [...perId.values()].every((set) => set.size === folderCount);
|
|
1004
|
+
}
|
|
1005
|
+
const newest = sorted[sorted.length - 1];
|
|
1006
|
+
const oldest = sorted[0];
|
|
1007
|
+
const participants = [...new Set(sorted.map((m) => m.from_addr).filter(Boolean))].slice(0, 12);
|
|
1008
|
+
out.push({
|
|
1009
|
+
thread_key: root.startsWith('row:') ? null : root,
|
|
1010
|
+
subject: oldest.subject || '',
|
|
1011
|
+
latest_subject: newest.subject !== oldest.subject ? newest.subject : undefined,
|
|
1012
|
+
messages: msgs.length,
|
|
1013
|
+
distinct_messages: distinctIds.size,
|
|
1014
|
+
by_folder: byFolder,
|
|
1015
|
+
...(folderCount > 1 && { copies_only: copiesOnly }),
|
|
1016
|
+
oldest: { folder: oldest.folder, uid: Number(oldest.uid), date: oldest.sent_date || new Date(dateOf(oldest)).toISOString(), from: oldest.from_addr },
|
|
1017
|
+
newest: { folder: newest.folder, uid: Number(newest.uid), date: newest.sent_date || new Date(dateOf(newest)).toISOString(), from: newest.from_addr },
|
|
1018
|
+
participants,
|
|
1019
|
+
threading: sorted.every((m) => m.thread_headers) ? 'headers' : 'partial: some messages still lack threading headers',
|
|
1020
|
+
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 }))
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
out.sort((a, b) => String(b.newest.date).localeCompare(String(a.newest.date)));
|
|
1024
|
+
const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
|
|
1025
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
1026
|
+
const page = out.slice(skip, skip + cap);
|
|
1027
|
+
return {
|
|
1028
|
+
scope,
|
|
1029
|
+
folders_searched: folderList.length ? folderList : 'all indexed folders',
|
|
1030
|
+
coverage: {
|
|
1031
|
+
messages_in_scope: rows.length,
|
|
1032
|
+
with_thread_headers: withHeaders,
|
|
1033
|
+
without_thread_headers: rows.length - withHeaders,
|
|
1034
|
+
...(rows.length > withHeaders && { note: 'messages without threading headers are still being backfilled; they thread only by their own Message-ID until then' }),
|
|
1035
|
+
threads_in_scope: threads.size
|
|
1036
|
+
},
|
|
1037
|
+
total_threads: out.length,
|
|
1038
|
+
returned: page.length,
|
|
1039
|
+
offset: skip,
|
|
1040
|
+
has_more: skip + page.length < out.length,
|
|
1041
|
+
threads: page,
|
|
770
1042
|
index: this._indexHealth(folderList)
|
|
771
1043
|
};
|
|
772
1044
|
}
|
|
@@ -799,6 +1071,7 @@ export class LocalIndex {
|
|
|
799
1071
|
const total = this.db.prepare(`SELECT count(*) AS n FROM fts JOIN messages m ON m.id = fts.rowid WHERE ${whereSql}`).get(...params).n;
|
|
800
1072
|
const rows = this.db.prepare(`
|
|
801
1073
|
SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments, m.message_id,
|
|
1074
|
+
m.in_reply_to, m.references_ids,
|
|
802
1075
|
snippet(fts, -1, '[', ']', '...', 16) AS snippet
|
|
803
1076
|
FROM fts JOIN messages m ON m.id = fts.rowid
|
|
804
1077
|
WHERE ${whereSql}
|
|
@@ -818,6 +1091,8 @@ export class LocalIndex {
|
|
|
818
1091
|
to: r.to_addr,
|
|
819
1092
|
subject: r.subject,
|
|
820
1093
|
message_id: normalizeMessageId(r.message_id) || undefined,
|
|
1094
|
+
in_reply_to: r.in_reply_to || undefined,
|
|
1095
|
+
references: r.references_ids ? r.references_ids.split(' ') : undefined,
|
|
821
1096
|
attachments: r.attachments || undefined,
|
|
822
1097
|
snippet: r.snippet
|
|
823
1098
|
}))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengelhard/imap-email-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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",
|