@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 +6 -0
- package/README.md +2 -0
- package/index.js +83 -28
- package/lib/local-index.js +1 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1791
|
-
|
|
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
|
|
1944
|
-
|
|
1945
|
-
const
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
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
|
}]
|
package/lib/local-index.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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",
|