@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 +13 -0
- package/README.md +13 -2
- package/index.js +162 -5
- package/lib/local-index.js +163 -21
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
-
|
|
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;
|
package/lib/local-index.js
CHANGED
|
@@ -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.
|
|
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",
|