@lengelhard/imap-email-mcp 1.8.1 → 1.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Added
6
+ - `find_split_threads`: lists conversations whose messages sit in more than one folder, threaded on the In-Reply-To and References headers rather than on subject. A reply that was renamed ("Onboarding - Accrete" becoming "Onboarding - Accrete: data room request list") stays in its thread because its References still chain back to the root, and two unrelated conversations that happen to share a subject ("Catch-up call") never merge. `scope: "split"` (the default) reports only threads spread across the searched folders; `scope: "all"` lists every thread in scope. Each thread carries `messages`, `distinct_messages`, `by_folder`, `oldest`, `newest`, `participants` and its `message_list`; a thread whose only spread is the same Message-ID copied into two folders is marked `copies_only`, which is a duplicate for `find_duplicates`, not a split. Paged with `limit` and `offset`. Requires the local index.
7
+ - `in_reply_to` and `references` on the results of `list_emails`, `search_emails`, `search_index` and `get_email`, angle brackets stripped, folded headers unfolded, oldest first.
8
+ - The index now stores In-Reply-To and References. Rows indexed by an earlier version are backfilled by the background sync, up to 3,000 rows per folder per run, without a rebuild. `index_status` reports the number still waiting as `thread_headers_pending`, and `find_split_threads` reports it under `coverage.without_thread_headers` and marks any thread that touches such rows `threading: "partial"`, so a report on a half-backfilled index says so instead of looking complete.
9
+
10
+ ### Changed
11
+ - Opening a database written by 1.8.x adds the three threading columns in place. Searches keep working while the backfill runs.
12
+
3
13
  ## 1.8.1
4
14
 
5
15
  ### Fixed
package/README.md CHANGED
@@ -129,6 +129,16 @@ Mail clients that file by tag sometimes append a fresh copy of a message instead
129
129
  - **Honest coverage.** A message with no Message-ID header cannot be compared. It is counted in `coverage.without_message_id` rather than quietly skipped, and `index.complete` says whether the index had seen everything.
130
130
  - **Nothing is deleted.** The tool only reports. Acting on it means calling `delete_email`, which moves to Trash unless you pass `expunge: true`.
131
131
 
132
+ ### Finding split threads
133
+
134
+ A conversation can end up spread across folders without any message being duplicated: the root is filed in one place, a later reply in another. `find_split_threads` finds those by following the In-Reply-To and References headers, which every mail client writes when replying.
135
+
136
+ - **Why headers, not subject.** References lists the Message-IDs of every earlier message in the conversation, so a reply keeps its thread even after someone renames the subject, and two unrelated conversations with the same subject line stay apart. Subject matching gets both of those wrong.
137
+ - **Scope.** `scope: "split"` (default) reports only threads present in more than one of the searched folders; `scope: "all"` lists every thread. The folders you pass decide what counts as split: a thread with three messages in Delegated and two in Hold is split when both folders are searched and not when only Delegated is.
138
+ - **Copies are not splits.** A thread whose only spread is the same Message-ID present in two folders is marked `copies_only`. That is a duplicate, and `find_duplicates` is the tool for it.
139
+ - **Honest coverage.** Rows indexed before 1.9.0 have no threading headers until the background sync backfills them (3,000 rows per folder per run). `coverage.without_thread_headers` and `index_status.thread_headers_pending` show how many are still waiting, and a thread touching such rows is marked `threading: "partial"`.
140
+ - **Nothing is moved.** The tool only reports. `move_email` or `move_thread` are the tools for acting on it.
141
+
132
142
  ### Provider Settings
133
143
 
134
144
  | Provider | IMAP_HOST | SMTP_HOST | Notes |
@@ -151,6 +161,7 @@ Mail clients that file by tag sometimes append a fresh copy of a message instead
151
161
  | `index_status` | Per-folder index coverage, last sync and errors; `sync_running_seconds` shows how long the current sync has run |
152
162
  | `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
153
163
  | `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
164
+ | `find_split_threads` | Conversations spread across folders, threaded on In-Reply-To and References so renamed replies stay with their thread |
154
165
  | `list_drafts` | List all draft emails |
155
166
  | `get_draft` | Get a specific draft by UID |
156
167
  | `create_draft` | Create a new email draft |
package/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  buildCriteria
32
32
  } from './lib/imap-session.js';
33
33
  import { htmlToText, stripDataUris, leafParts } from './lib/mime-text.js';
34
- import { LocalIndex, normalizeMessageId } from './lib/local-index.js';
34
+ import { LocalIndex, normalizeMessageId, parseMessageIdList } from './lib/local-index.js';
35
35
 
36
36
  const PKG_VERSION = createRequire(import.meta.url)('./package.json').version;
37
37
 
@@ -445,9 +445,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
445
445
  required: ['query']
446
446
  }
447
447
  },
448
+ {
449
+ name: 'find_split_threads',
450
+ description: 'List conversations whose messages sit in more than one folder, threaded by Message-ID chains (In-Reply-To and References), the same way a mail client threads. Exact where the headers exist: a renamed subject stays in its thread and two conversations that share a subject never merge. A thread flagged copies_only is the same message copied into several folders, a duplicate rather than a split; see find_duplicates for those. Check coverage.without_thread_headers: messages indexed before 1.9.0 thread only by their own Message-ID until the backfill reaches them. Requires the local index.',
451
+ inputSchema: {
452
+ type: 'object',
453
+ properties: {
454
+ folders: {
455
+ type: 'array',
456
+ items: { type: 'string' },
457
+ description: 'Only these folders (default: all indexed folders). A thread counts as split only across folders in this list.'
458
+ },
459
+ scope: {
460
+ type: 'string',
461
+ enum: ['split', 'all'],
462
+ description: 'split: threads spanning more than one folder (default); all: every thread',
463
+ default: 'split'
464
+ },
465
+ since_date: {
466
+ type: 'string',
467
+ description: 'Only messages delivered on or after this date (YYYY-MM-DD)'
468
+ },
469
+ before_date: {
470
+ type: 'string',
471
+ description: 'Only messages delivered before this date (YYYY-MM-DD)'
472
+ },
473
+ limit: {
474
+ type: 'number',
475
+ description: 'Maximum threads to return (default: 50, max: 500). total_threads reports the full count.',
476
+ default: 50
477
+ },
478
+ offset: {
479
+ type: 'number',
480
+ description: 'Skip this many threads, for paging (default: 0). has_more says whether another page exists.',
481
+ default: 0
482
+ },
483
+ refresh: {
484
+ type: 'boolean',
485
+ description: 'Pick up new and moved mail before searching (default: true). Skipped while a background sync is running.',
486
+ default: true
487
+ }
488
+ },
489
+ required: []
490
+ }
491
+ },
448
492
  {
449
493
  name: 'index_status',
450
- description: 'Show the local index state: per-folder message counts on the server and in the index, whether backfill is complete, last sync time and errors. sync_running_seconds says how long the current sync has been going; a value far beyond sync_max_minutes means it is stuck and sync_index with force: true will cut it loose.',
494
+ description: 'Show the local index state: per-folder message counts on the server and in the index, whether backfill is complete, last sync time and errors. thread_headers_pending counts messages still waiting for their threading headers (1.9.0 backfill). sync_running_seconds says how long the current sync has been going; a value far beyond sync_max_minutes means it is stuck and sync_index with force: true will cut it loose.',
451
495
  inputSchema: { type: 'object', properties: {}, required: [] }
452
496
  },
453
497
  {
@@ -866,7 +910,7 @@ async function connectIMAP() {
866
910
  return await sessions.acquire();
867
911
  }
868
912
 
869
- const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
913
+ const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)';
870
914
  const MAX_LIMIT = 200;
871
915
 
872
916
  function clampLimit(value, fallback = 20) {
@@ -895,6 +939,10 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
895
939
  if (h.cc?.length) item.cc = h.cc.join(', ');
896
940
  const messageId = normalizeMessageId(h['message-id']?.[0]);
897
941
  if (messageId) item.message_id = messageId;
942
+ const inReplyTo = parseMessageIdList(h['in-reply-to'])[0];
943
+ if (inReplyTo) item.in_reply_to = inReplyTo;
944
+ const references = parseMessageIdList(h.references);
945
+ if (references.length) item.references = references;
898
946
  if (withFlags) item.flags = rec.attributes.flags;
899
947
  out.push(item);
900
948
  }
@@ -1074,6 +1122,8 @@ async function handleTool(name, args) {
1074
1122
  subject: parsed.subject,
1075
1123
  date: parsed.date,
1076
1124
  ...(normalizeMessageId(parsed.messageId) && { message_id: normalizeMessageId(parsed.messageId) }),
1125
+ ...(parseMessageIdList(parsed.inReplyTo)[0] && { in_reply_to: parseMessageIdList(parsed.inReplyTo)[0] }),
1126
+ ...(parseMessageIdList(parsed.references).length && { references: parseMessageIdList(parsed.references) }),
1077
1127
  text: (htmlOnly || !parsed.text) && parsed.html ? htmlToText(parsed.html) : (parsed.text || ''),
1078
1128
  ...(htmlOnly && { text_source: 'converted from HTML' }),
1079
1129
  ...(args.include_html && parsed.html && { html: stripDataUris(parsed.html) }),
@@ -1999,6 +2049,37 @@ async function handleTool(name, args) {
1999
2049
  return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
2000
2050
  }
2001
2051
 
2052
+ case 'find_split_threads': {
2053
+ if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
2054
+ let refresh = null;
2055
+ if (args.refresh !== false) {
2056
+ if (localIndex.running) {
2057
+ refresh = 'skipped: background sync in progress';
2058
+ } else {
2059
+ const quick = localIndex.sync({ maxPerFolder: 300, passes: 1 });
2060
+ const outcome = await Promise.race([
2061
+ quick.then((r) => (r && r.skipped ? 'skipped' : 'done')),
2062
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), 20000))
2063
+ ]);
2064
+ refresh = {
2065
+ done: 'done',
2066
+ skipped: 'skipped: another server process is syncing the index',
2067
+ timeout: 'partial: refresh still running in background'
2068
+ }[outcome];
2069
+ }
2070
+ }
2071
+ const found = localIndex.findThreads({
2072
+ folders: args.folders || null,
2073
+ scope: args.scope || 'split',
2074
+ since_date: args.since_date || null,
2075
+ before_date: args.before_date || null,
2076
+ limit: args.limit,
2077
+ offset: args.offset
2078
+ });
2079
+ if (refresh) found.index = { ...found.index, refresh };
2080
+ return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
2081
+ }
2082
+
2002
2083
  case 'sync_index': {
2003
2084
  if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
2004
2085
  const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
@@ -56,7 +56,8 @@ function pidAlive(pid) {
56
56
  return err.code === 'EPERM';
57
57
  }
58
58
  }
59
- const HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
59
+ const HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)';
60
+ const THREAD_BACKFILL_PER_FOLDER = 3000;
60
61
  const DEFAULT_EXCLUDE = ['trash', 'junk', 'spam', 'deleted items', 'deleted messages', 'bin'];
61
62
  const SPECIAL_EXCLUDE = ['\\trash', '\\junk'];
62
63
 
@@ -134,6 +135,22 @@ export function normalizeMessageId(value) {
134
135
  return id || null;
135
136
  }
136
137
 
138
+ /**
139
+ * Every Message-ID in an In-Reply-To or References header, in order, without
140
+ * duplicates. A References header can be folded across lines and can carry
141
+ * several ids; a header with no angle brackets is treated as one bare id.
142
+ */
143
+ export function parseMessageIdList(value) {
144
+ const text = Array.isArray(value) ? value.join(' ') : String(value ?? '');
145
+ const out = [];
146
+ const seen = new Set();
147
+ const push = (id) => { const n = normalizeMessageId(id); if (n && !seen.has(n)) { seen.add(n); out.push(n); } };
148
+ const bracketed = text.match(/<[^<>]+>/g);
149
+ if (bracketed) bracketed.forEach(push);
150
+ else text.split(/\s+/).forEach(push);
151
+ return out;
152
+ }
153
+
137
154
  export class LocalIndex {
138
155
  constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, maxRunMinutes = DEFAULT_MAX_RUN_MINUTES, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
139
156
  this.imapConfig = imapConfig;
@@ -207,6 +224,13 @@ export class LocalIndex {
207
224
  tokenize = 'unicode61 remove_diacritics 2'
208
225
  );
209
226
  `);
227
+ // 1.9.0: threading headers. Rows indexed by an older version have
228
+ // thread_headers = 0 and are backfilled by the next syncs, headers only.
229
+ const columns = new Set(this.db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name));
230
+ if (!columns.has('in_reply_to')) this.db.exec('ALTER TABLE messages ADD COLUMN in_reply_to TEXT');
231
+ if (!columns.has('references_ids')) this.db.exec('ALTER TABLE messages ADD COLUMN references_ids TEXT');
232
+ if (!columns.has('thread_headers')) this.db.exec('ALTER TABLE messages ADD COLUMN thread_headers INTEGER NOT NULL DEFAULT 0');
233
+ this.db.exec('CREATE INDEX IF NOT EXISTS messages_thread_headers ON messages (thread_headers)');
210
234
  const version = this.db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version');
211
235
  if (!version) this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION);
212
236
  return this;
@@ -593,6 +617,8 @@ export class LocalIndex {
593
617
  added += await this._indexBatch(conn, folder, uidvalidity, todo.slice(i, i + this.batchSize));
594
618
  }
595
619
 
620
+ await this._backfillThreadHeaders(conn, folder);
621
+
596
622
  const pending = missing.length - todo.length;
597
623
  const indexed = this.db.prepare('SELECT count(*) AS n FROM messages WHERE folder = ?').get(folder).n;
598
624
  this.db.prepare(`INSERT INTO folders (name, uidvalidity, server_count, indexed_count, backfill_complete, last_sync, last_error)
@@ -607,6 +633,44 @@ export class LocalIndex {
607
633
  }
608
634
  }
609
635
 
636
+ /**
637
+ * Fetch In-Reply-To and References for rows indexed before 1.9.0, headers
638
+ * only, in batches, up to THREAD_BACKFILL_PER_FOLDER rows per run so a large
639
+ * Archive is spread over a few syncs rather than one long one.
640
+ */
641
+ async _backfillThreadHeaders(conn, folder) {
642
+ const rows = this.db.prepare('SELECT uid FROM messages WHERE folder = ? AND thread_headers = 0 ORDER BY uid DESC LIMIT ?')
643
+ .all(folder, THREAD_BACKFILL_PER_FOLDER);
644
+ if (!rows.length) return 0;
645
+ const parseHeader = conn.imap.constructor.parseHeader;
646
+ const upd = this.db.prepare('UPDATE messages SET in_reply_to = ?, references_ids = ?, thread_headers = 1 WHERE folder = ? AND uid = ?');
647
+ let done = 0;
648
+ for (let i = 0; i < rows.length; i += this.batchSize) {
649
+ this._assertLock();
650
+ this.progressAt = Date.now();
651
+ const uids = rows.slice(i, i + this.batchSize).map((r) => Number(r.uid));
652
+ const fetched = await fetchMessages(conn, uids, { bodies: ['HEADER.FIELDS (IN-REPLY-TO REFERENCES)'] });
653
+ this._assertLock();
654
+ this.db.exec('BEGIN');
655
+ try {
656
+ for (const uid of uids) {
657
+ const rec = fetched.get(uid);
658
+ // A uid the server no longer has is marked done; the next set
659
+ // difference removes the row.
660
+ const h = rec ? headerOf(rec, parseHeader) : {};
661
+ upd.run(parseMessageIdList(h['in-reply-to'])[0] || null, parseMessageIdList(h.references).join(' ') || null, folder, uid);
662
+ done++;
663
+ }
664
+ this.db.exec('COMMIT');
665
+ } catch (err) {
666
+ this.db.exec('ROLLBACK');
667
+ throw err;
668
+ }
669
+ }
670
+ if (done) this.log(`[index] ${folder}: threading headers backfilled for ${done} message(s)`);
671
+ return done;
672
+ }
673
+
610
674
  async _indexBatch(conn, folder, uidvalidity, uids) {
611
675
  const meta = await fetchMessages(conn, uids, { bodies: [HEADER_FIELDS], struct: true, size: true });
612
676
  this._assertLock(); // a run cut loose by the cap must not write after the fact
@@ -632,8 +696,9 @@ export class LocalIndex {
632
696
  this._assertLock();
633
697
 
634
698
  const insMsg = this.db.prepare(`INSERT OR IGNORE INTO messages
635
- (folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars)
636
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
699
+ (folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars,
700
+ in_reply_to, references_ids, thread_headers)
701
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`);
637
702
  const insFts = this.db.prepare('INSERT INTO fts (rowid, subject, from_addr, to_addr, attachments, body) VALUES (?, ?, ?, ?, ?, ?)');
638
703
 
639
704
  let added = 0;
@@ -655,7 +720,8 @@ export class LocalIndex {
655
720
  const attachments = plan.attachments.join(', ');
656
721
  const internal = rec.attributes.date instanceof Date ? rec.attributes.date.getTime() : null;
657
722
  const res = insMsg.run(folder, uid, uidvalidity, internal, (h.date || [null])[0], from, addressText(h.to), addressText(h.cc),
658
- subject, (h['message-id'] || [null])[0], rec.attributes.size ?? null, attachments, body.length);
723
+ subject, (h['message-id'] || [null])[0], rec.attributes.size ?? null, attachments, body.length,
724
+ parseMessageIdList(h['in-reply-to'])[0] || null, parseMessageIdList(h.references).join(' ') || null);
659
725
  if (res.changes) {
660
726
  insFts.run(Number(res.lastInsertRowid), subject, from, to, attachments, body);
661
727
  added++;
@@ -701,6 +767,7 @@ export class LocalIndex {
701
767
  sync_max_minutes: this.maxRunMs / 60000,
702
768
  command_timeout_seconds: getOpTimeout() / 1000,
703
769
  complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
770
+ thread_headers_pending: this.db.prepare('SELECT count(*) AS n FROM messages WHERE thread_headers = 0').get().n,
704
771
  last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
705
772
  last_run: lastRun,
706
773
  folders
@@ -853,6 +920,129 @@ export class LocalIndex {
853
920
  };
854
921
  }
855
922
 
923
+ /**
924
+ * Group messages into conversations by their Message-ID chains.
925
+ *
926
+ * Two messages are in the same thread when their Message-ID, In-Reply-To
927
+ * and References ids overlap, transitively. That is how mail clients thread,
928
+ * and it is exact: a renamed subject stays in its thread, two conversations
929
+ * that share a subject do not merge. A message with no chain at all (no
930
+ * In-Reply-To, no References, and nothing pointing at it) is a thread of one.
931
+ *
932
+ * scope:
933
+ * split threads whose messages sit in more than one of the searched
934
+ * folders (default). A thread whose spread is only the same
935
+ * message copied into two folders is flagged copies_only, since
936
+ * that is a duplicate, not a split.
937
+ * all every thread in scope.
938
+ */
939
+ findThreads({ folders = null, scope = 'split', since_date = null, before_date = null, limit = 50, offset = 0 } = {}) {
940
+ if (!['split', 'all'].includes(scope)) throw new Error(`Unknown scope: ${scope}. Use split or all.`);
941
+ const folderList = Array.isArray(folders) ? folders.filter(Boolean) : (folders ? [folders] : []);
942
+ const where = [];
943
+ const params = [];
944
+ if (folderList.length) {
945
+ where.push(`folder IN (${folderList.map(() => '?').join(', ')})`);
946
+ params.push(...folderList);
947
+ }
948
+ if (since_date) { where.push('internal_date >= ?'); params.push(localDate(since_date).getTime()); }
949
+ if (before_date) { where.push('internal_date < ?'); params.push(localDate(before_date).getTime()); }
950
+ const scopeSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
951
+ const rows = this.db.prepare(`
952
+ SELECT id, folder, uid, message_id, in_reply_to, references_ids, subject, from_addr, internal_date, sent_date, thread_headers
953
+ FROM messages ${scopeSql}`).all(...params);
954
+
955
+ // Union-find over ids. Each message joins its own Message-ID with every id
956
+ // it references; a message without a Message-ID gets a private key.
957
+ const parent = new Map();
958
+ const find = (k) => {
959
+ let r = k;
960
+ while (parent.get(r) !== r) r = parent.get(r);
961
+ let c = k;
962
+ while (parent.get(c) !== r) { const n = parent.get(c); parent.set(c, r); c = n; }
963
+ return r;
964
+ };
965
+ const add = (k) => { if (!parent.has(k)) parent.set(k, k); };
966
+ const union = (a, b) => { add(a); add(b); const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(ra, rb); };
967
+ const keyOf = new Map();
968
+ let withHeaders = 0;
969
+ for (const r of rows) {
970
+ const own = normalizeMessageId(r.message_id) || `row:${r.id}`;
971
+ keyOf.set(r.id, own);
972
+ add(own);
973
+ if (r.thread_headers) withHeaders++;
974
+ if (r.in_reply_to) union(own, r.in_reply_to);
975
+ if (r.references_ids) for (const ref of r.references_ids.split(' ')) if (ref) union(own, ref);
976
+ }
977
+
978
+ const threads = new Map();
979
+ for (const r of rows) {
980
+ const root = find(keyOf.get(r.id));
981
+ if (!threads.has(root)) threads.set(root, []);
982
+ threads.get(root).push(r);
983
+ }
984
+
985
+ const dateOf = (r) => Number(r.internal_date) || Date.parse(r.sent_date || '') || 0;
986
+ const out = [];
987
+ for (const [root, msgs] of threads) {
988
+ const byFolder = {};
989
+ for (const m of msgs) byFolder[m.folder] = (byFolder[m.folder] || 0) + 1;
990
+ const folderCount = Object.keys(byFolder).length;
991
+ if (scope === 'split' && folderCount < 2) continue;
992
+ const sorted = [...msgs].sort((a, b) => dateOf(a) - dateOf(b));
993
+ const distinctIds = new Set(sorted.map((m) => normalizeMessageId(m.message_id) || `row:${m.id}`));
994
+ // Spread that exists only because the same messages sit in two folders.
995
+ let copiesOnly = false;
996
+ if (folderCount > 1) {
997
+ const perId = new Map();
998
+ for (const m of sorted) {
999
+ const id = normalizeMessageId(m.message_id) || `row:${m.id}`;
1000
+ if (!perId.has(id)) perId.set(id, new Set());
1001
+ perId.get(id).add(m.folder);
1002
+ }
1003
+ copiesOnly = [...perId.values()].every((set) => set.size === folderCount);
1004
+ }
1005
+ const newest = sorted[sorted.length - 1];
1006
+ const oldest = sorted[0];
1007
+ const participants = [...new Set(sorted.map((m) => m.from_addr).filter(Boolean))].slice(0, 12);
1008
+ out.push({
1009
+ thread_key: root.startsWith('row:') ? null : root,
1010
+ subject: oldest.subject || '',
1011
+ latest_subject: newest.subject !== oldest.subject ? newest.subject : undefined,
1012
+ messages: msgs.length,
1013
+ distinct_messages: distinctIds.size,
1014
+ by_folder: byFolder,
1015
+ ...(folderCount > 1 && { copies_only: copiesOnly }),
1016
+ oldest: { folder: oldest.folder, uid: Number(oldest.uid), date: oldest.sent_date || new Date(dateOf(oldest)).toISOString(), from: oldest.from_addr },
1017
+ newest: { folder: newest.folder, uid: Number(newest.uid), date: newest.sent_date || new Date(dateOf(newest)).toISOString(), from: newest.from_addr },
1018
+ participants,
1019
+ threading: sorted.every((m) => m.thread_headers) ? 'headers' : 'partial: some messages still lack threading headers',
1020
+ message_list: sorted.slice(-50).map((m) => ({ folder: m.folder, uid: Number(m.uid), date: m.sent_date || new Date(dateOf(m)).toISOString(), from: m.from_addr }))
1021
+ });
1022
+ }
1023
+ out.sort((a, b) => String(b.newest.date).localeCompare(String(a.newest.date)));
1024
+ const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
1025
+ const skip = Math.max(0, Number(offset) || 0);
1026
+ const page = out.slice(skip, skip + cap);
1027
+ return {
1028
+ scope,
1029
+ folders_searched: folderList.length ? folderList : 'all indexed folders',
1030
+ coverage: {
1031
+ messages_in_scope: rows.length,
1032
+ with_thread_headers: withHeaders,
1033
+ without_thread_headers: rows.length - withHeaders,
1034
+ ...(rows.length > withHeaders && { note: 'messages without threading headers are still being backfilled; they thread only by their own Message-ID until then' }),
1035
+ threads_in_scope: threads.size
1036
+ },
1037
+ total_threads: out.length,
1038
+ returned: page.length,
1039
+ offset: skip,
1040
+ has_more: skip + page.length < out.length,
1041
+ threads: page,
1042
+ index: this._indexHealth(folderList)
1043
+ };
1044
+ }
1045
+
856
1046
  search({ query, folders = null, from = null, since_date = null, before_date = null, field = 'any', match = 'all', prefix = false, sort = 'date', limit = 20, offset = 0 }) {
857
1047
  const where = ['fts MATCH ?'];
858
1048
  const params = [ftsQuery(query, { match, prefix, field })];
@@ -881,6 +1071,7 @@ export class LocalIndex {
881
1071
  const total = this.db.prepare(`SELECT count(*) AS n FROM fts JOIN messages m ON m.id = fts.rowid WHERE ${whereSql}`).get(...params).n;
882
1072
  const rows = this.db.prepare(`
883
1073
  SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments, m.message_id,
1074
+ m.in_reply_to, m.references_ids,
884
1075
  snippet(fts, -1, '[', ']', '...', 16) AS snippet
885
1076
  FROM fts JOIN messages m ON m.id = fts.rowid
886
1077
  WHERE ${whereSql}
@@ -900,6 +1091,8 @@ export class LocalIndex {
900
1091
  to: r.to_addr,
901
1092
  subject: r.subject,
902
1093
  message_id: normalizeMessageId(r.message_id) || undefined,
1094
+ in_reply_to: r.in_reply_to || undefined,
1095
+ references: r.references_ids ? r.references_ids.split(' ') : undefined,
903
1096
  attachments: r.attachments || undefined,
904
1097
  snippet: r.snippet
905
1098
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.8.1",
3
+ "version": "1.9.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",