@lengelhard/imap-email-mcp 1.8.0 → 1.8.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/.env.example +1 -0
- package/CHANGELOG.md +12 -0
- package/README.md +4 -3
- package/index.js +29 -6
- package/lib/imap-session.js +55 -2
- package/lib/local-index.js +100 -18
- 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,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `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.
|
|
12
|
+
- `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.
|
|
13
|
+
- `find_duplicates` takes `offset` and returns `has_more`, so more than 500 groups can be paged through instead of partitioned by date.
|
|
14
|
+
|
|
3
15
|
## 1.8.0
|
|
4
16
|
|
|
5
17
|
### 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
|
|
|
@@ -147,8 +148,8 @@ Mail clients that file by tag sometimes append a fresh copy of a message instead
|
|
|
147
148
|
| `get_email` | Get an email by UID. Text by default; HTML (without inline images) on request |
|
|
148
149
|
| `search_emails` | Live IMAP search across one or more folders: subject, from, to, cc, body, text, dates, flags |
|
|
149
150
|
| `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 |
|
|
151
|
+
| `index_status` | Per-folder index coverage, last sync and errors; `sync_running_seconds` shows how long the current sync has run |
|
|
152
|
+
| `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
|
|
152
153
|
| `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
|
|
153
154
|
| `list_drafts` | List all draft emails |
|
|
154
155
|
| `get_draft` | Get a specific draft by UID |
|
package/index.js
CHANGED
|
@@ -447,7 +447,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
447
447
|
},
|
|
448
448
|
{
|
|
449
449
|
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.',
|
|
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.',
|
|
451
451
|
inputSchema: { type: 'object', properties: {}, required: [] }
|
|
452
452
|
},
|
|
453
453
|
{
|
|
@@ -480,6 +480,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
480
480
|
description: 'Maximum groups to return (default: 50, max: 500). total_groups reports the full count.',
|
|
481
481
|
default: 50
|
|
482
482
|
},
|
|
483
|
+
offset: {
|
|
484
|
+
type: 'number',
|
|
485
|
+
description: 'Skip this many groups, for paging (default: 0). has_more says whether another page exists.',
|
|
486
|
+
default: 0
|
|
487
|
+
},
|
|
483
488
|
refresh: {
|
|
484
489
|
type: 'boolean',
|
|
485
490
|
description: 'Pick up new and moved mail before searching (default: true). Skipped while a background sync is running.',
|
|
@@ -491,7 +496,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
491
496
|
},
|
|
492
497
|
{
|
|
493
498
|
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.',
|
|
499
|
+
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
500
|
inputSchema: {
|
|
496
501
|
type: 'object',
|
|
497
502
|
properties: {
|
|
@@ -504,6 +509,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
504
509
|
type: 'number',
|
|
505
510
|
description: 'How long to wait before returning while the sync continues in the background (default: 60)',
|
|
506
511
|
default: 60
|
|
512
|
+
},
|
|
513
|
+
force: {
|
|
514
|
+
type: 'boolean',
|
|
515
|
+
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.',
|
|
516
|
+
default: false
|
|
507
517
|
}
|
|
508
518
|
},
|
|
509
519
|
required: []
|
|
@@ -1982,7 +1992,8 @@ async function handleTool(name, args) {
|
|
|
1982
1992
|
scope: args.scope || 'all',
|
|
1983
1993
|
since_date: args.since_date || null,
|
|
1984
1994
|
before_date: args.before_date || null,
|
|
1985
|
-
limit: args.limit
|
|
1995
|
+
limit: args.limit,
|
|
1996
|
+
offset: args.offset
|
|
1986
1997
|
});
|
|
1987
1998
|
if (refresh) found.index = { ...found.index, refresh };
|
|
1988
1999
|
return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
|
|
@@ -1991,7 +2002,16 @@ async function handleTool(name, args) {
|
|
|
1991
2002
|
case 'sync_index': {
|
|
1992
2003
|
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
1993
2004
|
const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
|
|
1994
|
-
|
|
2005
|
+
let alreadyRunning = !!localIndex.running;
|
|
2006
|
+
let forced = false;
|
|
2007
|
+
if (alreadyRunning && args.force) {
|
|
2008
|
+
const stuckFor = localIndex.runningSince ? Math.round((Date.now() - localIndex.runningSince) / 1000) : null;
|
|
2009
|
+
forced = await Promise.race([
|
|
2010
|
+
localIndex.abortSync(`forced by sync_index after ${stuckFor}s`),
|
|
2011
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 15000))
|
|
2012
|
+
]);
|
|
2013
|
+
alreadyRunning = !!localIndex.running;
|
|
2014
|
+
}
|
|
1995
2015
|
const run = localIndex.sync(args.folders?.length ? { folders: args.folders } : {});
|
|
1996
2016
|
const outcome = await Promise.race([
|
|
1997
2017
|
run.then((summary) => ({ finished: true, summary })),
|
|
@@ -2002,7 +2022,9 @@ async function handleTool(name, args) {
|
|
|
2002
2022
|
content: [{
|
|
2003
2023
|
type: 'text',
|
|
2004
2024
|
text: JSON.stringify({
|
|
2005
|
-
...(
|
|
2025
|
+
...(forced === true && { note: 'A running sync was aborted first (force: true) and a fresh one started.' }),
|
|
2026
|
+
...(forced === 'timeout' && { note: 'force: true was requested but the running sync did not release within 15s; waited on it instead.' }),
|
|
2027
|
+
...(alreadyRunning && !forced && { note: 'A sync was already running; waited on that one.' }),
|
|
2006
2028
|
...(skipped && { note: `Not started: ${outcome.summary.reason}. It keeps the index current; check index_status.` }),
|
|
2007
2029
|
finished: skipped ? false : outcome.finished,
|
|
2008
2030
|
...(outcome.summary && !skipped && { summary: outcome.summary }),
|
|
@@ -2038,7 +2060,8 @@ async function main() {
|
|
|
2038
2060
|
dbPath: process.env.INDEX_DB_PATH || undefined,
|
|
2039
2061
|
folders: list(process.env.INDEX_FOLDERS),
|
|
2040
2062
|
excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
|
|
2041
|
-
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5')
|
|
2063
|
+
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5'),
|
|
2064
|
+
maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15')
|
|
2042
2065
|
}).open();
|
|
2043
2066
|
localIndex.start();
|
|
2044
2067
|
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);
|
|
@@ -134,17 +135,23 @@ export function normalizeMessageId(value) {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
export class LocalIndex {
|
|
137
|
-
constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
|
|
138
|
+
constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, maxRunMinutes = DEFAULT_MAX_RUN_MINUTES, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
|
|
138
139
|
this.imapConfig = imapConfig;
|
|
139
140
|
this.dbPath = dbPath || defaultDbPath(imapConfig.imap.user, imapConfig.imap.host);
|
|
140
141
|
this.folders = folders && folders.length ? folders : null;
|
|
141
142
|
this.excludeFolders = excludeFolders.map((f) => f.toLowerCase());
|
|
142
143
|
this.intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
|
|
144
|
+
// Wall-clock cap on one sync run. 0 disables it. Fractions are allowed for tests.
|
|
145
|
+
const cap = Number(maxRunMinutes);
|
|
146
|
+
this.maxRunMs = Number.isFinite(cap) && cap >= 0 ? cap * 60 * 1000 : DEFAULT_MAX_RUN_MINUTES * 60 * 1000;
|
|
143
147
|
this.batchSize = batchSize;
|
|
144
148
|
this.maxBodyChars = maxBodyChars;
|
|
145
149
|
this.log = log;
|
|
146
150
|
this.db = null;
|
|
147
151
|
this.running = null;
|
|
152
|
+
this.runningSince = 0;
|
|
153
|
+
this.abortRun = null;
|
|
154
|
+
this.stopped = false;
|
|
148
155
|
this.timer = null;
|
|
149
156
|
this.progressAt = 0;
|
|
150
157
|
this.debounce = null;
|
|
@@ -206,19 +213,31 @@ export class LocalIndex {
|
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
start() {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
// The next scheduled sync is armed when the previous one ends, so a slow
|
|
217
|
+
// or stuck run can never queue a pile-up behind itself.
|
|
218
|
+
const arm = (delay) => {
|
|
219
|
+
clearTimeout(this.timer);
|
|
220
|
+
if (this.stopped) return;
|
|
221
|
+
this.timer = setTimeout(kick, delay);
|
|
222
|
+
if (this.timer.unref) this.timer.unref();
|
|
213
223
|
};
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
224
|
+
const kick = async () => {
|
|
225
|
+
try {
|
|
226
|
+
const last = Number(this._meta('last_full_sync') || 0);
|
|
227
|
+
if (Date.now() - last >= this.intervalMs / 2) await this.sync();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
this.log(`[index] sync failed: ${err.message}`);
|
|
230
|
+
} finally {
|
|
231
|
+
arm(this.intervalMs);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
this.stopped = false;
|
|
235
|
+
arm(5000);
|
|
218
236
|
}
|
|
219
237
|
|
|
220
238
|
stop() {
|
|
221
|
-
|
|
239
|
+
this.stopped = true;
|
|
240
|
+
clearTimeout(this.timer);
|
|
222
241
|
clearTimeout(this.debounce);
|
|
223
242
|
this.releaseLock();
|
|
224
243
|
this.session.close();
|
|
@@ -332,7 +351,7 @@ export class LocalIndex {
|
|
|
332
351
|
}
|
|
333
352
|
|
|
334
353
|
async _targetFolders(connection) {
|
|
335
|
-
const boxes = flattenBoxes(await connection
|
|
354
|
+
const boxes = flattenBoxes(await listBoxes(connection));
|
|
336
355
|
const selectable = boxes.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
|
|
337
356
|
if (this.folders) {
|
|
338
357
|
const wanted = new Set(this.folders.map((f) => f.toLowerCase()));
|
|
@@ -353,6 +372,7 @@ export class LocalIndex {
|
|
|
353
372
|
*/
|
|
354
373
|
sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
|
|
355
374
|
if (this.running) return this.running;
|
|
375
|
+
this.runningSince = Date.now();
|
|
356
376
|
this.running = (async () => {
|
|
357
377
|
if (!this._acquireLock()) {
|
|
358
378
|
return { skipped: true, reason: `another server process (pid ${this.lockHolder}) is syncing the index` };
|
|
@@ -373,7 +393,7 @@ export class LocalIndex {
|
|
|
373
393
|
}, LOCK_HEARTBEAT_MS);
|
|
374
394
|
if (heartbeat.unref) heartbeat.unref();
|
|
375
395
|
try {
|
|
376
|
-
const summary = await this._sync({ folders, maxPerFolder, passes });
|
|
396
|
+
const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes }));
|
|
377
397
|
if (this.db) {
|
|
378
398
|
if (!folders && passes === Infinity && !summary.aborted) this._setMeta('last_full_sync', Date.now());
|
|
379
399
|
this._setMeta('last_run', JSON.stringify({
|
|
@@ -391,15 +411,58 @@ export class LocalIndex {
|
|
|
391
411
|
clearInterval(heartbeat);
|
|
392
412
|
this.releaseLock();
|
|
393
413
|
}
|
|
394
|
-
})().finally(() => { this.running = null; });
|
|
414
|
+
})().finally(() => { this.running = null; this.runningSince = 0; this.abortRun = null; });
|
|
395
415
|
return this.running;
|
|
396
416
|
}
|
|
397
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Run one sync body under a wall-clock cap and an abort handle. A run that
|
|
420
|
+
* outlives the cap, or is aborted through abortSync(), is cut loose: its
|
|
421
|
+
* connection is dropped so any command it is waiting on fails, the caller
|
|
422
|
+
* releases the lock, and the orphaned body cannot touch the index again
|
|
423
|
+
* because _assertLock() throws once the lock is gone.
|
|
424
|
+
*/
|
|
425
|
+
_runCapped(body) {
|
|
426
|
+
const started = Date.now();
|
|
427
|
+
let capTimer = null;
|
|
428
|
+
const cut = (reason) => ({ added: 0, removed: 0, folders: {}, aborted: reason, duration_ms: Date.now() - started, cut: true });
|
|
429
|
+
const stop = new Promise((resolve) => {
|
|
430
|
+
this.abortRun = (reason) => resolve(cut(reason));
|
|
431
|
+
if (this.maxRunMs > 0) {
|
|
432
|
+
const minutes = this.maxRunMs / 60000;
|
|
433
|
+
capTimer = setTimeout(() => this.abortRun(`sync exceeded ${minutes} minute${minutes === 1 ? '' : 's'}, aborted`), this.maxRunMs);
|
|
434
|
+
if (capTimer.unref) capTimer.unref();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
const guarded = body().catch((err) => ({ error: err }));
|
|
438
|
+
return Promise.race([guarded, stop]).then((summary) => {
|
|
439
|
+
clearTimeout(capTimer);
|
|
440
|
+
if (summary.error) throw summary.error;
|
|
441
|
+
if (summary.cut) {
|
|
442
|
+
delete summary.cut;
|
|
443
|
+
this.session.dropCurrent(summary.aborted);
|
|
444
|
+
this.log(`[index] sync stopped: ${summary.aborted}`);
|
|
445
|
+
}
|
|
446
|
+
return summary;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Abort a running sync so a fresh one can start. Resolves once the run has
|
|
452
|
+
* been released. Returns false when nothing was running.
|
|
453
|
+
*/
|
|
454
|
+
async abortSync(reason = 'aborted') {
|
|
455
|
+
if (!this.running) return false;
|
|
456
|
+
if (this.abortRun) this.abortRun(reason);
|
|
457
|
+
try { await this.running; } catch { /* the aborted run's own error is not interesting */ }
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
398
461
|
async _sync({ folders, maxPerFolder, passes }) {
|
|
399
462
|
const started = Date.now();
|
|
400
463
|
const summary = { added: 0, removed: 0, folders: {} };
|
|
401
464
|
let targets;
|
|
402
|
-
{
|
|
465
|
+
try {
|
|
403
466
|
const conn = await this.session.acquire();
|
|
404
467
|
try {
|
|
405
468
|
const all = await this._targetFolders(conn);
|
|
@@ -408,6 +471,14 @@ export class LocalIndex {
|
|
|
408
471
|
} finally {
|
|
409
472
|
conn.end();
|
|
410
473
|
}
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (err.code !== 'IMAP_TIMEOUT') throw err;
|
|
476
|
+
// The folder LIST on a dead connection: the run ends here, recorded like
|
|
477
|
+
// any other timed-out command, and the next scheduled sync reconnects.
|
|
478
|
+
summary.aborted = err.message;
|
|
479
|
+
summary.duration_ms = Date.now() - started;
|
|
480
|
+
this.log(`[index] sync stopped: ${err.message}`);
|
|
481
|
+
return summary;
|
|
411
482
|
}
|
|
412
483
|
|
|
413
484
|
let pass = 0;
|
|
@@ -538,6 +609,7 @@ export class LocalIndex {
|
|
|
538
609
|
|
|
539
610
|
async _indexBatch(conn, folder, uidvalidity, uids) {
|
|
540
611
|
const meta = await fetchMessages(conn, uids, { bodies: [HEADER_FIELDS], struct: true, size: true });
|
|
612
|
+
this._assertLock(); // a run cut loose by the cap must not write after the fact
|
|
541
613
|
const parseHeader = conn.imap.constructor.parseHeader;
|
|
542
614
|
|
|
543
615
|
// Group messages by the exact set of text parts they need, so each group
|
|
@@ -557,6 +629,7 @@ export class LocalIndex {
|
|
|
557
629
|
const fetched = await fetchMessages(conn, groupUids, { bodies: key.split(',') });
|
|
558
630
|
for (const [uid, rec] of fetched) bodies.set(uid, rec.parts);
|
|
559
631
|
}
|
|
632
|
+
this._assertLock();
|
|
560
633
|
|
|
561
634
|
const insMsg = this.db.prepare(`INSERT OR IGNORE INTO messages
|
|
562
635
|
(folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars)
|
|
@@ -619,8 +692,13 @@ export class LocalIndex {
|
|
|
619
692
|
db_size_mb: Math.round(sizeBytes / 1024 / 102.4) / 10,
|
|
620
693
|
this_pid: process.pid,
|
|
621
694
|
sync_running: !!this.running || lockLive,
|
|
695
|
+
...(this.running && this.runningSince && {
|
|
696
|
+
sync_running_since: new Date(this.runningSince).toISOString(),
|
|
697
|
+
sync_running_seconds: Math.round((Date.now() - this.runningSince) / 1000)
|
|
698
|
+
}),
|
|
622
699
|
...(lockLive && lock.pid !== process.pid && { sync_running_in_pid: lock.pid }),
|
|
623
700
|
sync_interval_minutes: this.intervalMs / 60000,
|
|
701
|
+
sync_max_minutes: this.maxRunMs / 60000,
|
|
624
702
|
command_timeout_seconds: getOpTimeout() / 1000,
|
|
625
703
|
complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
|
|
626
704
|
last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
|
|
@@ -673,7 +751,7 @@ export class LocalIndex {
|
|
|
673
751
|
* Messages with no Message-ID header cannot be compared and are reported
|
|
674
752
|
* under coverage rather than silently ignored.
|
|
675
753
|
*/
|
|
676
|
-
findDuplicates({ folders = null, scope = 'all', since_date = null, before_date = null, limit = 50 } = {}) {
|
|
754
|
+
findDuplicates({ folders = null, scope = 'all', since_date = null, before_date = null, limit = 50, offset = 0 } = {}) {
|
|
677
755
|
if (!['all', 'same_folder', 'cross_folder'].includes(scope)) {
|
|
678
756
|
throw new Error(`Unknown scope: ${scope}. Use all, same_folder or cross_folder.`);
|
|
679
757
|
}
|
|
@@ -755,6 +833,8 @@ export class LocalIndex {
|
|
|
755
833
|
}
|
|
756
834
|
out.sort((a, b) => (b.copies - a.copies) || String(b.date || '').localeCompare(String(a.date || '')));
|
|
757
835
|
const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
|
|
836
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
837
|
+
const page = out.slice(skip, skip + cap);
|
|
758
838
|
return {
|
|
759
839
|
scope,
|
|
760
840
|
folders_searched: folderList.length ? folderList : 'all indexed folders',
|
|
@@ -765,8 +845,10 @@ export class LocalIndex {
|
|
|
765
845
|
...(inScope > withId && { note: 'messages without a Message-ID header cannot be compared and are not included' })
|
|
766
846
|
},
|
|
767
847
|
total_groups: out.length,
|
|
768
|
-
returned:
|
|
769
|
-
|
|
848
|
+
returned: page.length,
|
|
849
|
+
offset: skip,
|
|
850
|
+
has_more: skip + page.length < out.length,
|
|
851
|
+
groups: page,
|
|
770
852
|
index: this._indexHealth(folderList)
|
|
771
853
|
};
|
|
772
854
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengelhard/imap-email-mcp",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.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",
|