@lengelhard/imap-email-mcp 1.9.1 → 1.9.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.2
4
+
5
+ ### Fixed
6
+ - `move_email` and `move_thread` no longer create a duplicate when the destination already holds the message. A mail client that files by label can leave the same message in INBOX and in the label's folder at once; moving the INBOX copy then held it twice in the destination (seen on 25 Sep 2026 after a scheduled inbox sweep). The move now checks the destination for the same Message-ID first: if found, the source copy is removed instead of copied, and the response says `action: "already_present"` (`move_email`) or lists `already_present` (`move_thread`). A message with no Message-ID is moved the plain way.
7
+ - `references` is no longer returned by `list_emails`, `search_emails` and `search_index`. On long threads it was 20 to 30 Message-IDs per message, several times the size of the rest of the result, and the tasks that read listings never use it. It stays on `get_email`, and `find_split_threads` threads on it as before. `in_reply_to` stays everywhere.
8
+
3
9
  ## 1.9.1
4
10
 
5
11
  ### Fixed
package/README.md CHANGED
@@ -163,6 +163,8 @@ A conversation can end up spread across folders without any message being duplic
163
163
  | `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
164
164
  | `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
165
165
  | `find_split_threads` | Conversations spread across folders, threaded on In-Reply-To and References so renamed replies stay with their thread |
166
+ | `move_email` | Move one message by UID. If the destination already holds it (same Message-ID) the source copy is removed instead, never duplicated |
167
+ | `move_thread` | Move every message of a thread by subject in one call, with the same duplicate-safe behaviour |
166
168
  | `list_drafts` | List all draft emails |
167
169
  | `get_draft` | Get a specific draft by UID |
168
170
  | `create_draft` | Create a new email draft |
package/index.js CHANGED
@@ -23,6 +23,7 @@ import os from 'os';
23
23
  import { createRequire } from 'module';
24
24
  import {
25
25
  setOpTimeout,
26
+ withTimeout,
26
27
  SessionManager,
27
28
  openBox,
28
29
  queryUids,
@@ -787,7 +788,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
787
788
  },
788
789
  {
789
790
  name: 'move_email',
790
- description: 'Move an email from one folder to another (e.g. Inbox → Action, Waiting, Delegated, Hold)',
791
+ description: 'Move an email from one folder to another (e.g. Inbox → Action, Waiting, Delegated, Hold). If the destination already holds the same message (same Message-ID, as when a mail client filed it there by label), the source copy is removed instead of copied and the response says action: "already_present", so a move never creates a duplicate.',
791
792
  inputSchema: {
792
793
  type: 'object',
793
794
  properties: {
@@ -856,7 +857,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
856
857
  },
857
858
  {
858
859
  name: 'move_thread',
859
- description: 'Move an entire email thread (all messages matching a subject) from one folder to another in a single call. Strips Re:/Fwd: prefixes for matching. Use move_email for single-message precision moves.',
860
+ description: 'Move an entire email thread (all messages matching a subject) from one folder to another in a single call. Strips Re:/Fwd: prefixes for matching. Messages the destination already holds (same Message-ID) are removed from the source instead of copied, reported under already_present, so a move never creates a duplicate. Use move_email for single-message precision moves.',
860
861
  inputSchema: {
861
862
  type: 'object',
862
863
  properties: {
@@ -910,7 +911,67 @@ async function connectIMAP() {
910
911
  return await sessions.acquire();
911
912
  }
912
913
 
913
- const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)';
914
+ /**
915
+ * Move UIDs from the open folder `fromFolder` to `toFolder` without creating a
916
+ * second copy. A mail client that files by label can leave the same message in
917
+ * both folders already (the INBOX copy of a message also tagged "Deal Flow"),
918
+ * and a plain MOVE would then hold it twice in the destination. When the
919
+ * destination already has a message with the same Message-ID, the source copy
920
+ * is removed instead of copied, which is what a move means anyway.
921
+ * Returns one entry per uid: { uid, action: 'moved' | 'already_present' | 'failed', ... }.
922
+ * Leaves `fromFolder` open when done.
923
+ */
924
+ async function moveWithoutDuplicates(connection, fromFolder, uids, toFolder) {
925
+ const parseHeader = connection.imap.constructor.parseHeader;
926
+ const idsByUid = new Map();
927
+ try {
928
+ const fetched = await fetchMessages(connection, uids, { bodies: ['HEADER.FIELDS (MESSAGE-ID)'] });
929
+ for (const [uid, rec] of fetched) {
930
+ const id = normalizeMessageId(headerOf(rec, parseHeader)['message-id']?.[0]);
931
+ if (id) idsByUid.set(Number(uid), id);
932
+ }
933
+ } catch { /* no ids: every uid is moved the plain way */ }
934
+
935
+ // Which of these Message-IDs does the destination already hold?
936
+ const present = new Map();
937
+ if (idsByUid.size) {
938
+ try {
939
+ await openBox(connection, toFolder, true);
940
+ for (const [uid, id] of idsByUid) {
941
+ const found = await queryUids(connection, [['HEADER', 'MESSAGE-ID', id]]);
942
+ if (found.length) present.set(uid, found[0]);
943
+ }
944
+ } catch { /* destination unreadable: fall through to plain moves, the MOVE itself will report */ }
945
+ await openBox(connection, fromFolder, false);
946
+ }
947
+
948
+ const results = [];
949
+ for (const uid of uids) {
950
+ try {
951
+ if (present.has(uid)) {
952
+ await withTimeout(connection, `remove ${uid}`, new Promise((resolve, reject) => {
953
+ connection.imap.addFlags(uid, ['\\Deleted'], (err) => {
954
+ if (err) return reject(err);
955
+ connection.imap.expunge(uid, (e) => (e ? reject(e) : resolve()));
956
+ });
957
+ }));
958
+ results.push({ uid, action: 'already_present', existing_uid: present.get(uid), message_id: idsByUid.get(uid) });
959
+ } else {
960
+ await withTimeout(connection, `move ${uid}`, new Promise((resolve, reject) => {
961
+ connection.imap.move(uid, toFolder, (err) => (err ? reject(err) : resolve()));
962
+ }));
963
+ results.push({ uid, action: 'moved' });
964
+ }
965
+ } catch (err) {
966
+ results.push({ uid, action: 'failed', error: err.message });
967
+ }
968
+ }
969
+ return results;
970
+ }
971
+
972
+ // References is not fetched here: on long threads it is 20 to 30 ids per message and
973
+ // listings are read by tasks that never use it. get_email and find_split_threads carry it.
974
+ const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID IN-REPLY-TO)';
914
975
  const MAX_LIMIT = 200;
915
976
 
916
977
  function clampLimit(value, fallback = 20) {
@@ -941,8 +1002,6 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
941
1002
  if (messageId) item.message_id = messageId;
942
1003
  const inReplyTo = parseMessageIdList(h['in-reply-to'])[0];
943
1004
  if (inReplyTo) item.in_reply_to = inReplyTo;
944
- const references = parseMessageIdList(h.references);
945
- if (references.length) item.references = references;
946
1005
  if (withFlags) item.flags = rec.attributes.flags;
947
1006
  out.push(item);
948
1007
  }
@@ -1787,12 +1846,8 @@ async function handleTool(name, args) {
1787
1846
  try {
1788
1847
  await connection.openBox(fromFolder);
1789
1848
 
1790
- await new Promise((resolve, reject) => {
1791
- connection.imap.move(args.uid, toFolder, (err) => {
1792
- if (err) reject(err);
1793
- else resolve();
1794
- });
1795
- });
1849
+ const [r] = await moveWithoutDuplicates(connection, fromFolder, [Number(args.uid)], toFolder);
1850
+ if (r.action === 'failed') throw new Error(r.error);
1796
1851
 
1797
1852
  return {
1798
1853
  content: [{
@@ -1801,7 +1856,12 @@ async function handleTool(name, args) {
1801
1856
  success: true,
1802
1857
  uid: args.uid,
1803
1858
  from: fromFolder,
1804
- to: toFolder
1859
+ to: toFolder,
1860
+ action: r.action,
1861
+ ...(r.action === 'already_present' && {
1862
+ existing_uid: r.existing_uid,
1863
+ note: `${toFolder} already held this message (same Message-ID) as uid ${r.existing_uid}; the ${fromFolder} copy was removed instead of copied, so it is not duplicated.`
1864
+ })
1805
1865
  }, null, 2)
1806
1866
  }]
1807
1867
  };
@@ -1940,22 +2000,12 @@ async function handleTool(name, args) {
1940
2000
  };
1941
2001
  }
1942
2002
 
1943
- // Move each matching UID using the same imap.move() pattern as move_email
1944
- let moved = 0;
1945
- const failures = [];
1946
- for (const uid of matchingUIDs) {
1947
- try {
1948
- await new Promise((resolve, reject) => {
1949
- connection.imap.move(uid, to_folder, (err) => {
1950
- if (err) reject(err);
1951
- else resolve();
1952
- });
1953
- });
1954
- moved++;
1955
- } catch (moveErr) {
1956
- failures.push({ uid, error: moveErr.message });
1957
- }
1958
- }
2003
+ // Move each matching UID; a message the destination already holds is
2004
+ // removed from the source instead of copied a second time.
2005
+ const results = await moveWithoutDuplicates(connection, from_folder, matchingUIDs.map(Number), to_folder);
2006
+ const moved = results.filter((r) => r.action === 'moved').length;
2007
+ const alreadyPresent = results.filter((r) => r.action === 'already_present');
2008
+ const failures = results.filter((r) => r.action === 'failed').map((r) => ({ uid: r.uid, error: r.error }));
1959
2009
 
1960
2010
  return {
1961
2011
  content: [{
@@ -1963,11 +2013,16 @@ async function handleTool(name, args) {
1963
2013
  text: JSON.stringify({
1964
2014
  success: failures.length === 0,
1965
2015
  moved,
2016
+ already_present: alreadyPresent.length,
1966
2017
  failed: failures.length,
1967
2018
  total_found: matchingUIDs.length,
1968
2019
  from_folder,
1969
2020
  to_folder,
1970
2021
  subject: normalizedTarget,
2022
+ ...(alreadyPresent.length > 0 && {
2023
+ already_present_uids: alreadyPresent.map((r) => ({ uid: r.uid, existing_uid: r.existing_uid })),
2024
+ note: `${alreadyPresent.length} message(s) were already in ${to_folder} (same Message-ID); their ${from_folder} copies were removed instead of copied, so nothing is duplicated.`
2025
+ }),
1971
2026
  ...(failures.length > 0 && { failures })
1972
2027
  }, null, 2)
1973
2028
  }]
@@ -1106,7 +1106,7 @@ export class LocalIndex {
1106
1106
  const total = this.db.prepare(`SELECT count(*) AS n FROM fts JOIN messages m ON m.id = fts.rowid WHERE ${whereSql}`).get(...params).n;
1107
1107
  const rows = this.db.prepare(`
1108
1108
  SELECT m.folder, m.uid, m.internal_date, m.sent_date, m.from_addr, m.to_addr, m.subject, m.attachments, m.message_id,
1109
- m.in_reply_to, m.references_ids,
1109
+ m.in_reply_to,
1110
1110
  snippet(fts, -1, '[', ']', '...', 16) AS snippet
1111
1111
  FROM fts JOIN messages m ON m.id = fts.rowid
1112
1112
  WHERE ${whereSql}
@@ -1127,7 +1127,6 @@ export class LocalIndex {
1127
1127
  subject: r.subject,
1128
1128
  message_id: normalizeMessageId(r.message_id) || undefined,
1129
1129
  in_reply_to: r.in_reply_to || undefined,
1130
- references: r.references_ids ? r.references_ids.split(' ') : undefined,
1131
1130
  attachments: r.attachments || undefined,
1132
1131
  snippet: r.snippet
1133
1132
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.9.1",
3
+ "version": "1.9.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",