@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 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
- const alreadyRunning = !!localIndex.running;
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
- ...(alreadyRunning && { note: 'A sync was already running; waited on that one.' }),
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}`);
@@ -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
- this.connecting = (async () => {
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
- })().finally(() => { this.connecting = null; });
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) => {
@@ -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
- const kick = () => {
210
- const last = Number(this._meta('last_full_sync') || 0);
211
- if (Date.now() - last < this.intervalMs / 2) return;
212
- this.sync().catch((err) => this.log(`[index] sync failed: ${err.message}`));
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 first = setTimeout(kick, 5000);
215
- if (first.unref) first.unref();
216
- this.timer = setInterval(kick, this.intervalMs);
217
- if (this.timer.unref) this.timer.unref();
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
- clearInterval(this.timer);
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.getBoxes());
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: Math.min(out.length, cap),
769
- groups: out.slice(0, cap),
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.0",
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",