@lengelhard/imap-email-mcp 1.7.2 → 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 +2 -0
- package/CHANGELOG.md +17 -0
- package/README.md +3 -0
- package/index.js +3 -0
- package/lib/imap-session.js +81 -7
- package/lib/local-index.js +29 -7
- package/package.json +1 -1
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,
|
|
@@ -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, {
|
package/lib/imap-session.js
CHANGED
|
@@ -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
|
|
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] }. */
|
package/lib/local-index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|