@lengelhard/imap-email-mcp 1.7.0 → 1.7.2
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/CHANGELOG.md +13 -0
- package/README.md +1 -0
- package/index.js +47 -10
- package/lib/local-index.js +173 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Several server processes sharing one index (Claude Desktop can start more than one) no longer sync at the same time. A lock file next to the database lets one process sync while the others keep answering searches. Stale locks (process gone, or no heartbeat for 3 minutes) are taken over automatically.
|
|
7
|
+
- SQLite waits up to 10 seconds for a busy database instead of failing with "database is locked".
|
|
8
|
+
- A scheduled sync is skipped when another process completed a full sync in the last half interval.
|
|
9
|
+
- Index refreshes requested after a move or delete are retried when another process holds the sync lock.
|
|
10
|
+
- The server exits when the client disconnects or on SIGTERM/SIGINT/SIGHUP, instead of lingering while IMAP sockets are open. The sync lock is released on exit.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- `index_status` reports `this_pid`, `sync_running_in_pid` (when another process syncs), `last_full_sync`, and a `last_run` shared by all processes.
|
|
14
|
+
- `sync_index` and `search_index` say so when another process is already syncing.
|
|
15
|
+
|
|
3
16
|
## 1.7.0
|
|
4
17
|
|
|
5
18
|
### Added
|
package/README.md
CHANGED
|
@@ -112,6 +112,7 @@ Some IMAP servers return incomplete results for body searches without reporting
|
|
|
112
112
|
- **What is indexed.** Subject, sender, recipients, attachment file names, and the text body (plain text, or HTML converted to text). Attachment contents are not indexed.
|
|
113
113
|
- **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
114
|
- **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
|
+
- **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.
|
|
115
116
|
- **Requirements.** Node 22.13 or later (uses the built-in `node:sqlite`, no native modules).
|
|
116
117
|
|
|
117
118
|
### Provider Settings
|
package/index.js
CHANGED
|
@@ -130,6 +130,20 @@ const SMTP_CONFIG = {
|
|
|
130
130
|
}
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
+
// Optional display name for outgoing mail. When unset, behaviour is unchanged
|
|
134
|
+
// and the From header is the bare address.
|
|
135
|
+
const SENDER_NAME = process.env.SENDER_NAME || '';
|
|
136
|
+
|
|
137
|
+
// Build an RFC 5322 From value. ASCII names are quoted; anything else is
|
|
138
|
+
// encoded as an RFC 2047 encoded-word so accents survive transport.
|
|
139
|
+
function formatFrom(address) {
|
|
140
|
+
if (!SENDER_NAME) return address;
|
|
141
|
+
const name = /^[\x20-\x7E]*$/.test(SENDER_NAME)
|
|
142
|
+
? `"${SENDER_NAME.replace(/["\\]/g, '\\$&')}"`
|
|
143
|
+
: `=?UTF-8?B?${Buffer.from(SENDER_NAME, 'utf8').toString('base64')}?=`;
|
|
144
|
+
return `${name} <${address}>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
133
147
|
// Traverse the raw IMAP MIME struct to find all attachment parts.
|
|
134
148
|
//
|
|
135
149
|
// Detection rules (in priority order):
|
|
@@ -1134,7 +1148,7 @@ async function handleTool(name, args) {
|
|
|
1134
1148
|
// Build RFC 2822 compliant email message
|
|
1135
1149
|
const boundary = `----=_Part_${Date.now()}`;
|
|
1136
1150
|
let message = '';
|
|
1137
|
-
message += `From: ${IMAP_CONFIG.imap.user}\r\n`;
|
|
1151
|
+
message += `From: ${formatFrom(IMAP_CONFIG.imap.user)}\r\n`;
|
|
1138
1152
|
message += `To: ${args.to}\r\n`;
|
|
1139
1153
|
if (args.cc) message += `Cc: ${args.cc}\r\n`;
|
|
1140
1154
|
if (args.bcc) message += `Bcc: ${args.bcc}\r\n`;
|
|
@@ -1180,7 +1194,7 @@ async function handleTool(name, args) {
|
|
|
1180
1194
|
|
|
1181
1195
|
const boundary = `----=_Part_${Date.now()}`;
|
|
1182
1196
|
let message = '';
|
|
1183
|
-
message += `From: ${IMAP_CONFIG.imap.user}\r\n`;
|
|
1197
|
+
message += `From: ${formatFrom(IMAP_CONFIG.imap.user)}\r\n`;
|
|
1184
1198
|
message += `To: ${args.to}\r\n`;
|
|
1185
1199
|
if (args.cc) message += `Cc: ${args.cc}\r\n`;
|
|
1186
1200
|
if (args.bcc) message += `Bcc: ${args.bcc}\r\n`;
|
|
@@ -1221,7 +1235,9 @@ async function handleTool(name, args) {
|
|
|
1221
1235
|
const transporter = nodemailer.createTransport(SMTP_CONFIG);
|
|
1222
1236
|
|
|
1223
1237
|
const mailOptions = {
|
|
1224
|
-
from:
|
|
1238
|
+
from: SENDER_NAME
|
|
1239
|
+
? { name: SENDER_NAME, address: SMTP_CONFIG.auth.user }
|
|
1240
|
+
: SMTP_CONFIG.auth.user,
|
|
1225
1241
|
to: args.to,
|
|
1226
1242
|
subject: args.subject,
|
|
1227
1243
|
text: args.body,
|
|
@@ -1238,7 +1254,7 @@ async function handleTool(name, args) {
|
|
|
1238
1254
|
const sentDate = new Date();
|
|
1239
1255
|
const boundary = `----=_Part_${Date.now()}`;
|
|
1240
1256
|
let rawMessage = '';
|
|
1241
|
-
rawMessage += `From: ${SMTP_CONFIG.auth.user}\r\n`;
|
|
1257
|
+
rawMessage += `From: ${formatFrom(SMTP_CONFIG.auth.user)}\r\n`;
|
|
1242
1258
|
rawMessage += `To: ${args.to}\r\n`;
|
|
1243
1259
|
if (args.cc) rawMessage += `Cc: ${args.cc}\r\n`;
|
|
1244
1260
|
if (args.bcc) rawMessage += `Bcc: ${args.bcc}\r\n`;
|
|
@@ -1779,11 +1795,15 @@ async function handleTool(name, args) {
|
|
|
1779
1795
|
refresh = 'skipped: background sync in progress';
|
|
1780
1796
|
} else {
|
|
1781
1797
|
const quick = localIndex.sync({ maxPerFolder: 300, passes: 1 });
|
|
1782
|
-
const
|
|
1783
|
-
quick.then(() =>
|
|
1784
|
-
new Promise((resolve) => setTimeout(() => resolve(
|
|
1798
|
+
const outcome = await Promise.race([
|
|
1799
|
+
quick.then((r) => (r && r.skipped ? 'skipped' : 'done')),
|
|
1800
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 20000))
|
|
1785
1801
|
]);
|
|
1786
|
-
refresh =
|
|
1802
|
+
refresh = {
|
|
1803
|
+
done: 'done',
|
|
1804
|
+
skipped: 'skipped: another server process is syncing the index',
|
|
1805
|
+
timeout: 'partial: refresh still running in background'
|
|
1806
|
+
}[outcome];
|
|
1787
1807
|
}
|
|
1788
1808
|
}
|
|
1789
1809
|
const found = localIndex.search({
|
|
@@ -1817,13 +1837,15 @@ async function handleTool(name, args) {
|
|
|
1817
1837
|
run.then((summary) => ({ finished: true, summary })),
|
|
1818
1838
|
new Promise((resolve) => setTimeout(() => resolve({ finished: false }), waitMs))
|
|
1819
1839
|
]);
|
|
1840
|
+
const skipped = outcome.summary && outcome.summary.skipped;
|
|
1820
1841
|
return {
|
|
1821
1842
|
content: [{
|
|
1822
1843
|
type: 'text',
|
|
1823
1844
|
text: JSON.stringify({
|
|
1824
1845
|
...(alreadyRunning && { note: 'A sync was already running; waited on that one.' }),
|
|
1825
|
-
|
|
1826
|
-
|
|
1846
|
+
...(skipped && { note: `Not started: ${outcome.summary.reason}. It keeps the index current; check index_status.` }),
|
|
1847
|
+
finished: skipped ? false : outcome.finished,
|
|
1848
|
+
...(outcome.summary && !skipped && { summary: outcome.summary }),
|
|
1827
1849
|
status: localIndex.status()
|
|
1828
1850
|
}, null, 2)
|
|
1829
1851
|
}]
|
|
@@ -1867,6 +1889,21 @@ async function main() {
|
|
|
1867
1889
|
}
|
|
1868
1890
|
}
|
|
1869
1891
|
|
|
1892
|
+
// Exit promptly when the client goes away. Without this, open IMAP sockets
|
|
1893
|
+
// would keep an orphaned process alive after Claude Desktop quits.
|
|
1894
|
+
let shuttingDown = false;
|
|
1895
|
+
const shutdown = () => {
|
|
1896
|
+
if (shuttingDown) return;
|
|
1897
|
+
shuttingDown = true;
|
|
1898
|
+
try { localIndex?.stop(); } catch { /* ignore */ }
|
|
1899
|
+
try { sessions.close(); } catch { /* ignore */ }
|
|
1900
|
+
setTimeout(() => process.exit(0), 500);
|
|
1901
|
+
};
|
|
1902
|
+
process.stdin.on('end', shutdown);
|
|
1903
|
+
process.stdin.on('close', shutdown);
|
|
1904
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, shutdown);
|
|
1905
|
+
process.on('exit', () => { try { localIndex?.releaseLock(); } catch { /* ignore */ } });
|
|
1906
|
+
|
|
1870
1907
|
const transport = new StdioServerTransport();
|
|
1871
1908
|
await server.connect(transport);
|
|
1872
1909
|
console.error('IMAP Email MCP Server running on stdio');
|
package/lib/local-index.js
CHANGED
|
@@ -23,6 +23,27 @@ import { SessionManager, openBox, fetchMessages, headerOf, localDate } from './i
|
|
|
23
23
|
import { planParts, partsToText } from './mime-text.js';
|
|
24
24
|
|
|
25
25
|
const SCHEMA_VERSION = '1';
|
|
26
|
+
const LOCK_STALE_MS = 3 * 60 * 1000;
|
|
27
|
+
const LOCK_HEARTBEAT_MS = 30 * 1000;
|
|
28
|
+
const REQUEST_RETRY_MS = 15 * 1000;
|
|
29
|
+
const REQUEST_MAX_ATTEMPTS = 6;
|
|
30
|
+
|
|
31
|
+
class LockLostError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super('sync lock taken over by another process');
|
|
34
|
+
this.code = 'LOCK_LOST';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pidAlive(pid) {
|
|
39
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return err.code === 'EPERM';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
26
47
|
const HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
|
|
27
48
|
const DEFAULT_EXCLUDE = ['trash', 'junk', 'spam', 'deleted items', 'deleted messages', 'bin'];
|
|
28
49
|
const SPECIAL_EXCLUDE = ['\\trash', '\\junk'];
|
|
@@ -99,7 +120,6 @@ export class LocalIndex {
|
|
|
99
120
|
this.timer = null;
|
|
100
121
|
this.debounce = null;
|
|
101
122
|
this.pendingFolders = new Set();
|
|
102
|
-
this.lastRun = null;
|
|
103
123
|
this.session = new SessionManager(imapConfig, { label: 'index', idleCloseMs: 60 * 1000, leaseTimeoutMs: 10 * 60 * 1000, log });
|
|
104
124
|
}
|
|
105
125
|
|
|
@@ -110,6 +130,9 @@ export class LocalIndex {
|
|
|
110
130
|
for (const suffix of ['', '-wal', '-shm']) {
|
|
111
131
|
try { fs.chmodSync(this.dbPath + suffix, 0o600); } catch { /* may not exist yet */ }
|
|
112
132
|
}
|
|
133
|
+
// Several server processes can share this file (Claude Desktop may start
|
|
134
|
+
// more than one). Wait for locks instead of failing immediately.
|
|
135
|
+
this.db.exec('PRAGMA busy_timeout = 10000;');
|
|
113
136
|
this.db.exec(`
|
|
114
137
|
PRAGMA journal_mode = WAL;
|
|
115
138
|
PRAGMA synchronous = NORMAL;
|
|
@@ -153,7 +176,11 @@ export class LocalIndex {
|
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
start() {
|
|
156
|
-
const kick = () =>
|
|
179
|
+
const kick = () => {
|
|
180
|
+
const last = Number(this._meta('last_full_sync') || 0);
|
|
181
|
+
if (Date.now() - last < this.intervalMs / 2) return;
|
|
182
|
+
this.sync().catch((err) => this.log(`[index] sync failed: ${err.message}`));
|
|
183
|
+
};
|
|
157
184
|
const first = setTimeout(kick, 5000);
|
|
158
185
|
if (first.unref) first.unref();
|
|
159
186
|
this.timer = setInterval(kick, this.intervalMs);
|
|
@@ -163,20 +190,114 @@ export class LocalIndex {
|
|
|
163
190
|
stop() {
|
|
164
191
|
clearInterval(this.timer);
|
|
165
192
|
clearTimeout(this.debounce);
|
|
193
|
+
this.releaseLock();
|
|
166
194
|
this.session.close();
|
|
167
195
|
try { this.db?.close(); } catch { /* ignore */ }
|
|
196
|
+
this.db = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_meta(key) {
|
|
200
|
+
try {
|
|
201
|
+
return this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value ?? null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_setMeta(key, value) {
|
|
208
|
+
this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
209
|
+
.run(key, String(value));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Cross-process sync lock: a small file next to the database holding the
|
|
213
|
+
// owner's pid, with its mtime refreshed while a sync runs. A lock is stale
|
|
214
|
+
// when its pid is gone or its heartbeat is older than LOCK_STALE_MS.
|
|
215
|
+
get lockPath() {
|
|
216
|
+
return `${this.dbPath}.lock`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_readLock() {
|
|
220
|
+
try {
|
|
221
|
+
const pid = parseInt(fs.readFileSync(this.lockPath, 'utf8'), 10);
|
|
222
|
+
const mtime = fs.statSync(this.lockPath).mtimeMs;
|
|
223
|
+
return { pid, mtime };
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_lockIsLive(lock) {
|
|
230
|
+
return !!lock && pidAlive(lock.pid) && Date.now() - lock.mtime < LOCK_STALE_MS;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_acquireLock() {
|
|
234
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFileSync(this.lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 });
|
|
237
|
+
return true;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err.code !== 'EEXIST') throw err;
|
|
240
|
+
const lock = this._readLock();
|
|
241
|
+
if (!lock) continue;
|
|
242
|
+
if (lock.pid === process.pid) {
|
|
243
|
+
this._touchLock();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
if (this._lockIsLive(lock)) {
|
|
247
|
+
this.lockHolder = lock.pid;
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
this.log(`[index] removing stale sync lock from pid ${lock.pid}`);
|
|
251
|
+
try { fs.unlinkSync(this.lockPath); } catch { /* another process got there first */ }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_holdsLock() {
|
|
258
|
+
const lock = this._readLock();
|
|
259
|
+
return !!lock && lock.pid === process.pid;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_touchLock() {
|
|
263
|
+
if (!this._holdsLock()) return;
|
|
264
|
+
const now = new Date();
|
|
265
|
+
try { fs.utimesSync(this.lockPath, now, now); } catch { /* ignore */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
releaseLock() {
|
|
269
|
+
if (this._holdsLock()) {
|
|
270
|
+
try { fs.unlinkSync(this.lockPath); } catch { /* ignore */ }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_assertLock() {
|
|
275
|
+
if (!this._holdsLock()) throw new LockLostError();
|
|
168
276
|
}
|
|
169
277
|
|
|
170
278
|
/** Ask for a near-term incremental sync of specific folders (after moves or deletes). */
|
|
171
|
-
requestSync(folders = []) {
|
|
279
|
+
requestSync(folders = [], attempt = 1, delay = 3000) {
|
|
172
280
|
for (const f of folders) if (f) this.pendingFolders.add(f);
|
|
173
281
|
clearTimeout(this.debounce);
|
|
174
|
-
this.debounce = setTimeout(() => {
|
|
282
|
+
this.debounce = setTimeout(async () => {
|
|
175
283
|
const list = [...this.pendingFolders];
|
|
176
|
-
|
|
177
|
-
if (this.running)
|
|
178
|
-
|
|
179
|
-
|
|
284
|
+
if (!list.length) return;
|
|
285
|
+
if (this.running) {
|
|
286
|
+
// A local sync may have listed these folders before the move. Try again after it.
|
|
287
|
+
this.running.catch(() => {}).then(() => this.requestSync([], attempt, REQUEST_RETRY_MS));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const result = await this.sync({ folders: list, maxPerFolder: 500, passes: 1 });
|
|
292
|
+
if (result && result.skipped) {
|
|
293
|
+
if (attempt < REQUEST_MAX_ATTEMPTS) this.requestSync([], attempt + 1, REQUEST_RETRY_MS);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
for (const f of list) this.pendingFolders.delete(f);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.log(`[index] sync failed: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
}, delay);
|
|
180
301
|
if (this.debounce.unref) this.debounce.unref();
|
|
181
302
|
}
|
|
182
303
|
|
|
@@ -202,7 +323,32 @@ export class LocalIndex {
|
|
|
202
323
|
*/
|
|
203
324
|
sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
|
|
204
325
|
if (this.running) return this.running;
|
|
205
|
-
this.running =
|
|
326
|
+
this.running = (async () => {
|
|
327
|
+
if (!this._acquireLock()) {
|
|
328
|
+
return { skipped: true, reason: `another server process (pid ${this.lockHolder}) is syncing the index` };
|
|
329
|
+
}
|
|
330
|
+
const heartbeat = setInterval(() => this._touchLock(), LOCK_HEARTBEAT_MS);
|
|
331
|
+
if (heartbeat.unref) heartbeat.unref();
|
|
332
|
+
try {
|
|
333
|
+
const summary = await this._sync({ folders, maxPerFolder, passes });
|
|
334
|
+
if (this.db) {
|
|
335
|
+
if (!folders && passes === Infinity && !summary.aborted) this._setMeta('last_full_sync', Date.now());
|
|
336
|
+
this._setMeta('last_run', JSON.stringify({
|
|
337
|
+
finished: new Date().toISOString(),
|
|
338
|
+
pid: process.pid,
|
|
339
|
+
scope: folders ? folders.join(', ') : 'all folders',
|
|
340
|
+
added: summary.added,
|
|
341
|
+
removed: summary.removed,
|
|
342
|
+
duration_ms: summary.duration_ms,
|
|
343
|
+
...(summary.aborted && { aborted: summary.aborted })
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
return summary;
|
|
347
|
+
} finally {
|
|
348
|
+
clearInterval(heartbeat);
|
|
349
|
+
this.releaseLock();
|
|
350
|
+
}
|
|
351
|
+
})().finally(() => { this.running = null; });
|
|
206
352
|
return this.running;
|
|
207
353
|
}
|
|
208
354
|
|
|
@@ -223,6 +369,7 @@ export class LocalIndex {
|
|
|
223
369
|
|
|
224
370
|
let pass = 0;
|
|
225
371
|
let remaining = new Set(targets);
|
|
372
|
+
outer:
|
|
226
373
|
while (remaining.size && pass < passes) {
|
|
227
374
|
pass++;
|
|
228
375
|
for (const folder of [...remaining]) {
|
|
@@ -244,6 +391,11 @@ export class LocalIndex {
|
|
|
244
391
|
this.log(`[index] ${folder}: ${r.attempted} messages could not be fetched, will retry next sync`);
|
|
245
392
|
}
|
|
246
393
|
} catch (err) {
|
|
394
|
+
if (err.code === 'LOCK_LOST' || !this.db) {
|
|
395
|
+
summary.aborted = err.code === 'LOCK_LOST' ? err.message : 'index closed';
|
|
396
|
+
this.log(`[index] sync stopped: ${summary.aborted}`);
|
|
397
|
+
break outer;
|
|
398
|
+
}
|
|
247
399
|
remaining.delete(folder);
|
|
248
400
|
this.log(`[index] ${folder}: ${err.message}`);
|
|
249
401
|
this.db.prepare(`INSERT INTO folders (name, last_error, last_sync) VALUES (?, ?, ?)
|
|
@@ -254,7 +406,6 @@ export class LocalIndex {
|
|
|
254
406
|
}
|
|
255
407
|
}
|
|
256
408
|
summary.duration_ms = Date.now() - started;
|
|
257
|
-
this.lastRun = { finished: Date.now(), ...summary };
|
|
258
409
|
return summary;
|
|
259
410
|
}
|
|
260
411
|
|
|
@@ -280,6 +431,7 @@ export class LocalIndex {
|
|
|
280
431
|
}
|
|
281
432
|
|
|
282
433
|
async _syncFolder(folder, maxPerFolder) {
|
|
434
|
+
this._assertLock();
|
|
283
435
|
const conn = await this.session.acquire();
|
|
284
436
|
try {
|
|
285
437
|
const box = await openBox(conn, folder, true);
|
|
@@ -317,6 +469,7 @@ export class LocalIndex {
|
|
|
317
469
|
const todo = missing.slice(0, maxPerFolder);
|
|
318
470
|
let added = 0;
|
|
319
471
|
for (let i = 0; i < todo.length; i += this.batchSize) {
|
|
472
|
+
this._assertLock();
|
|
320
473
|
added += await this._indexBatch(conn, folder, uidvalidity, todo.slice(i, i + this.batchSize));
|
|
321
474
|
}
|
|
322
475
|
|
|
@@ -407,18 +560,21 @@ export class LocalIndex {
|
|
|
407
560
|
for (const suffix of ['', '-wal']) {
|
|
408
561
|
try { sizeBytes += fs.statSync(this.dbPath + suffix).size; } catch { /* ignore */ }
|
|
409
562
|
}
|
|
563
|
+
const lock = this._readLock();
|
|
564
|
+
const lockLive = this._lockIsLive(lock);
|
|
565
|
+
let lastRun = null;
|
|
566
|
+
try { lastRun = JSON.parse(this._meta('last_run') || 'null'); } catch { /* ignore */ }
|
|
567
|
+
const lastFull = Number(this._meta('last_full_sync') || 0);
|
|
410
568
|
return {
|
|
411
569
|
db_path: this.dbPath,
|
|
412
570
|
db_size_mb: Math.round(sizeBytes / 1024 / 102.4) / 10,
|
|
413
|
-
|
|
571
|
+
this_pid: process.pid,
|
|
572
|
+
sync_running: !!this.running || lockLive,
|
|
573
|
+
...(lockLive && lock.pid !== process.pid && { sync_running_in_pid: lock.pid }),
|
|
414
574
|
sync_interval_minutes: this.intervalMs / 60000,
|
|
415
575
|
complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
added: this.lastRun.added,
|
|
419
|
-
removed: this.lastRun.removed,
|
|
420
|
-
duration_ms: this.lastRun.duration_ms
|
|
421
|
-
} : null,
|
|
576
|
+
last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
|
|
577
|
+
last_run: lastRun,
|
|
422
578
|
folders
|
|
423
579
|
};
|
|
424
580
|
}
|
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.2",
|
|
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",
|