@lengelhard/imap-email-mcp 1.7.1 → 1.7.3

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.3
4
+
5
+ ### Fixed
6
+ - 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.
7
+ - 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.
8
+ - Tool calls on a dead connection now fail with a clear timeout message instead of waiting out the 5-minute lease.
9
+ - 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.
10
+ - A socket-level inactivity timeout was added as a second line of defence.
11
+
12
+ ### Changed
13
+ - `index_status` reports `command_timeout_seconds`.
14
+
15
+ ## 1.7.2
16
+
17
+ ### Added
18
+ - `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.
19
+
3
20
  ## 1.7.1
4
21
 
5
22
  ### 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
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,
@@ -130,6 +131,20 @@ const SMTP_CONFIG = {
130
131
  }
131
132
  };
132
133
 
134
+ // Optional display name for outgoing mail. When unset, behaviour is unchanged
135
+ // and the From header is the bare address.
136
+ const SENDER_NAME = process.env.SENDER_NAME || '';
137
+
138
+ // Build an RFC 5322 From value. ASCII names are quoted; anything else is
139
+ // encoded as an RFC 2047 encoded-word so accents survive transport.
140
+ function formatFrom(address) {
141
+ if (!SENDER_NAME) return address;
142
+ const name = /^[\x20-\x7E]*$/.test(SENDER_NAME)
143
+ ? `"${SENDER_NAME.replace(/["\\]/g, '\\$&')}"`
144
+ : `=?UTF-8?B?${Buffer.from(SENDER_NAME, 'utf8').toString('base64')}?=`;
145
+ return `${name} <${address}>`;
146
+ }
147
+
133
148
  // Traverse the raw IMAP MIME struct to find all attachment parts.
134
149
  //
135
150
  // Detection rules (in priority order):
@@ -778,6 +793,8 @@ function normalizeSubject(subject) {
778
793
  return subject.replace(/^(\s*(Re|Fwd|Fw)\s*:\s*)+/i, '').trim();
779
794
  }
780
795
 
796
+ setOpTimeout(parseFloat(process.env.IMAP_OP_TIMEOUT_SECONDS || '120') * 1000);
797
+
781
798
  // Shared IMAP session. connectIMAP() returns a lease on one persistent
782
799
  // connection; connection.end() releases the lease instead of logging out.
783
800
  const sessions = new SessionManager(IMAP_CONFIG, {
@@ -1134,7 +1151,7 @@ async function handleTool(name, args) {
1134
1151
  // Build RFC 2822 compliant email message
1135
1152
  const boundary = `----=_Part_${Date.now()}`;
1136
1153
  let message = '';
1137
- message += `From: ${IMAP_CONFIG.imap.user}\r\n`;
1154
+ message += `From: ${formatFrom(IMAP_CONFIG.imap.user)}\r\n`;
1138
1155
  message += `To: ${args.to}\r\n`;
1139
1156
  if (args.cc) message += `Cc: ${args.cc}\r\n`;
1140
1157
  if (args.bcc) message += `Bcc: ${args.bcc}\r\n`;
@@ -1180,7 +1197,7 @@ async function handleTool(name, args) {
1180
1197
 
1181
1198
  const boundary = `----=_Part_${Date.now()}`;
1182
1199
  let message = '';
1183
- message += `From: ${IMAP_CONFIG.imap.user}\r\n`;
1200
+ message += `From: ${formatFrom(IMAP_CONFIG.imap.user)}\r\n`;
1184
1201
  message += `To: ${args.to}\r\n`;
1185
1202
  if (args.cc) message += `Cc: ${args.cc}\r\n`;
1186
1203
  if (args.bcc) message += `Bcc: ${args.bcc}\r\n`;
@@ -1221,7 +1238,9 @@ async function handleTool(name, args) {
1221
1238
  const transporter = nodemailer.createTransport(SMTP_CONFIG);
1222
1239
 
1223
1240
  const mailOptions = {
1224
- from: SMTP_CONFIG.auth.user,
1241
+ from: SENDER_NAME
1242
+ ? { name: SENDER_NAME, address: SMTP_CONFIG.auth.user }
1243
+ : SMTP_CONFIG.auth.user,
1225
1244
  to: args.to,
1226
1245
  subject: args.subject,
1227
1246
  text: args.body,
@@ -1238,7 +1257,7 @@ async function handleTool(name, args) {
1238
1257
  const sentDate = new Date();
1239
1258
  const boundary = `----=_Part_${Date.now()}`;
1240
1259
  let rawMessage = '';
1241
- rawMessage += `From: ${SMTP_CONFIG.auth.user}\r\n`;
1260
+ rawMessage += `From: ${formatFrom(SMTP_CONFIG.auth.user)}\r\n`;
1242
1261
  rawMessage += `To: ${args.to}\r\n`;
1243
1262
  if (args.cc) rawMessage += `Cc: ${args.cc}\r\n`;
1244
1263
  if (args.bcc) rawMessage += `Bcc: ${args.bcc}\r\n`;
@@ -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,13 +19,14 @@ 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;
30
31
 
31
32
  class LockLostError extends Error {
@@ -118,6 +119,7 @@ export class LocalIndex {
118
119
  this.db = null;
119
120
  this.running = null;
120
121
  this.timer = null;
122
+ this.progressAt = 0;
121
123
  this.debounce = null;
122
124
  this.pendingFolders = new Set();
123
125
  this.session = new SessionManager(imapConfig, { label: 'index', idleCloseMs: 60 * 1000, leaseTimeoutMs: 10 * 60 * 1000, log });
@@ -327,7 +329,20 @@ export class LocalIndex {
327
329
  if (!this._acquireLock()) {
328
330
  return { skipped: true, reason: `another server process (pid ${this.lockHolder}) is syncing the index` };
329
331
  }
330
- const heartbeat = setInterval(() => this._touchLock(), LOCK_HEARTBEAT_MS);
332
+ this.progressAt = Date.now();
333
+ let stallLogged = false;
334
+ const heartbeat = setInterval(() => {
335
+ // Do not keep the lock alive for a run that is no longer moving:
336
+ // another process should be able to take over.
337
+ if (Date.now() - this.progressAt > STALL_MS) {
338
+ if (!stallLogged) {
339
+ stallLogged = true;
340
+ this.log('[index] sync has made no progress for 5 minutes, releasing the lock to another process');
341
+ }
342
+ return;
343
+ }
344
+ this._touchLock();
345
+ }, LOCK_HEARTBEAT_MS);
331
346
  if (heartbeat.unref) heartbeat.unref();
332
347
  try {
333
348
  const summary = await this._sync({ folders, maxPerFolder, passes });
@@ -391,6 +406,14 @@ export class LocalIndex {
391
406
  this.log(`[index] ${folder}: ${r.attempted} messages could not be fetched, will retry next sync`);
392
407
  }
393
408
  } catch (err) {
409
+ if (err.code === 'IMAP_TIMEOUT') {
410
+ summary.aborted = err.message;
411
+ this.db?.prepare(`INSERT INTO folders (name, last_error, last_sync) VALUES (?, ?, ?)
412
+ ON CONFLICT(name) DO UPDATE SET last_error = excluded.last_error`)
413
+ .run(folder, String(err.message).slice(0, 500), Date.now());
414
+ this.log(`[index] sync stopped: ${err.message}`);
415
+ break outer;
416
+ }
394
417
  if (err.code === 'LOCK_LOST' || !this.db) {
395
418
  summary.aborted = err.code === 'LOCK_LOST' ? err.message : 'index closed';
396
419
  this.log(`[index] sync stopped: ${summary.aborted}`);
@@ -432,6 +455,7 @@ export class LocalIndex {
432
455
 
433
456
  async _syncFolder(folder, maxPerFolder) {
434
457
  this._assertLock();
458
+ this.progressAt = Date.now();
435
459
  const conn = await this.session.acquire();
436
460
  try {
437
461
  const box = await openBox(conn, folder, true);
@@ -442,11 +466,7 @@ export class LocalIndex {
442
466
  this._wipeFolder(folder);
443
467
  }
444
468
 
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
- : [];
469
+ const serverUids = box.messages.total ? await uidSearchAll(conn) : [];
450
470
  const serverSet = new Set(serverUids);
451
471
  const local = this.db.prepare('SELECT id, uid FROM messages WHERE folder = ?').all(folder);
452
472
  const localSet = new Set(local.map((r) => Number(r.uid)));
@@ -470,6 +490,7 @@ export class LocalIndex {
470
490
  let added = 0;
471
491
  for (let i = 0; i < todo.length; i += this.batchSize) {
472
492
  this._assertLock();
493
+ this.progressAt = Date.now();
473
494
  added += await this._indexBatch(conn, folder, uidvalidity, todo.slice(i, i + this.batchSize));
474
495
  }
475
496
 
@@ -572,6 +593,7 @@ export class LocalIndex {
572
593
  sync_running: !!this.running || lockLive,
573
594
  ...(lockLive && lock.pid !== process.pid && { sync_running_in_pid: lock.pid }),
574
595
  sync_interval_minutes: this.intervalMs / 60000,
596
+ command_timeout_seconds: getOpTimeout() / 1000,
575
597
  complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
576
598
  last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
577
599
  last_run: lastRun,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
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",