@lengelhard/imap-email-mcp 1.7.4 → 1.8.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0
4
+
5
+ ### Added
6
+ - `find_duplicates`: lists messages that exist more than once, matched on the Message-ID header. `scope: "same_folder"` reports a folder holding the same message twice, a filing error, and names the extra copies under `redundant_copies` (the lowest UID is kept). `scope: "cross_folder"` reports a message present in several folders, usually a split thread, and never marks anything redundant, because deleting one copy of those only relocates the problem. Messages with no Message-ID are counted in `coverage.without_message_id` instead of being silently skipped. Requires the local index.
7
+ - `message_id` on the results of `list_emails`, `search_emails`, `search_index` and `get_email`, with the angle brackets stripped. It was already fetched and stored by the indexer but never exposed, which left callers matching on subject, sender and timestamp instead. That heuristic is unreliable: in a fast thread two genuinely different replies can share a subject, a sender and a minute, and on 19 Sep 2026 it produced five candidate pairs of which none were duplicates.
8
+ - `messages.message_id` is now indexed in SQLite.
9
+
10
+ ### Changed
11
+ - **`delete_email` moves to Trash instead of erasing.** It previously flagged `\Deleted` and expunged, so a wrong UID was unrecoverable and nothing appeared in Trash. It now moves the message to the folder the server flags as `\Trash` (falling back to the usual names), and the response says where it went. Pass `expunge: true` for the old behaviour, which is permanent. A message already in Trash is still erased. On an account with no Trash folder the call now fails and says so, rather than erasing a message when the recoverable path was the one asked for.
12
+
13
+ ### Fixed
14
+ - `normalizeMessageId` returned the literal `<>` for an empty Message-ID header instead of treating it as absent, which would have grouped every such message together as duplicates of one another.
15
+
3
16
  ## 1.7.4
4
17
 
5
18
  ### Added
package/README.md CHANGED
@@ -75,7 +75,8 @@ Add to your config file:
75
75
  - **Compose** - Create and save email drafts
76
76
  - **Send** - Send emails directly via SMTP
77
77
  - **Manage drafts** - List, read, update, and delete drafts
78
- - **Delete emails** - Remove unwanted messages
78
+ - **Find duplicates** - Spot messages filed twice in one folder, or the same message sitting in several folders, matched on Message-ID
79
+ - **Delete emails** - Move to Trash by default, with an explicit opt-in to erase permanently
79
80
  - **Multi-provider support** - Works with Gmail, Outlook, Yahoo, Fastmail, and any standard IMAP provider
80
81
 
81
82
  ## Configuration
@@ -118,6 +119,15 @@ Some IMAP servers return incomplete results for body searches without reporting
118
119
  - **Recovery.** If the connection dies silently, the running sync gives up at the command time limit, records the error and releases its lock. The next sync reconnects on its own.
119
120
  - **Requirements.** Node 22.13 or later (uses the built-in `node:sqlite`, no native modules).
120
121
 
122
+ ### Finding duplicates
123
+
124
+ Mail clients that file by tag sometimes append a fresh copy of a message instead of moving the one already on the server, which leaves the same message in a folder twice. `find_duplicates` reports those, using the Message-ID header as the identity of a message.
125
+
126
+ - **Why Message-ID.** It is set by the sending server, it is unique, and it does not change when mail is moved. Matching on subject, sender and timestamp instead produces false pairs: in a fast thread two different replies can share a subject, a sender and a minute.
127
+ - **Two shapes.** `scope: "same_folder"` finds a folder holding one message more than once. Those extra copies carry nothing unique, and the tool lists them under `redundant_copies` (lowest UID kept). `scope: "cross_folder"` finds a message present in several folders, usually a split thread. Do not resolve those by deleting: removing one copy only moves the problem.
128
+ - **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.
129
+ - **Nothing is deleted.** The tool only reports. Acting on it means calling `delete_email`, which moves to Trash unless you pass `expunge: true`.
130
+
121
131
  ### Provider Settings
122
132
 
123
133
  | Provider | IMAP_HOST | SMTP_HOST | Notes |
@@ -139,12 +149,13 @@ Some IMAP servers return incomplete results for body searches without reporting
139
149
  | `search_index` | Full-text search of the local index (when `INDEX_ENABLED=true`); `index.warning` flags a stale index or folder errors |
140
150
  | `index_status` | Per-folder index coverage, last sync and errors |
141
151
  | `sync_index` | Run an index sync now |
152
+ | `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
142
153
  | `list_drafts` | List all draft emails |
143
154
  | `get_draft` | Get a specific draft by UID |
144
155
  | `create_draft` | Create a new email draft |
145
156
  | `update_draft` | Update an existing draft |
146
157
  | `send_email` | Send an email directly |
147
- | `delete_email` | Delete an email by UID |
158
+ | `delete_email` | Delete an email by UID. Moves to Trash unless `expunge: true` |
148
159
  | `download_attachment` | Download an attachment: ≤700KB returns inline base64, larger saves to disk (ATTACHMENT_DIR) |
149
160
  | `extract_text` | Extract plain text from a PDF, xlsx/xlsm/xls, docx, or text attachment in one call, parsed server-side |
150
161
 
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 } from './lib/local-index.js';
34
+ import { LocalIndex, normalizeMessageId } from './lib/local-index.js';
35
35
 
36
36
  const PKG_VERSION = createRequire(import.meta.url)('./package.json').version;
37
37
 
@@ -450,6 +450,45 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
450
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.',
451
451
  inputSchema: { type: 'object', properties: {}, required: [] }
452
452
  },
453
+ {
454
+ name: 'find_duplicates',
455
+ description: 'List messages that exist more than once, matched on their Message-ID header. Message-ID is assigned by the sending server and survives moves, so it identifies a message reliably; matching on subject, sender and timestamp instead pairs up genuinely different messages in fast threads. scope "same_folder" finds a folder holding the same message twice, which is a filing error and whose extra copies are listed under redundant_copies. scope "cross_folder" finds a message sitting in several folders, usually a split thread; never resolve those by deleting, since that only relocates the problem. Check index.complete and coverage.without_message_id before treating the result as exhaustive.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ scope: {
460
+ type: 'string',
461
+ enum: ['all', 'same_folder', 'cross_folder'],
462
+ description: 'same_folder: one folder holds the message more than once; cross_folder: the message is in several folders; all: either (default)',
463
+ default: 'all'
464
+ },
465
+ folders: {
466
+ type: 'array',
467
+ items: { type: 'string' },
468
+ description: 'Only these folders (default: all indexed folders). A copy outside this list is not counted.'
469
+ },
470
+ since_date: {
471
+ type: 'string',
472
+ description: 'Only messages delivered on or after this date (YYYY-MM-DD)'
473
+ },
474
+ before_date: {
475
+ type: 'string',
476
+ description: 'Only messages delivered before this date (YYYY-MM-DD)'
477
+ },
478
+ limit: {
479
+ type: 'number',
480
+ description: 'Maximum groups to return (default: 50, max: 500). total_groups reports the full count.',
481
+ default: 50
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
+ },
453
492
  {
454
493
  name: 'sync_index',
455
494
  description: 'Run an index sync now and wait for it (up to wait_seconds). The index is read-only: syncing never changes anything on the mail server.',
@@ -670,7 +709,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
670
709
  },
671
710
  {
672
711
  name: 'delete_email',
673
- description: 'Delete an email by UID',
712
+ description: 'Delete an email by UID. By default the message is moved to the Trash folder, so it can still be recovered; the response says where it went. Pass expunge: true to erase it from the server instead, which cannot be undone. A message already in Trash is always erased.',
674
713
  inputSchema: {
675
714
  type: 'object',
676
715
  properties: {
@@ -682,6 +721,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
682
721
  type: 'string',
683
722
  description: 'Folder name (default: INBOX)',
684
723
  default: 'INBOX'
724
+ },
725
+ expunge: {
726
+ type: 'boolean',
727
+ description: 'Erase the message from the server instead of moving it to Trash. Permanent and not recoverable (default: false).',
728
+ default: false
685
729
  }
686
730
  },
687
731
  required: ['uid']
@@ -812,7 +856,7 @@ async function connectIMAP() {
812
856
  return await sessions.acquire();
813
857
  }
814
858
 
815
- const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE)';
859
+ const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
816
860
  const MAX_LIMIT = 200;
817
861
 
818
862
  function clampLimit(value, fallback = 20) {
@@ -839,6 +883,8 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
839
883
  subject: h.subject?.[0]
840
884
  };
841
885
  if (h.cc?.length) item.cc = h.cc.join(', ');
886
+ const messageId = normalizeMessageId(h['message-id']?.[0]);
887
+ if (messageId) item.message_id = messageId;
842
888
  if (withFlags) item.flags = rec.attributes.flags;
843
889
  out.push(item);
844
890
  }
@@ -850,6 +896,37 @@ function dateValue(item) {
850
896
  return Number.isNaN(t) ? 0 : t;
851
897
  }
852
898
 
899
+ /**
900
+ * Find the mailbox that holds deleted mail.
901
+ *
902
+ * Prefers the folder the server itself flags as \Trash, since that is what the
903
+ * user's other mail clients will treat as the bin, and falls back to the usual
904
+ * names. Returns null when nothing plausible exists, which the caller reports
905
+ * rather than guessing and writing mail somewhere unexpected.
906
+ */
907
+ async function findTrashFolder(connection) {
908
+ const boxes = await connection.getBoxes();
909
+ const walk = (node, prefix = '', out = []) => {
910
+ for (const [name, box] of Object.entries(node || {})) {
911
+ const full = prefix ? `${prefix}${box.delimiter || '/'}${name}` : name;
912
+ const attribs = (box.attribs || []).map((a) => String(a).toLowerCase());
913
+ if (box.special_use_attrib) attribs.push(String(box.special_use_attrib).toLowerCase());
914
+ out.push({ name: full, attribs, leaf: name });
915
+ if (box.children) walk(box.children, full, out);
916
+ }
917
+ return out;
918
+ };
919
+ const all = walk(boxes);
920
+ const selectable = all.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
921
+
922
+ const flagged = selectable.find((b) => b.attribs.includes('\\trash'));
923
+ if (flagged) return flagged.name;
924
+
925
+ const known = ['trash', 'deleted items', 'deleted messages', 'bin', 'papierkorb', 'corbeille'];
926
+ const byName = selectable.find((b) => known.includes(b.leaf.toLowerCase()));
927
+ return byName ? byName.name : null;
928
+ }
929
+
853
930
  // Helper to find drafts folder (handles different provider conventions)
854
931
  async function findDraftsFolder(connection) {
855
932
  const boxes = await connection.getBoxes();
@@ -986,6 +1063,7 @@ async function handleTool(name, args) {
986
1063
  cc: parsed.cc?.text,
987
1064
  subject: parsed.subject,
988
1065
  date: parsed.date,
1066
+ ...(normalizeMessageId(parsed.messageId) && { message_id: normalizeMessageId(parsed.messageId) }),
989
1067
  text: (htmlOnly || !parsed.text) && parsed.html ? htmlToText(parsed.html) : (parsed.text || ''),
990
1068
  ...(htmlOnly && { text_source: 'converted from HTML' }),
991
1069
  ...(args.include_html && parsed.html && { html: stripDataUris(parsed.html) }),
@@ -1582,11 +1660,60 @@ async function handleTool(name, args) {
1582
1660
  const connection = await connectIMAP();
1583
1661
 
1584
1662
  try {
1663
+ // Default to the recoverable path: move to Trash. Erasing is opt-in,
1664
+ // because an expunge cannot be undone and a wrong uid is silent.
1665
+ const trash = args.expunge ? null : await findTrashFolder(connection);
1666
+ const alreadyInTrash = trash && trash.toLowerCase() === folder.toLowerCase();
1667
+
1585
1668
  await connection.openBox(folder);
1669
+
1670
+ if (trash && !alreadyInTrash) {
1671
+ await new Promise((resolve, reject) => {
1672
+ connection.imap.move(args.uid, trash, (err) => (err ? reject(err) : resolve()));
1673
+ });
1674
+ return {
1675
+ content: [{
1676
+ type: 'text',
1677
+ text: JSON.stringify({
1678
+ success: true,
1679
+ uid: args.uid,
1680
+ folder,
1681
+ action: 'moved_to_trash',
1682
+ trash_folder: trash,
1683
+ recoverable: true,
1684
+ note: `Moved to ${trash}. Pass expunge: true to erase permanently instead.`
1685
+ }, null, 2)
1686
+ }]
1687
+ };
1688
+ }
1689
+
1690
+ if (!args.expunge && !trash) {
1691
+ // The caller asked for the recoverable path and it is not available.
1692
+ // Erasing anyway would be a permanent action nobody asked for.
1693
+ return {
1694
+ content: [{
1695
+ type: 'text',
1696
+ text: `No Trash folder found on this server, so the message was left where it is. Pass expunge: true to erase uid ${args.uid} from ${folder} permanently, or create a Trash folder first.`
1697
+ }],
1698
+ isError: true
1699
+ };
1700
+ }
1701
+
1586
1702
  await connection.addFlags(args.uid, ['\\Deleted']);
1587
1703
  await connection.closeBox(true); // Expunge
1588
-
1589
- return { content: [{ type: 'text', text: 'Email deleted successfully' }] };
1704
+ return {
1705
+ content: [{
1706
+ type: 'text',
1707
+ text: JSON.stringify({
1708
+ success: true,
1709
+ uid: args.uid,
1710
+ folder,
1711
+ action: 'expunged',
1712
+ recoverable: false,
1713
+ ...(alreadyInTrash && { reason: 'message was already in Trash' })
1714
+ }, null, 2)
1715
+ }]
1716
+ };
1590
1717
  } finally {
1591
1718
  connection.end();
1592
1719
  }
@@ -1831,6 +1958,36 @@ async function handleTool(name, args) {
1831
1958
  return { content: [{ type: 'text', text: JSON.stringify(localIndex.status(), null, 2) }] };
1832
1959
  }
1833
1960
 
1961
+ case 'find_duplicates': {
1962
+ if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
1963
+ let refresh = null;
1964
+ if (args.refresh !== false) {
1965
+ if (localIndex.running) {
1966
+ refresh = 'skipped: background sync in progress';
1967
+ } else {
1968
+ const quick = localIndex.sync({ maxPerFolder: 300, passes: 1 });
1969
+ const outcome = await Promise.race([
1970
+ quick.then((r) => (r && r.skipped ? 'skipped' : 'done')),
1971
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), 20000))
1972
+ ]);
1973
+ refresh = {
1974
+ done: 'done',
1975
+ skipped: 'skipped: another server process is syncing the index',
1976
+ timeout: 'partial: refresh still running in background'
1977
+ }[outcome];
1978
+ }
1979
+ }
1980
+ const found = localIndex.findDuplicates({
1981
+ folders: args.folders || null,
1982
+ scope: args.scope || 'all',
1983
+ since_date: args.since_date || null,
1984
+ before_date: args.before_date || null,
1985
+ limit: args.limit
1986
+ });
1987
+ if (refresh) found.index = { ...found.index, refresh };
1988
+ return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
1989
+ }
1990
+
1834
1991
  case 'sync_index': {
1835
1992
  if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
1836
1993
  const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
@@ -116,6 +116,23 @@ function ftsQuery(query, { match = 'all', prefix = false, field = 'any' } = {})
116
116
  return expr;
117
117
  }
118
118
 
119
+ /**
120
+ * Compare form for a Message-ID header.
121
+ *
122
+ * Strips the surrounding angle brackets and whitespace and nothing else.
123
+ * Case is preserved on purpose: the local part of a Message-ID is
124
+ * case-sensitive per RFC 5322, and this value decides whether two messages
125
+ * are reported as copies of each other. A missed duplicate is a nuisance; a
126
+ * wrongly matched pair could cost someone a message, so this errs strict.
127
+ */
128
+ export function normalizeMessageId(value) {
129
+ const s = String(value ?? '').trim();
130
+ if (!s) return null;
131
+ const m = s.match(/<([^<>]+)>/);
132
+ const id = (m ? m[1] : s.replace(/^<+|>+$/g, '')).trim();
133
+ return id || null;
134
+ }
135
+
119
136
  export class LocalIndex {
120
137
  constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
121
138
  this.imapConfig = imapConfig;
@@ -177,6 +194,7 @@ export class LocalIndex {
177
194
  );
178
195
  CREATE INDEX IF NOT EXISTS messages_date ON messages (internal_date);
179
196
  CREATE INDEX IF NOT EXISTS messages_folder_date ON messages (folder, internal_date);
197
+ CREATE INDEX IF NOT EXISTS messages_message_id ON messages (message_id);
180
198
  CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5 (
181
199
  subject, from_addr, to_addr, attachments, body,
182
200
  tokenize = 'unicode61 remove_diacritics 2'
@@ -611,6 +629,148 @@ export class LocalIndex {
611
629
  };
612
630
  }
613
631
 
632
+ /** Sync health for a set of folders, shared by search() and findDuplicates(). */
633
+ _indexHealth(folderList = []) {
634
+ const st = this.status();
635
+ const scope = folderList.length ? st.folders.filter((f) => folderList.includes(f.folder)) : st.folders;
636
+ const warnings = [];
637
+ const lastFullMs = Number(this._meta('last_full_sync') || 0);
638
+ if (!lastFullMs) {
639
+ warnings.push('index has never completed a full sync, results may be missing mail');
640
+ } else if (Date.now() - lastFullMs > STALE_AFTER_MS) {
641
+ warnings.push(`index last synced ${describeAge(Date.now() - lastFullMs)} ago, results may be missing recent mail`);
642
+ }
643
+ const errored = scope.filter((f) => f.last_error);
644
+ if (errored.length) {
645
+ warnings.push(`folder error${errored.length === 1 ? '' : 's'}: ${errored.map((f) => `${f.folder} (${f.last_error})`).join(', ')}; results from ${errored.length === 1 ? 'that folder' : 'those folders'} may be incomplete`);
646
+ }
647
+ return {
648
+ complete: scope.length > 0 && scope.every((f) => f.complete && !f.last_error),
649
+ sync_running: st.sync_running,
650
+ oldest_folder_sync: scope.reduce((min, f) => (f.last_sync && (!min || f.last_sync < min) ? f.last_sync : min), null),
651
+ incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder),
652
+ ...(warnings.length && { warning: warnings.join('; ') })
653
+ };
654
+ }
655
+
656
+ /**
657
+ * Find messages that exist more than once, keyed on Message-ID.
658
+ *
659
+ * Message-ID is assigned by the sending server and does not change when mail
660
+ * is moved, so it is the only dependable identity for a message across
661
+ * folders. Matching on subject, sender and timestamp instead produces false
662
+ * pairs on fast back-and-forth threads, where two genuinely different
663
+ * messages can share everything but the second.
664
+ *
665
+ * scope:
666
+ * same_folder one folder holds the message more than once. A filing
667
+ * error: the extra copies carry nothing unique.
668
+ * cross_folder the message sits in more than one folder. Usually a split
669
+ * thread or a stray archive copy. Never resolve these by
670
+ * deleting; deleting one copy only relocates the problem.
671
+ * all either of the above (default).
672
+ *
673
+ * Messages with no Message-ID header cannot be compared and are reported
674
+ * under coverage rather than silently ignored.
675
+ */
676
+ findDuplicates({ folders = null, scope = 'all', since_date = null, before_date = null, limit = 50 } = {}) {
677
+ if (!['all', 'same_folder', 'cross_folder'].includes(scope)) {
678
+ throw new Error(`Unknown scope: ${scope}. Use all, same_folder or cross_folder.`);
679
+ }
680
+ const folderList = Array.isArray(folders) ? folders.filter(Boolean) : (folders ? [folders] : []);
681
+ const where = [];
682
+ const params = [];
683
+ if (folderList.length) {
684
+ where.push(`folder IN (${folderList.map(() => '?').join(', ')})`);
685
+ params.push(...folderList);
686
+ }
687
+ if (since_date) {
688
+ where.push('internal_date >= ?');
689
+ params.push(localDate(since_date).getTime());
690
+ }
691
+ if (before_date) {
692
+ where.push('internal_date < ?');
693
+ params.push(localDate(before_date).getTime());
694
+ }
695
+ const scopeSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
696
+ const hasId = "message_id IS NOT NULL AND trim(message_id) <> ''";
697
+ const andHasId = where.length ? `${scopeSql} AND ${hasId}` : `WHERE ${hasId}`;
698
+
699
+ const inScope = this.db.prepare(`SELECT count(*) AS n FROM messages ${scopeSql}`).get(...params).n;
700
+ const withId = this.db.prepare(`SELECT count(*) AS n FROM messages ${andHasId}`).get(...params).n;
701
+
702
+ // Pre-filter in SQL to the ids that occur more than once, then regroup in
703
+ // JS on the normalized id so bracket differences cannot split a group.
704
+ const key = "trim(replace(replace(message_id, '<', ''), '>', ''))";
705
+ const rows = this.db.prepare(`
706
+ SELECT folder, uid, message_id, subject, from_addr, internal_date, sent_date
707
+ FROM messages
708
+ ${andHasId} AND ${key} IN (
709
+ SELECT ${key} FROM messages ${andHasId} GROUP BY ${key} HAVING count(*) > 1
710
+ )
711
+ ORDER BY internal_date DESC, folder, uid`).all(...params, ...params);
712
+
713
+ const groups = new Map();
714
+ for (const r of rows) {
715
+ const id = normalizeMessageId(r.message_id);
716
+ if (!id) continue;
717
+ if (!groups.has(id)) groups.set(id, []);
718
+ groups.get(id).push(r);
719
+ }
720
+
721
+ const out = [];
722
+ for (const [message_id, copies] of groups) {
723
+ if (copies.length < 2) continue;
724
+ const byFolder = {};
725
+ for (const c of copies) byFolder[c.folder] = (byFolder[c.folder] || 0) + 1;
726
+ const sameFolder = Object.values(byFolder).some((n) => n > 1);
727
+ const crossFolder = Object.keys(byFolder).length > 1;
728
+ if (scope === 'same_folder' && !sameFolder) continue;
729
+ if (scope === 'cross_folder' && !crossFolder) continue;
730
+ const first = copies[0];
731
+ // Redundant copies only ever come from a folder holding the message more
732
+ // than once: within that folder keep the lowest uid and list the rest.
733
+ const redundant = [];
734
+ for (const [folder, n] of Object.entries(byFolder)) {
735
+ if (n < 2) continue;
736
+ const inFolder = copies.filter((c) => c.folder === folder).sort((a, b) => Number(a.uid) - Number(b.uid));
737
+ for (const c of inFolder.slice(1)) redundant.push({ folder, uid: Number(c.uid) });
738
+ }
739
+ out.push({
740
+ message_id,
741
+ subject: first.subject,
742
+ from: first.from_addr,
743
+ date: first.sent_date || (first.internal_date ? new Date(Number(first.internal_date)).toISOString() : null),
744
+ copies: copies.length,
745
+ same_folder_duplicates: sameFolder,
746
+ cross_folder: crossFolder,
747
+ by_folder: byFolder,
748
+ ...(redundant.length && { redundant_copies: redundant }),
749
+ messages: copies.map((c) => ({
750
+ folder: c.folder,
751
+ uid: Number(c.uid),
752
+ date: c.sent_date || (c.internal_date ? new Date(Number(c.internal_date)).toISOString() : null)
753
+ }))
754
+ });
755
+ }
756
+ out.sort((a, b) => (b.copies - a.copies) || String(b.date || '').localeCompare(String(a.date || '')));
757
+ const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
758
+ return {
759
+ scope,
760
+ folders_searched: folderList.length ? folderList : 'all indexed folders',
761
+ coverage: {
762
+ messages_in_scope: inScope,
763
+ with_message_id: withId,
764
+ without_message_id: inScope - withId,
765
+ ...(inScope > withId && { note: 'messages without a Message-ID header cannot be compared and are not included' })
766
+ },
767
+ total_groups: out.length,
768
+ returned: Math.min(out.length, cap),
769
+ groups: out.slice(0, cap),
770
+ index: this._indexHealth(folderList)
771
+ };
772
+ }
773
+
614
774
  search({ query, folders = null, from = null, since_date = null, before_date = null, field = 'any', match = 'all', prefix = false, sort = 'date', limit = 20, offset = 0 }) {
615
775
  const where = ['fts MATCH ?'];
616
776
  const params = [ftsQuery(query, { match, prefix, field })];
@@ -638,37 +798,18 @@ export class LocalIndex {
638
798
 
639
799
  const total = this.db.prepare(`SELECT count(*) AS n FROM fts JOIN messages m ON m.id = fts.rowid WHERE ${whereSql}`).get(...params).n;
640
800
  const rows = this.db.prepare(`
641
- SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments,
801
+ SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments, m.message_id,
642
802
  snippet(fts, -1, '[', ']', '...', 16) AS snippet
643
803
  FROM fts JOIN messages m ON m.id = fts.rowid
644
804
  WHERE ${whereSql}
645
805
  ORDER BY ${order}
646
806
  LIMIT ? OFFSET ?`).all(...params, cap, skip);
647
807
 
648
- const st = this.status();
649
- const scope = folderList.length ? st.folders.filter((f) => folderList.includes(f.folder)) : st.folders;
650
- const warnings = [];
651
- const lastFullMs = Number(this._meta('last_full_sync') || 0);
652
- if (!lastFullMs) {
653
- warnings.push('index has never completed a full sync, results may be missing mail');
654
- } else if (Date.now() - lastFullMs > STALE_AFTER_MS) {
655
- warnings.push(`index last synced ${describeAge(Date.now() - lastFullMs)} ago, results may be missing recent mail`);
656
- }
657
- const errored = scope.filter((f) => f.last_error);
658
- if (errored.length) {
659
- warnings.push(`folder error${errored.length === 1 ? '' : 's'}: ${errored.map((f) => `${f.folder} (${f.last_error})`).join(', ')}; results from ${errored.length === 1 ? 'that folder' : 'those folders'} may be incomplete`);
660
- }
661
808
  return {
662
809
  total_matches: total,
663
810
  returned: rows.length,
664
811
  offset: skip,
665
- index: {
666
- complete: scope.length > 0 && scope.every((f) => f.complete && !f.last_error),
667
- sync_running: st.sync_running,
668
- oldest_folder_sync: scope.reduce((min, f) => (f.last_sync && (!min || f.last_sync < min) ? f.last_sync : min), null),
669
- incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder),
670
- ...(warnings.length && { warning: warnings.join('; ') })
671
- },
812
+ index: this._indexHealth(folderList),
672
813
  results: rows.map((r) => ({
673
814
  folder: r.folder,
674
815
  uid: Number(r.uid),
@@ -676,6 +817,7 @@ export class LocalIndex {
676
817
  from: r.from_addr,
677
818
  to: r.to_addr,
678
819
  subject: r.subject,
820
+ message_id: normalizeMessageId(r.message_id) || undefined,
679
821
  attachments: r.attachments || undefined,
680
822
  snippet: r.snippet
681
823
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.7.4",
3
+ "version": "1.8.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",