@lengelhard/imap-email-mcp 1.5.0 → 1.7.0

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.
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Local read-only full-text index for imap-email-mcp.
3
+ *
4
+ * Mirrors the text of your mail into a local SQLite FTS5 database so content
5
+ * searches do not depend on the IMAP server's body search.
6
+ *
7
+ * Read-only by design: the indexer only uses EXAMINE and BODY.PEEK, so it never
8
+ * changes flags, never moves or deletes mail, and never appends. Message
9
+ * content never changes for a given UID, so sync is a set difference: index
10
+ * UIDs the server has and we lack, drop UIDs we have and the server lost
11
+ * (moved or deleted), and wipe a folder when its UIDVALIDITY changes.
12
+ *
13
+ * Requires Node's built-in node:sqlite (Node 22.13+ / 23.4+ / 24+).
14
+ *
15
+ * @license MIT
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import os from 'os';
20
+ import path from 'path';
21
+ import crypto from 'crypto';
22
+ import { SessionManager, openBox, fetchMessages, headerOf, localDate } from './imap-session.js';
23
+ import { planParts, partsToText } from './mime-text.js';
24
+
25
+ const SCHEMA_VERSION = '1';
26
+ const HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
27
+ const DEFAULT_EXCLUDE = ['trash', 'junk', 'spam', 'deleted items', 'deleted messages', 'bin'];
28
+ const SPECIAL_EXCLUDE = ['\\trash', '\\junk'];
29
+
30
+ async function loadSqlite() {
31
+ const original = process.emitWarning;
32
+ process.emitWarning = function (warning, ...rest) {
33
+ if (String(warning && warning.message ? warning.message : warning).includes('SQLite is an experimental')) return;
34
+ return original.call(process, warning, ...rest);
35
+ };
36
+ try {
37
+ const mod = await import('node:sqlite');
38
+ return mod.DatabaseSync;
39
+ } finally {
40
+ process.emitWarning = original;
41
+ }
42
+ }
43
+
44
+ export function defaultDbPath(user, host) {
45
+ const id = crypto.createHash('sha1').update(`${user}@${host}`).digest('hex').slice(0, 12);
46
+ const base = process.platform === 'darwin'
47
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'imap-email-mcp')
48
+ : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'imap-email-mcp');
49
+ return path.join(base, `index-${id}.sqlite`);
50
+ }
51
+
52
+ function flattenBoxes(boxes, prefix = '', out = []) {
53
+ for (const [name, box] of Object.entries(boxes || {})) {
54
+ const full = prefix ? `${prefix}${box.delimiter || '/'}${name}` : name;
55
+ const attribs = (box.attribs || []).map((a) => String(a).toLowerCase());
56
+ if (box.special_use_attrib) attribs.push(String(box.special_use_attrib).toLowerCase());
57
+ out.push({ name: full, attribs });
58
+ if (box.children) flattenBoxes(box.children, full, out);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function addressText(values) {
64
+ return (values || []).join(', ');
65
+ }
66
+
67
+ function ftsQuery(query, { match = 'all', prefix = false, field = 'any' } = {}) {
68
+ const q = String(query || '').trim();
69
+ if (!q) throw new Error('query is required');
70
+ const quote = (s) => `"${s.replace(/"/g, '""')}"`;
71
+ let expr;
72
+ if (match === 'phrase') {
73
+ expr = quote(q) + (prefix ? '*' : '');
74
+ } else {
75
+ const tokens = q.split(/\s+/).filter(Boolean).map((t) => quote(t) + (prefix ? '*' : ''));
76
+ expr = tokens.join(match === 'any' ? ' OR ' : ' AND ');
77
+ }
78
+ const columns = { subject: 'subject', from: 'from_addr', to: 'to_addr', attachments: 'attachments', body: 'body' };
79
+ if (field && field !== 'any') {
80
+ const col = columns[field];
81
+ if (!col) throw new Error(`Unknown field: ${field}`);
82
+ expr = `${col} : (${expr})`;
83
+ }
84
+ return expr;
85
+ }
86
+
87
+ export class LocalIndex {
88
+ constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
89
+ this.imapConfig = imapConfig;
90
+ this.dbPath = dbPath || defaultDbPath(imapConfig.imap.user, imapConfig.imap.host);
91
+ this.folders = folders && folders.length ? folders : null;
92
+ this.excludeFolders = excludeFolders.map((f) => f.toLowerCase());
93
+ this.intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
94
+ this.batchSize = batchSize;
95
+ this.maxBodyChars = maxBodyChars;
96
+ this.log = log;
97
+ this.db = null;
98
+ this.running = null;
99
+ this.timer = null;
100
+ this.debounce = null;
101
+ this.pendingFolders = new Set();
102
+ this.lastRun = null;
103
+ this.session = new SessionManager(imapConfig, { label: 'index', idleCloseMs: 60 * 1000, leaseTimeoutMs: 10 * 60 * 1000, log });
104
+ }
105
+
106
+ async open() {
107
+ const DatabaseSync = await loadSqlite();
108
+ fs.mkdirSync(path.dirname(this.dbPath), { recursive: true, mode: 0o700 });
109
+ this.db = new DatabaseSync(this.dbPath);
110
+ for (const suffix of ['', '-wal', '-shm']) {
111
+ try { fs.chmodSync(this.dbPath + suffix, 0o600); } catch { /* may not exist yet */ }
112
+ }
113
+ this.db.exec(`
114
+ PRAGMA journal_mode = WAL;
115
+ PRAGMA synchronous = NORMAL;
116
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
117
+ CREATE TABLE IF NOT EXISTS folders (
118
+ name TEXT PRIMARY KEY,
119
+ uidvalidity INTEGER,
120
+ server_count INTEGER,
121
+ indexed_count INTEGER,
122
+ backfill_complete INTEGER DEFAULT 0,
123
+ last_sync INTEGER,
124
+ last_error TEXT
125
+ );
126
+ CREATE TABLE IF NOT EXISTS messages (
127
+ id INTEGER PRIMARY KEY,
128
+ folder TEXT NOT NULL,
129
+ uid INTEGER NOT NULL,
130
+ uidvalidity INTEGER NOT NULL,
131
+ internal_date INTEGER,
132
+ sent_date TEXT,
133
+ from_addr TEXT,
134
+ to_addr TEXT,
135
+ cc_addr TEXT,
136
+ subject TEXT,
137
+ message_id TEXT,
138
+ size INTEGER,
139
+ attachments TEXT,
140
+ body_chars INTEGER,
141
+ UNIQUE (folder, uid)
142
+ );
143
+ CREATE INDEX IF NOT EXISTS messages_date ON messages (internal_date);
144
+ CREATE INDEX IF NOT EXISTS messages_folder_date ON messages (folder, internal_date);
145
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5 (
146
+ subject, from_addr, to_addr, attachments, body,
147
+ tokenize = 'unicode61 remove_diacritics 2'
148
+ );
149
+ `);
150
+ const version = this.db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version');
151
+ if (!version) this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION);
152
+ return this;
153
+ }
154
+
155
+ start() {
156
+ const kick = () => this.sync().catch((err) => this.log(`[index] sync failed: ${err.message}`));
157
+ const first = setTimeout(kick, 5000);
158
+ if (first.unref) first.unref();
159
+ this.timer = setInterval(kick, this.intervalMs);
160
+ if (this.timer.unref) this.timer.unref();
161
+ }
162
+
163
+ stop() {
164
+ clearInterval(this.timer);
165
+ clearTimeout(this.debounce);
166
+ this.session.close();
167
+ try { this.db?.close(); } catch { /* ignore */ }
168
+ }
169
+
170
+ /** Ask for a near-term incremental sync of specific folders (after moves or deletes). */
171
+ requestSync(folders = []) {
172
+ for (const f of folders) if (f) this.pendingFolders.add(f);
173
+ clearTimeout(this.debounce);
174
+ this.debounce = setTimeout(() => {
175
+ 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);
180
+ if (this.debounce.unref) this.debounce.unref();
181
+ }
182
+
183
+ async _targetFolders(connection) {
184
+ const boxes = flattenBoxes(await connection.getBoxes());
185
+ const selectable = boxes.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
186
+ if (this.folders) {
187
+ const wanted = new Set(this.folders.map((f) => f.toLowerCase()));
188
+ return selectable.filter((b) => wanted.has(b.name.toLowerCase())).map((b) => b.name);
189
+ }
190
+ return selectable
191
+ .filter((b) => !b.attribs.some((a) => SPECIAL_EXCLUDE.includes(a)))
192
+ .filter((b) => {
193
+ const leaf = b.name.split(/[/.]/).pop().toLowerCase();
194
+ return !DEFAULT_EXCLUDE.includes(leaf) && !this.excludeFolders.includes(b.name.toLowerCase());
195
+ })
196
+ .map((b) => b.name);
197
+ }
198
+
199
+ /**
200
+ * Run a sync. Single-flight: concurrent callers share the running sync.
201
+ * maxPerFolder caps new messages per folder per pass; passes caps the loop.
202
+ */
203
+ sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
204
+ if (this.running) return this.running;
205
+ this.running = this._sync({ folders, maxPerFolder, passes }).finally(() => { this.running = null; });
206
+ return this.running;
207
+ }
208
+
209
+ async _sync({ folders, maxPerFolder, passes }) {
210
+ const started = Date.now();
211
+ const summary = { added: 0, removed: 0, folders: {} };
212
+ let targets;
213
+ {
214
+ const conn = await this.session.acquire();
215
+ try {
216
+ const all = await this._targetFolders(conn);
217
+ targets = folders ? all.filter((f) => folders.includes(f)) : all;
218
+ if (!folders) this._dropUntracked(all);
219
+ } finally {
220
+ conn.end();
221
+ }
222
+ }
223
+
224
+ let pass = 0;
225
+ let remaining = new Set(targets);
226
+ while (remaining.size && pass < passes) {
227
+ pass++;
228
+ for (const folder of [...remaining]) {
229
+ try {
230
+ const r = await this._syncFolder(folder, maxPerFolder);
231
+ summary.added += r.added;
232
+ summary.removed += r.removed;
233
+ const s = summary.folders[folder] || { added: 0, removed: 0 };
234
+ s.added += r.added;
235
+ s.removed += r.removed;
236
+ s.pending = r.pending;
237
+ summary.folders[folder] = s;
238
+ if (r.pending === 0) {
239
+ remaining.delete(folder);
240
+ } else if (r.attempted > 0 && r.added === 0) {
241
+ // The server lists these UIDs but returns nothing for them. Stop
242
+ // this run instead of looping; the next scheduled sync retries.
243
+ remaining.delete(folder);
244
+ this.log(`[index] ${folder}: ${r.attempted} messages could not be fetched, will retry next sync`);
245
+ }
246
+ } catch (err) {
247
+ remaining.delete(folder);
248
+ this.log(`[index] ${folder}: ${err.message}`);
249
+ this.db.prepare(`INSERT INTO folders (name, last_error, last_sync) VALUES (?, ?, ?)
250
+ ON CONFLICT(name) DO UPDATE SET last_error = excluded.last_error, last_sync = excluded.last_sync`)
251
+ .run(folder, String(err.message).slice(0, 500), Date.now());
252
+ summary.folders[folder] = { error: err.message };
253
+ }
254
+ }
255
+ }
256
+ summary.duration_ms = Date.now() - started;
257
+ this.lastRun = { finished: Date.now(), ...summary };
258
+ return summary;
259
+ }
260
+
261
+ _dropUntracked(serverFolders) {
262
+ const keep = new Set(serverFolders);
263
+ const tracked = this.db.prepare('SELECT DISTINCT folder FROM messages').all().map((r) => r.folder);
264
+ for (const f of tracked) if (!keep.has(f)) this._wipeFolder(f);
265
+ for (const r of this.db.prepare('SELECT name FROM folders').all()) {
266
+ if (!keep.has(r.name)) this.db.prepare('DELETE FROM folders WHERE name = ?').run(r.name);
267
+ }
268
+ }
269
+
270
+ _wipeFolder(folder) {
271
+ this.db.exec('BEGIN');
272
+ try {
273
+ this.db.prepare('DELETE FROM fts WHERE rowid IN (SELECT id FROM messages WHERE folder = ?)').run(folder);
274
+ this.db.prepare('DELETE FROM messages WHERE folder = ?').run(folder);
275
+ this.db.exec('COMMIT');
276
+ } catch (err) {
277
+ this.db.exec('ROLLBACK');
278
+ throw err;
279
+ }
280
+ }
281
+
282
+ async _syncFolder(folder, maxPerFolder) {
283
+ const conn = await this.session.acquire();
284
+ try {
285
+ const box = await openBox(conn, folder, true);
286
+ const uidvalidity = Number(box.uidvalidity);
287
+ const row = this.db.prepare('SELECT uidvalidity FROM folders WHERE name = ?').get(folder);
288
+ if (row && row.uidvalidity != null && Number(row.uidvalidity) !== uidvalidity) {
289
+ this.log(`[index] ${folder}: UIDVALIDITY changed, re-indexing`);
290
+ this._wipeFolder(folder);
291
+ }
292
+
293
+ const serverUids = box.messages.total
294
+ ? await new Promise((resolve, reject) => {
295
+ try { conn.imap.search(['ALL'], (e, u) => (e ? reject(e) : resolve((u || []).map(Number)))); } catch (e) { reject(e); }
296
+ })
297
+ : [];
298
+ const serverSet = new Set(serverUids);
299
+ const local = this.db.prepare('SELECT id, uid FROM messages WHERE folder = ?').all(folder);
300
+ const localSet = new Set(local.map((r) => Number(r.uid)));
301
+
302
+ const vanished = local.filter((r) => !serverSet.has(Number(r.uid)));
303
+ if (vanished.length) {
304
+ this.db.exec('BEGIN');
305
+ try {
306
+ const delFts = this.db.prepare('DELETE FROM fts WHERE rowid = ?');
307
+ const delMsg = this.db.prepare('DELETE FROM messages WHERE id = ?');
308
+ for (const r of vanished) { delFts.run(r.id); delMsg.run(r.id); }
309
+ this.db.exec('COMMIT');
310
+ } catch (err) {
311
+ this.db.exec('ROLLBACK');
312
+ throw err;
313
+ }
314
+ }
315
+
316
+ const missing = serverUids.filter((u) => !localSet.has(u)).sort((a, b) => b - a);
317
+ const todo = missing.slice(0, maxPerFolder);
318
+ let added = 0;
319
+ for (let i = 0; i < todo.length; i += this.batchSize) {
320
+ added += await this._indexBatch(conn, folder, uidvalidity, todo.slice(i, i + this.batchSize));
321
+ }
322
+
323
+ const pending = missing.length - todo.length;
324
+ const indexed = this.db.prepare('SELECT count(*) AS n FROM messages WHERE folder = ?').get(folder).n;
325
+ this.db.prepare(`INSERT INTO folders (name, uidvalidity, server_count, indexed_count, backfill_complete, last_sync, last_error)
326
+ VALUES (?, ?, ?, ?, ?, ?, NULL)
327
+ ON CONFLICT(name) DO UPDATE SET uidvalidity = excluded.uidvalidity, server_count = excluded.server_count,
328
+ indexed_count = excluded.indexed_count, backfill_complete = excluded.backfill_complete,
329
+ last_sync = excluded.last_sync, last_error = NULL`)
330
+ .run(folder, uidvalidity, serverUids.length, indexed, pending === 0 ? 1 : 0, Date.now());
331
+ return { added, removed: vanished.length, pending, attempted: todo.length };
332
+ } finally {
333
+ conn.end();
334
+ }
335
+ }
336
+
337
+ async _indexBatch(conn, folder, uidvalidity, uids) {
338
+ const meta = await fetchMessages(conn, uids, { bodies: [HEADER_FIELDS], struct: true, size: true });
339
+ const parseHeader = conn.imap.constructor.parseHeader;
340
+
341
+ // Group messages by the exact set of text parts they need, so each group
342
+ // is one FETCH. Most messages share part "1" or "1.1".
343
+ const plans = new Map();
344
+ const groups = new Map();
345
+ for (const [uid, rec] of meta) {
346
+ const plan = planParts(rec.attributes.struct || []);
347
+ plans.set(uid, plan);
348
+ if (!plan.parts.length) continue;
349
+ const key = plan.parts.map((p) => p.partID).join(',');
350
+ if (!groups.has(key)) groups.set(key, []);
351
+ groups.get(key).push(uid);
352
+ }
353
+ const bodies = new Map();
354
+ for (const [key, groupUids] of groups) {
355
+ const fetched = await fetchMessages(conn, groupUids, { bodies: key.split(',') });
356
+ for (const [uid, rec] of fetched) bodies.set(uid, rec.parts);
357
+ }
358
+
359
+ const insMsg = this.db.prepare(`INSERT OR IGNORE INTO messages
360
+ (folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars)
361
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
362
+ const insFts = this.db.prepare('INSERT INTO fts (rowid, subject, from_addr, to_addr, attachments, body) VALUES (?, ?, ?, ?, ?, ?)');
363
+
364
+ let added = 0;
365
+ this.db.exec('BEGIN');
366
+ try {
367
+ for (const [uid, rec] of meta) {
368
+ const h = headerOf(rec, parseHeader);
369
+ const plan = plans.get(uid);
370
+ let body = '';
371
+ try {
372
+ body = partsToText(plan, bodies.get(uid) || {});
373
+ } catch (err) {
374
+ this.log(`[index] ${folder} uid ${uid}: body decode failed: ${err.message}`);
375
+ }
376
+ if (body.length > this.maxBodyChars) body = body.slice(0, this.maxBodyChars);
377
+ const from = addressText(h.from);
378
+ const to = addressText([...(h.to || []), ...(h.cc || [])]);
379
+ const subject = (h.subject || [''])[0];
380
+ const attachments = plan.attachments.join(', ');
381
+ const internal = rec.attributes.date instanceof Date ? rec.attributes.date.getTime() : null;
382
+ const res = insMsg.run(folder, uid, uidvalidity, internal, (h.date || [null])[0], from, addressText(h.to), addressText(h.cc),
383
+ subject, (h['message-id'] || [null])[0], rec.attributes.size ?? null, attachments, body.length);
384
+ if (res.changes) {
385
+ insFts.run(Number(res.lastInsertRowid), subject, from, to, attachments, body);
386
+ added++;
387
+ }
388
+ }
389
+ this.db.exec('COMMIT');
390
+ } catch (err) {
391
+ this.db.exec('ROLLBACK');
392
+ throw err;
393
+ }
394
+ return added;
395
+ }
396
+
397
+ status() {
398
+ const folders = this.db.prepare('SELECT * FROM folders ORDER BY name').all().map((r) => ({
399
+ folder: r.name,
400
+ server_count: r.server_count,
401
+ indexed_count: r.indexed_count,
402
+ complete: !!r.backfill_complete,
403
+ last_sync: r.last_sync ? new Date(r.last_sync).toISOString() : null,
404
+ last_error: r.last_error || undefined
405
+ }));
406
+ let sizeBytes = 0;
407
+ for (const suffix of ['', '-wal']) {
408
+ try { sizeBytes += fs.statSync(this.dbPath + suffix).size; } catch { /* ignore */ }
409
+ }
410
+ return {
411
+ db_path: this.dbPath,
412
+ db_size_mb: Math.round(sizeBytes / 1024 / 102.4) / 10,
413
+ sync_running: !!this.running,
414
+ sync_interval_minutes: this.intervalMs / 60000,
415
+ 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,
422
+ folders
423
+ };
424
+ }
425
+
426
+ search({ query, folders = null, from = null, since_date = null, before_date = null, field = 'any', match = 'all', prefix = false, sort = 'date', limit = 20, offset = 0 }) {
427
+ const where = ['fts MATCH ?'];
428
+ const params = [ftsQuery(query, { match, prefix, field })];
429
+ const folderList = Array.isArray(folders) ? folders.filter(Boolean) : (folders ? [folders] : []);
430
+ if (folderList.length) {
431
+ where.push(`m.folder IN (${folderList.map(() => '?').join(', ')})`);
432
+ params.push(...folderList);
433
+ }
434
+ if (from) {
435
+ where.push('m.from_addr LIKE ?');
436
+ params.push(`%${from}%`);
437
+ }
438
+ if (since_date) {
439
+ where.push('m.internal_date >= ?');
440
+ params.push(localDate(since_date).getTime());
441
+ }
442
+ if (before_date) {
443
+ where.push('m.internal_date < ?');
444
+ params.push(localDate(before_date).getTime());
445
+ }
446
+ const whereSql = where.join(' AND ');
447
+ const order = sort === 'relevance' ? 'bm25(fts) ASC, m.internal_date DESC' : 'm.internal_date DESC';
448
+ const cap = Math.min(Math.max(1, Number(limit) || 20), 200);
449
+ const skip = Math.max(0, Number(offset) || 0);
450
+
451
+ const total = this.db.prepare(`SELECT count(*) AS n FROM fts JOIN messages m ON m.id = fts.rowid WHERE ${whereSql}`).get(...params).n;
452
+ const rows = this.db.prepare(`
453
+ SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments,
454
+ snippet(fts, -1, '[', ']', '...', 16) AS snippet
455
+ FROM fts JOIN messages m ON m.id = fts.rowid
456
+ WHERE ${whereSql}
457
+ ORDER BY ${order}
458
+ LIMIT ? OFFSET ?`).all(...params, cap, skip);
459
+
460
+ const st = this.status();
461
+ const scope = folderList.length ? st.folders.filter((f) => folderList.includes(f.folder)) : st.folders;
462
+ return {
463
+ total_matches: total,
464
+ returned: rows.length,
465
+ offset: skip,
466
+ index: {
467
+ complete: scope.length > 0 && scope.every((f) => f.complete && !f.last_error),
468
+ sync_running: st.sync_running,
469
+ oldest_folder_sync: scope.reduce((min, f) => (f.last_sync && (!min || f.last_sync < min) ? f.last_sync : min), null),
470
+ incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder)
471
+ },
472
+ results: rows.map((r) => ({
473
+ folder: r.folder,
474
+ uid: Number(r.uid),
475
+ date: r.sent_date || (r.internal_date ? new Date(Number(r.internal_date)).toISOString() : null),
476
+ from: r.from_addr,
477
+ to: r.to_addr,
478
+ subject: r.subject,
479
+ attachments: r.attachments || undefined,
480
+ snippet: r.snippet
481
+ }))
482
+ };
483
+ }
484
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * MIME text helpers for the local search index.
3
+ *
4
+ * @license MIT
5
+ */
6
+
7
+ const MAX_PART_BYTES = 2 * 1024 * 1024;
8
+
9
+ /** Flatten a node-imap BODYSTRUCTURE into its leaf parts. */
10
+ export function leafParts(struct, out = []) {
11
+ if (!Array.isArray(struct)) return out;
12
+ for (const node of struct) {
13
+ if (Array.isArray(node)) {
14
+ leafParts(node, out);
15
+ } else if (node && typeof node === 'object' && node.partID && node.subtype) {
16
+ out.push(node);
17
+ }
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function filenameOf(node) {
23
+ return node.disposition?.params?.filename || node.disposition?.params?.name || node.params?.name || null;
24
+ }
25
+
26
+ function isAttachment(node) {
27
+ const disposition = (node.disposition?.type || '').toLowerCase();
28
+ if (disposition === 'attachment') return true;
29
+ const ct = `${node.type}/${node.subtype}`.toLowerCase();
30
+ return !!filenameOf(node) && ct !== 'text/plain' && ct !== 'text/html';
31
+ }
32
+
33
+ /**
34
+ * Choose which parts to download for indexing. Prefers text/plain body parts
35
+ * and falls back to text/html. Also returns attachment filenames.
36
+ */
37
+ export function planParts(struct) {
38
+ const leaves = leafParts(struct);
39
+ const bodyLeaves = leaves.filter((n) => !isAttachment(n) && (n.size == null || n.size <= MAX_PART_BYTES));
40
+ const plain = bodyLeaves.filter((n) => `${n.type}/${n.subtype}`.toLowerCase() === 'text/plain');
41
+ const html = bodyLeaves.filter((n) => `${n.type}/${n.subtype}`.toLowerCase() === 'text/html');
42
+ const chosen = plain.length ? plain : html;
43
+ const attachments = leaves.filter(isAttachment).map(filenameOf).filter(Boolean);
44
+ return {
45
+ parts: chosen.map((n) => ({
46
+ partID: String(n.partID),
47
+ html: `${n.subtype}`.toLowerCase() === 'html',
48
+ encoding: (n.encoding || '').toLowerCase(),
49
+ charset: (n.params?.charset || 'utf-8').toLowerCase()
50
+ })),
51
+ attachments
52
+ };
53
+ }
54
+
55
+ function decodeQuotedPrintable(input) {
56
+ const s = input.replace(/=\r?\n/g, '');
57
+ const bytes = [];
58
+ for (let i = 0; i < s.length; i++) {
59
+ const ch = s[i];
60
+ if (ch === '=' && /^[0-9A-Fa-f]{2}$/.test(s.slice(i + 1, i + 3))) {
61
+ bytes.push(parseInt(s.slice(i + 1, i + 3), 16));
62
+ i += 2;
63
+ } else {
64
+ const code = s.charCodeAt(i);
65
+ if (code < 256) bytes.push(code);
66
+ else bytes.push(...Buffer.from(ch, 'utf8'));
67
+ }
68
+ }
69
+ return Buffer.from(bytes);
70
+ }
71
+
72
+ export function decodeTransfer(buf, encoding) {
73
+ const enc = (encoding || '').toLowerCase();
74
+ if (enc === 'base64') return Buffer.from(buf.toString('latin1').replace(/[^A-Za-z0-9+/=]/g, ''), 'base64');
75
+ if (enc === 'quoted-printable') return decodeQuotedPrintable(buf.toString('latin1'));
76
+ return buf;
77
+ }
78
+
79
+ export function decodeCharset(buf, charset) {
80
+ const label = (charset || 'utf-8').toLowerCase().replace(/^"|"$/g, '');
81
+ try {
82
+ return new TextDecoder(label).decode(buf);
83
+ } catch {
84
+ try { return new TextDecoder('utf-8').decode(buf); } catch { return buf.toString('latin1'); }
85
+ }
86
+ }
87
+
88
+ const ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", ndash: '-', mdash: '-', hellip: '...', rsquo: "'", lsquo: "'", rdquo: '"', ldquo: '"', eacute: 'é', egrave: 'è', agrave: 'à', ccedil: 'ç', uuml: 'ü', ouml: 'ö', auml: 'ä', euro: '€' };
89
+
90
+ function decodeEntities(s) {
91
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, code) => {
92
+ if (code[0] === '#') {
93
+ const n = code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
94
+ try { return String.fromCodePoint(n); } catch { return m; }
95
+ }
96
+ const v = ENTITIES[code.toLowerCase()];
97
+ return v !== undefined ? v : m;
98
+ });
99
+ }
100
+
101
+ export function htmlToText(html) {
102
+ return decodeEntities(
103
+ String(html)
104
+ .replace(/<!--[\s\S]*?-->/g, ' ')
105
+ .replace(/<(head|style|script|title)[\s\S]*?<\/\1\s*>/gi, ' ')
106
+ .replace(/<br\s*\/?>/gi, '\n')
107
+ .replace(/<\/(p|div|tr|li|h[1-6]|blockquote|table)\s*>/gi, '\n')
108
+ .replace(/<[^>]+>/g, ' ')
109
+ )
110
+ .replace(/[ \t\u00a0]+/g, ' ')
111
+ .replace(/\s*\n\s*/g, '\n')
112
+ .replace(/\n{3,}/g, '\n\n')
113
+ .trim();
114
+ }
115
+
116
+ /** Remove inline data: images from HTML so it stays small enough for a tool result. */
117
+ export function stripDataUris(html) {
118
+ return String(html).replace(/(["'(])data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+(["')])/gi, '$1[inline image removed]$2');
119
+ }
120
+
121
+ /** Turn fetched part buffers into one plain-text string. */
122
+ export function partsToText(plan, partBuffers) {
123
+ const texts = [];
124
+ for (const p of plan.parts) {
125
+ const raw = partBuffers[p.partID];
126
+ if (!raw || !raw.length) continue;
127
+ const decoded = decodeCharset(decodeTransfer(raw, p.encoding), p.charset);
128
+ texts.push(p.html ? htmlToText(decoded) : decoded.replace(/\r\n/g, '\n'));
129
+ }
130
+ return texts.join('\n\n').trim();
131
+ }
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
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",
7
7
  "bin": {
8
8
  "imap-email-mcp": "index.js"
9
9
  },
10
+ "files": [
11
+ "index.js",
12
+ "lib/",
13
+ "README.md",
14
+ "LICENSE",
15
+ ".env.example",
16
+ "CHANGELOG.md"
17
+ ],
10
18
  "scripts": {
11
19
  "start": "node index.js"
12
20
  },
@@ -26,7 +34,8 @@
26
34
  "outlook",
27
35
  "email-client",
28
36
  "ai-tools",
29
- "llm-tools"
37
+ "llm-tools",
38
+ "full-text-search"
30
39
  ],
31
40
  "author": "",
32
41
  "license": "MIT",
@@ -45,6 +54,9 @@
45
54
  "@modelcontextprotocol/sdk": "^1.0.0",
46
55
  "imap-simple": "^5.1.0",
47
56
  "mailparser": "^3.7.2",
48
- "nodemailer": "^6.9.16"
57
+ "mammoth": "^1.8.0",
58
+ "nodemailer": "^6.9.16",
59
+ "unpdf": "^1.8.1",
60
+ "xlsx": "^0.18.5"
49
61
  }
50
62
  }