@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 +2 -0
- package/CHANGELOG.md +22 -0
- package/README.md +4 -1
- package/index.js +4 -1
- package/lib/imap-session.js +81 -7
- package/lib/local-index.js +52 -8
- 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,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, {
|
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,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
|
-
|
|
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.
|
|
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",
|