@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 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: SMTP_CONFIG.auth.user,
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 timedOut = await Promise.race([
1783
- quick.then(() => false),
1784
- new Promise((resolve) => setTimeout(() => resolve(true), 20000))
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 = timedOut ? 'partial: refresh still running in background' : 'done';
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
- finished: outcome.finished,
1826
- ...(outcome.summary && { summary: outcome.summary }),
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');
@@ -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 = () => this.sync().catch((err) => this.log(`[index] sync failed: ${err.message}`));
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
- this.pendingFolders.clear();
177
- if (this.running) return; // the running sync or the next scheduled one will catch up
178
- this.sync({ folders: list, maxPerFolder: 500, passes: 1 }).catch((err) => this.log(`[index] sync failed: ${err.message}`));
179
- }, 3000);
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 = this._sync({ folders, maxPerFolder, passes }).finally(() => { this.running = null; });
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
- sync_running: !!this.running,
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
- last_run: this.lastRun ? {
417
- finished: new Date(this.lastRun.finished).toISOString(),
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.0",
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",