@lengelhard/imap-email-mcp 1.7.2 → 1.7.4

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
@@ -12,6 +12,8 @@ IMAP_HOST=imap.example.com
12
12
  # IMAP_AUTH_TIMEOUT=10000
13
13
  # IMAP_TLS_REJECT_UNAUTHORIZED=true
14
14
  # IMAP_IDLE_CLOSE_SECONDS=300
15
+ # IMAP_OP_TIMEOUT_SECONDS=120
16
+ # SENDER_NAME=
15
17
 
16
18
  # Optional - Local read-only search index (requires Node 22.13+)
17
19
  # INDEX_ENABLED=false
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.4
4
+
5
+ ### Added
6
+ - `search_index` results carry `index.warning` when the index may be behind: the last full sync finished more than 15 minutes ago (for example "index last synced 3 hours ago, results may be missing recent mail"), the index has never completed a full sync, or a searched folder recorded an error. The existing `index` fields are unchanged. This makes a stalled background sync visible in every search instead of silent, following the overnight freeze of 18 Sep 2026.
7
+
8
+ ## 1.7.3
9
+
10
+ ### Fixed
11
+ - A sync could hang forever and never release its lock, which silently froze the index until the server was restarted. This happened when the IMAP connection went silent without closing, for example after the machine slept: the underlying library never fails a command whose connection died, so the sync waited indefinitely. Every IMAP command now runs under a time limit (`IMAP_OP_TIMEOUT_SECONDS`, default 120). A command that expires drops the socket, the sync stops with the folder's error recorded, the lock is released, and the next scheduled sync reconnects.
12
+ - The sync lock's heartbeat now stops when a run makes no progress for 5 minutes, so another server process can take over even if a run is stuck for some other reason.
13
+ - Tool calls on a dead connection now fail with a clear timeout message instead of waiting out the 5-minute lease.
14
+ - A connection idle for over a minute is checked with a cheap command before reuse, so the first call after a sleep reconnects in seconds instead of waiting for a timeout.
15
+ - A socket-level inactivity timeout was added as a second line of defence.
16
+
17
+ ### Changed
18
+ - `index_status` reports `command_timeout_seconds`.
19
+
20
+ ## 1.7.2
21
+
22
+ ### Added
23
+ - `SENDER_NAME`: optional display name for outgoing mail. The From header becomes `Name <address>`, with non-ASCII names encoded. Unset means the bare address, as before.
24
+
3
25
  ## 1.7.1
4
26
 
5
27
  ### Fixed
package/README.md CHANGED
@@ -98,6 +98,8 @@ Add to your config file:
98
98
  | `SMTP_PORT` | `465` | SMTP port |
99
99
  | `SMTP_SECURE` | `true` | Use secure SMTP |
100
100
  | `IMAP_IDLE_CLOSE_SECONDS` | `300` | Close the shared IMAP connection after this much inactivity |
101
+ | `IMAP_OP_TIMEOUT_SECONDS` | `120` | Time limit for a single IMAP command. Set `0` to disable |
102
+ | `SENDER_NAME` | none | Display name for outgoing mail (From becomes `Name <address>`) |
101
103
  | `INDEX_ENABLED` | `false` | Enable the local read-only search index |
102
104
  | `INDEX_DB_PATH` | see below | Location of the index database |
103
105
  | `INDEX_FOLDERS` | all but Trash/Junk | Comma-separated folders to index |
@@ -113,6 +115,7 @@ Some IMAP servers return incomplete results for body searches without reporting
113
115
  - **Sync.** Newest mail first, in the background, every few minutes. Moves and deletes are picked up on the next sync; moves made through this server trigger a sync of the affected folders within seconds.
114
116
  - **Storage.** A single SQLite file, created with owner-only permissions. Default location on macOS: `~/Library/Application Support/imap-email-mcp/index-<account>.sqlite`; elsewhere `~/.local/share/imap-email-mcp/`. It contains the text of your mail, so keep the disk encrypted. Deleting the file is safe; it is rebuilt on the next start.
115
117
  - **Several processes.** Some clients start more than one copy of the server. They share the index file: one process syncs at a time (a lock file next to the database), and all of them answer searches. A lock left by a process that died is taken over after at most 3 minutes.
118
+ - **Recovery.** If the connection dies silently, the running sync gives up at the command time limit, records the error and releases its lock. The next sync reconnects on its own.
116
119
  - **Requirements.** Node 22.13 or later (uses the built-in `node:sqlite`, no native modules).
117
120
 
118
121
  ### Provider Settings
@@ -133,7 +136,7 @@ Some IMAP servers return incomplete results for body searches without reporting
133
136
  | `list_emails` | List emails newest first, with unread, flagged and date filters and paging |
134
137
  | `get_email` | Get an email by UID. Text by default; HTML (without inline images) on request |
135
138
  | `search_emails` | Live IMAP search across one or more folders: subject, from, to, cc, body, text, dates, flags |
136
- | `search_index` | Full-text search of the local index (when `INDEX_ENABLED=true`) |
139
+ | `search_index` | Full-text search of the local index (when `INDEX_ENABLED=true`); `index.warning` flags a stale index or folder errors |
137
140
  | `index_status` | Per-folder index coverage, last sync and errors |
138
141
  | `sync_index` | Run an index sync now |
139
142
  | `list_drafts` | List all draft emails |
package/index.js CHANGED
@@ -22,6 +22,7 @@ import path from 'path';
22
22
  import os from 'os';
23
23
  import { createRequire } from 'module';
24
24
  import {
25
+ setOpTimeout,
25
26
  SessionManager,
26
27
  openBox,
27
28
  queryUids,
@@ -377,7 +378,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
377
378
  ...(localIndex ? [
378
379
  {
379
380
  name: 'search_index',
380
- description: 'Full-text search of the local read-only mail index (subject, sender, recipients, attachment names and body text). Accent-insensitive: "Loic" matches "Loïc". Use this for content searches. Results carry folder and uid for get_email; a uid can be stale if the message was moved since the last sync, in which case get_email reports it as not found. Check index.complete in the response before treating zero results as proof that nothing exists.',
381
+ description: 'Full-text search of the local read-only mail index (subject, sender, recipients, attachment names and body text). Accent-insensitive: "Loic" matches "Loïc". Use this for content searches. Results carry folder and uid for get_email; a uid can be stale if the message was moved since the last sync, in which case get_email reports it as not found. Check index.complete in the response before treating zero results as proof that nothing exists. index.warning is present when the last full sync is older than 15 minutes or a searched folder has an error; pass it on to the user.',
381
382
  inputSchema: {
382
383
  type: 'object',
383
384
  properties: {
@@ -792,6 +793,8 @@ function normalizeSubject(subject) {
792
793
  return subject.replace(/^(\s*(Re|Fwd|Fw)\s*:\s*)+/i, '').trim();
793
794
  }
794
795
 
796
+ setOpTimeout(parseFloat(process.env.IMAP_OP_TIMEOUT_SECONDS || '120') * 1000);
797
+
795
798
  // Shared IMAP session. connectIMAP() returns a lease on one persistent
796
799
  // connection; connection.end() releases the lease instead of logging out.
797
800
  const sessions = new SessionManager(IMAP_CONFIG, {
@@ -17,13 +17,61 @@ import imaps from 'imap-simple';
17
17
 
18
18
  const DEFAULT_IDLE_CLOSE_MS = 5 * 60 * 1000;
19
19
  const DEFAULT_LEASE_TIMEOUT_MS = 5 * 60 * 1000;
20
+ const DEFAULT_OP_TIMEOUT_MS = 120 * 1000;
21
+ const VALIDATE_AFTER_IDLE_MS = 60 * 1000;
22
+ const VALIDATE_TIMEOUT_MS = 10 * 1000;
23
+
24
+ /**
25
+ * A command that never comes back.
26
+ *
27
+ * node-imap does not fail pending commands when the socket dies, so a
28
+ * connection that goes silent (a sleeping laptop, a server that drops the
29
+ * connection without a FIN) leaves the caller waiting forever. Every IMAP
30
+ * command therefore runs under its own time limit.
31
+ */
32
+ export class ImapTimeoutError extends Error {
33
+ constructor(label, ms) {
34
+ super(`IMAP ${label} timed out after ${Math.round(ms / 1000)}s (connection dropped, will reconnect)`);
35
+ this.code = 'IMAP_TIMEOUT';
36
+ }
37
+ }
38
+
39
+ let opTimeoutMs = DEFAULT_OP_TIMEOUT_MS;
40
+
41
+ /** Set the per-command time limit. 0 disables it. */
42
+ export function setOpTimeout(ms) {
43
+ opTimeoutMs = Number(ms) >= 0 ? Number(ms) : DEFAULT_OP_TIMEOUT_MS;
44
+ }
45
+
46
+ export function getOpTimeout() {
47
+ return opTimeoutMs;
48
+ }
49
+
50
+ /** Run one IMAP command under a time limit, destroying the socket if it expires. */
51
+ export function withTimeout(connection, label, promise, ms = opTimeoutMs) {
52
+ if (!ms) return promise;
53
+ let timer;
54
+ const guard = new Promise((_, reject) => {
55
+ timer = setTimeout(() => {
56
+ // Kill the socket so the session opens a fresh one next time.
57
+ try { connection.imap.destroy(); } catch { /* ignore */ }
58
+ reject(new ImapTimeoutError(label, ms));
59
+ }, ms);
60
+ });
61
+ return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
62
+ }
20
63
 
21
64
  function withKeepalive(config) {
22
65
  // NOOP-based keepalive is more predictable than IDLE behind proxies.
66
+ // socketTimeout is a second line of defence: it kills a socket that goes
67
+ // quiet even when no command is pending.
23
68
  return {
24
69
  ...config,
25
70
  imap: {
26
71
  keepalive: { interval: 60000, idleInterval: 300000, forceNoop: true },
72
+ connTimeout: 20000,
73
+ authTimeout: 10000,
74
+ socketTimeout: opTimeoutMs ? opTimeoutMs + 60000 : 0,
27
75
  ...config.imap
28
76
  }
29
77
  };
@@ -38,6 +86,7 @@ export class SessionManager {
38
86
  this.log = log;
39
87
  this.conn = null;
40
88
  this.connecting = null;
89
+ this.lastUsedAt = 0;
41
90
  this.tail = Promise.resolve();
42
91
  this.idleTimer = null;
43
92
  }
@@ -100,6 +149,19 @@ export class SessionManager {
100
149
  let conn;
101
150
  try {
102
151
  conn = await this._connect();
152
+ // A connection that has been idle may be dead without knowing it
153
+ // (laptop sleep, silent server drop). Check it cheaply first so the
154
+ // caller does not wait out the full command timeout.
155
+ if (this.lastUsedAt && Date.now() - this.lastUsedAt > VALIDATE_AFTER_IDLE_MS) {
156
+ try {
157
+ await withTimeout(conn, 'STATUS (health check)', new Promise((resolve, reject) => {
158
+ conn.imap.status('INBOX', (err) => (err ? reject(err) : resolve()));
159
+ }), VALIDATE_TIMEOUT_MS);
160
+ } catch (err) {
161
+ this._drop(conn, `health check failed: ${err.message}`);
162
+ conn = await this._connect();
163
+ }
164
+ }
103
165
  } catch (err) {
104
166
  releaseLock();
105
167
  this._armIdleTimer();
@@ -110,6 +172,7 @@ export class SessionManager {
110
172
  const release = () => {
111
173
  if (released) return;
112
174
  released = true;
175
+ this.lastUsedAt = Date.now();
113
176
  clearTimeout(watchdog);
114
177
  if (!this._isUsable(conn)) this._drop(conn, 'unusable after call');
115
178
  this._armIdleTimer();
@@ -141,13 +204,24 @@ export class SessionManager {
141
204
 
142
205
  /** Open a mailbox. readOnly uses EXAMINE, which never changes flags. */
143
206
  export function openBox(connection, name, readOnly = false) {
144
- return new Promise((resolve, reject) => {
207
+ return withTimeout(connection, `open ${name}`, new Promise((resolve, reject) => {
145
208
  try {
146
209
  connection.imap.openBox(name, readOnly, (err, box) => (err ? reject(err) : resolve(box)));
147
210
  } catch (err) {
148
211
  reject(err);
149
212
  }
150
- });
213
+ }));
214
+ }
215
+
216
+ /** UID SEARCH ALL for the open mailbox. */
217
+ export function uidSearchAll(connection) {
218
+ return withTimeout(connection, 'search', new Promise((resolve, reject) => {
219
+ try {
220
+ connection.imap.search(['ALL'], (err, uids) => (err ? reject(err) : resolve((uids || []).map(Number))));
221
+ } catch (err) {
222
+ reject(err);
223
+ }
224
+ }));
151
225
  }
152
226
 
153
227
  export function serverSupports(connection, cap) {
@@ -163,7 +237,7 @@ export function queryUids(connection, criteria, { order = 'arrival' } = {}) {
163
237
  const imap = connection.imap;
164
238
  const crit = criteria && criteria.length ? criteria : ['ALL'];
165
239
  const key = order === 'date' ? '-DATE' : '-ARRIVAL';
166
- return new Promise((resolve, reject) => {
240
+ return withTimeout(connection, 'search', new Promise((resolve, reject) => {
167
241
  const done = (err, uids) => (err ? reject(err) : resolve((uids || []).map(Number)));
168
242
  try {
169
243
  if (serverSupports(connection, 'SORT')) {
@@ -177,7 +251,7 @@ export function queryUids(connection, criteria, { order = 'arrival' } = {}) {
177
251
  } catch (err) {
178
252
  reject(err);
179
253
  }
180
- });
254
+ }));
181
255
  }
182
256
 
183
257
  /**
@@ -186,9 +260,9 @@ export function queryUids(connection, criteria, { order = 'arrival' } = {}) {
186
260
  * exist are simply absent from the result.
187
261
  */
188
262
  export function fetchMessages(connection, uids, options) {
189
- return new Promise((resolve, reject) => {
263
+ if (!uids || !uids.length) return Promise.resolve(new Map());
264
+ return withTimeout(connection, `fetch ${uids.length} message(s)`, new Promise((resolve, reject) => {
190
265
  const out = new Map();
191
- if (!uids || !uids.length) return resolve(out);
192
266
  let f;
193
267
  try {
194
268
  f = connection.imap.fetch(uids, options);
@@ -209,7 +283,7 @@ export function fetchMessages(connection, uids, options) {
209
283
  });
210
284
  f.once('error', reject);
211
285
  f.once('end', () => resolve(out));
212
- });
286
+ }));
213
287
  }
214
288
 
215
289
  /** Find the header buffer in a fetched record and parse it into { name: [values] }. */
@@ -19,14 +19,25 @@ 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 } from './imap-session.js';
22
+ import { SessionManager, openBox, fetchMessages, headerOf, localDate, uidSearchAll, getOpTimeout } from './imap-session.js';
23
23
  import { planParts, partsToText } from './mime-text.js';
24
24
 
25
25
  const SCHEMA_VERSION = '1';
26
26
  const LOCK_STALE_MS = 3 * 60 * 1000;
27
27
  const LOCK_HEARTBEAT_MS = 30 * 1000;
28
28
  const REQUEST_RETRY_MS = 15 * 1000;
29
+ const STALL_MS = 5 * 60 * 1000;
29
30
  const REQUEST_MAX_ATTEMPTS = 6;
31
+ const STALE_AFTER_MS = 15 * 60 * 1000;
32
+
33
+ function describeAge(ms) {
34
+ const minutes = Math.round(ms / 60000);
35
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}`;
36
+ const hours = Math.round(ms / 3600000);
37
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'}`;
38
+ const days = Math.round(ms / 86400000);
39
+ return `${days} day${days === 1 ? '' : 's'}`;
40
+ }
30
41
 
31
42
  class LockLostError extends Error {
32
43
  constructor() {
@@ -118,6 +129,7 @@ export class LocalIndex {
118
129
  this.db = null;
119
130
  this.running = null;
120
131
  this.timer = null;
132
+ this.progressAt = 0;
121
133
  this.debounce = null;
122
134
  this.pendingFolders = new Set();
123
135
  this.session = new SessionManager(imapConfig, { label: 'index', idleCloseMs: 60 * 1000, leaseTimeoutMs: 10 * 60 * 1000, log });
@@ -327,7 +339,20 @@ export class LocalIndex {
327
339
  if (!this._acquireLock()) {
328
340
  return { skipped: true, reason: `another server process (pid ${this.lockHolder}) is syncing the index` };
329
341
  }
330
- const heartbeat = setInterval(() => this._touchLock(), LOCK_HEARTBEAT_MS);
342
+ this.progressAt = Date.now();
343
+ let stallLogged = false;
344
+ const heartbeat = setInterval(() => {
345
+ // Do not keep the lock alive for a run that is no longer moving:
346
+ // another process should be able to take over.
347
+ if (Date.now() - this.progressAt > STALL_MS) {
348
+ if (!stallLogged) {
349
+ stallLogged = true;
350
+ this.log('[index] sync has made no progress for 5 minutes, releasing the lock to another process');
351
+ }
352
+ return;
353
+ }
354
+ this._touchLock();
355
+ }, LOCK_HEARTBEAT_MS);
331
356
  if (heartbeat.unref) heartbeat.unref();
332
357
  try {
333
358
  const summary = await this._sync({ folders, maxPerFolder, passes });
@@ -391,6 +416,14 @@ export class LocalIndex {
391
416
  this.log(`[index] ${folder}: ${r.attempted} messages could not be fetched, will retry next sync`);
392
417
  }
393
418
  } catch (err) {
419
+ if (err.code === 'IMAP_TIMEOUT') {
420
+ summary.aborted = err.message;
421
+ this.db?.prepare(`INSERT INTO folders (name, last_error, last_sync) VALUES (?, ?, ?)
422
+ ON CONFLICT(name) DO UPDATE SET last_error = excluded.last_error`)
423
+ .run(folder, String(err.message).slice(0, 500), Date.now());
424
+ this.log(`[index] sync stopped: ${err.message}`);
425
+ break outer;
426
+ }
394
427
  if (err.code === 'LOCK_LOST' || !this.db) {
395
428
  summary.aborted = err.code === 'LOCK_LOST' ? err.message : 'index closed';
396
429
  this.log(`[index] sync stopped: ${summary.aborted}`);
@@ -432,6 +465,7 @@ export class LocalIndex {
432
465
 
433
466
  async _syncFolder(folder, maxPerFolder) {
434
467
  this._assertLock();
468
+ this.progressAt = Date.now();
435
469
  const conn = await this.session.acquire();
436
470
  try {
437
471
  const box = await openBox(conn, folder, true);
@@ -442,11 +476,7 @@ export class LocalIndex {
442
476
  this._wipeFolder(folder);
443
477
  }
444
478
 
445
- const serverUids = box.messages.total
446
- ? await new Promise((resolve, reject) => {
447
- try { conn.imap.search(['ALL'], (e, u) => (e ? reject(e) : resolve((u || []).map(Number)))); } catch (e) { reject(e); }
448
- })
449
- : [];
479
+ const serverUids = box.messages.total ? await uidSearchAll(conn) : [];
450
480
  const serverSet = new Set(serverUids);
451
481
  const local = this.db.prepare('SELECT id, uid FROM messages WHERE folder = ?').all(folder);
452
482
  const localSet = new Set(local.map((r) => Number(r.uid)));
@@ -470,6 +500,7 @@ export class LocalIndex {
470
500
  let added = 0;
471
501
  for (let i = 0; i < todo.length; i += this.batchSize) {
472
502
  this._assertLock();
503
+ this.progressAt = Date.now();
473
504
  added += await this._indexBatch(conn, folder, uidvalidity, todo.slice(i, i + this.batchSize));
474
505
  }
475
506
 
@@ -572,6 +603,7 @@ export class LocalIndex {
572
603
  sync_running: !!this.running || lockLive,
573
604
  ...(lockLive && lock.pid !== process.pid && { sync_running_in_pid: lock.pid }),
574
605
  sync_interval_minutes: this.intervalMs / 60000,
606
+ command_timeout_seconds: getOpTimeout() / 1000,
575
607
  complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
576
608
  last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
577
609
  last_run: lastRun,
@@ -615,6 +647,17 @@ export class LocalIndex {
615
647
 
616
648
  const st = this.status();
617
649
  const scope = folderList.length ? st.folders.filter((f) => folderList.includes(f.folder)) : st.folders;
650
+ const warnings = [];
651
+ const lastFullMs = Number(this._meta('last_full_sync') || 0);
652
+ if (!lastFullMs) {
653
+ warnings.push('index has never completed a full sync, results may be missing mail');
654
+ } else if (Date.now() - lastFullMs > STALE_AFTER_MS) {
655
+ warnings.push(`index last synced ${describeAge(Date.now() - lastFullMs)} ago, results may be missing recent mail`);
656
+ }
657
+ const errored = scope.filter((f) => f.last_error);
658
+ if (errored.length) {
659
+ warnings.push(`folder error${errored.length === 1 ? '' : 's'}: ${errored.map((f) => `${f.folder} (${f.last_error})`).join(', ')}; results from ${errored.length === 1 ? 'that folder' : 'those folders'} may be incomplete`);
660
+ }
618
661
  return {
619
662
  total_matches: total,
620
663
  returned: rows.length,
@@ -623,7 +666,8 @@ export class LocalIndex {
623
666
  complete: scope.length > 0 && scope.every((f) => f.complete && !f.last_error),
624
667
  sync_running: st.sync_running,
625
668
  oldest_folder_sync: scope.reduce((min, f) => (f.last_sync && (!min || f.last_sync < min) ? f.last_sync : min), null),
626
- incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder)
669
+ incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder),
670
+ ...(warnings.length && { warning: warnings.join('; ') })
627
671
  },
628
672
  results: rows.map((r) => ({
629
673
  folder: r.folder,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
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",