@lengelhard/imap-email-mcp 1.7.4 → 1.8.1
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/.env.example +1 -0
- package/CHANGELOG.md +25 -0
- package/README.md +17 -5
- package/index.js +190 -10
- package/lib/imap-session.js +55 -2
- package/lib/local-index.js +260 -36
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -21,6 +21,7 @@ IMAP_HOST=imap.example.com
|
|
|
21
21
|
# INDEX_FOLDERS= (comma list; default: all folders except Trash and Junk)
|
|
22
22
|
# INDEX_EXCLUDE_FOLDERS= (comma list, added to the default exclusions)
|
|
23
23
|
# INDEX_SYNC_INTERVAL_MINUTES=5
|
|
24
|
+
# INDEX_SYNC_MAX_MINUTES=15 (wall-clock cap on one sync run; 0 disables)
|
|
24
25
|
|
|
25
26
|
# Optional - SMTP Settings (defaults to IMAP values if not set)
|
|
26
27
|
# SMTP_HOST=smtp.example.com
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- A sync could still hang forever after a connection dropped. The folder LIST at the start of every run was the one IMAP command not under the 1.7.3 time limit, and node-imap never fails a command whose socket has died, so a run that started on a silent connection (a sleeping laptop, a dropped session) waited indefinitely with `sync_running: true`, blocking every later sync and every `sync_index` call. Seen on 18, 20 and 21 Sep 2026. The LIST now runs under the same limit as every other command.
|
|
7
|
+
- As a backstop, every sync run has a wall-clock cap (`INDEX_SYNC_MAX_MINUTES`, default 15). A run that outlives it is aborted, its connection is dropped and its lease released, and the next scheduled sync starts clean. A run cut loose can no longer write to the index.
|
|
8
|
+
- The next background sync is scheduled from the end of the previous run instead of a fixed timer, so a slow run cannot queue a pile-up behind itself.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `index_status` reports `sync_running_since`, `sync_running_seconds` and `sync_max_minutes`, so a stuck sync is a visible number rather than a flag.
|
|
12
|
+
- `sync_index` takes `force: true`, which aborts a running sync before starting a fresh one. The index is read-only against the server, so this is always safe.
|
|
13
|
+
- `find_duplicates` takes `offset` and returns `has_more`, so more than 500 groups can be paged through instead of partitioned by date.
|
|
14
|
+
|
|
15
|
+
## 1.8.0
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `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.
|
|
19
|
+
- `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.
|
|
20
|
+
- `messages.message_id` is now indexed in SQLite.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- **`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.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- `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.
|
|
27
|
+
|
|
3
28
|
## 1.7.4
|
|
4
29
|
|
|
5
30
|
### 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
|
|
@@ -104,7 +105,8 @@ Add to your config file:
|
|
|
104
105
|
| `INDEX_DB_PATH` | see below | Location of the index database |
|
|
105
106
|
| `INDEX_FOLDERS` | all but Trash/Junk | Comma-separated folders to index |
|
|
106
107
|
| `INDEX_EXCLUDE_FOLDERS` | none | Comma-separated folders to skip, on top of Trash/Junk |
|
|
107
|
-
| `INDEX_SYNC_INTERVAL_MINUTES` | `5` | Background sync interval |
|
|
108
|
+
| `INDEX_SYNC_INTERVAL_MINUTES` | `5` | Background sync interval, measured from the end of the previous run |
|
|
109
|
+
| `INDEX_SYNC_MAX_MINUTES` | `15` | Wall-clock cap on one sync run; a run that outlives it is aborted and its connection dropped (`0` disables) |
|
|
108
110
|
|
|
109
111
|
### Local search index
|
|
110
112
|
|
|
@@ -118,6 +120,15 @@ Some IMAP servers return incomplete results for body searches without reporting
|
|
|
118
120
|
- **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
121
|
- **Requirements.** Node 22.13 or later (uses the built-in `node:sqlite`, no native modules).
|
|
120
122
|
|
|
123
|
+
### Finding duplicates
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
- **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.
|
|
128
|
+
- **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.
|
|
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
|
+
- **Nothing is deleted.** The tool only reports. Acting on it means calling `delete_email`, which moves to Trash unless you pass `expunge: true`.
|
|
131
|
+
|
|
121
132
|
### Provider Settings
|
|
122
133
|
|
|
123
134
|
| Provider | IMAP_HOST | SMTP_HOST | Notes |
|
|
@@ -137,14 +148,15 @@ Some IMAP servers return incomplete results for body searches without reporting
|
|
|
137
148
|
| `get_email` | Get an email by UID. Text by default; HTML (without inline images) on request |
|
|
138
149
|
| `search_emails` | Live IMAP search across one or more folders: subject, from, to, cc, body, text, dates, flags |
|
|
139
150
|
| `search_index` | Full-text search of the local index (when `INDEX_ENABLED=true`); `index.warning` flags a stale index or folder errors |
|
|
140
|
-
| `index_status` | Per-folder index coverage, last sync and errors |
|
|
141
|
-
| `sync_index` | Run an index sync now |
|
|
151
|
+
| `index_status` | Per-folder index coverage, last sync and errors; `sync_running_seconds` shows how long the current sync has run |
|
|
152
|
+
| `sync_index` | Run an index sync now; `force: true` aborts a stuck sync first |
|
|
153
|
+
| `find_duplicates` | Messages that exist more than once, matched on Message-ID: filed twice in one folder, or spread across folders |
|
|
142
154
|
| `list_drafts` | List all draft emails |
|
|
143
155
|
| `get_draft` | Get a specific draft by UID |
|
|
144
156
|
| `create_draft` | Create a new email draft |
|
|
145
157
|
| `update_draft` | Update an existing draft |
|
|
146
158
|
| `send_email` | Send an email directly |
|
|
147
|
-
| `delete_email` | Delete an email by UID |
|
|
159
|
+
| `delete_email` | Delete an email by UID. Moves to Trash unless `expunge: true` |
|
|
148
160
|
| `download_attachment` | Download an attachment: ≤700KB returns inline base64, larger saves to disk (ATTACHMENT_DIR) |
|
|
149
161
|
| `extract_text` | Extract plain text from a PDF, xlsx/xlsm/xls, docx, or text attachment in one call, parsed server-side |
|
|
150
162
|
|
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
|
|
|
@@ -447,12 +447,56 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
447
447
|
},
|
|
448
448
|
{
|
|
449
449
|
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.',
|
|
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.',
|
|
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
|
+
offset: {
|
|
484
|
+
type: 'number',
|
|
485
|
+
description: 'Skip this many groups, for paging (default: 0). has_more says whether another page exists.',
|
|
486
|
+
default: 0
|
|
487
|
+
},
|
|
488
|
+
refresh: {
|
|
489
|
+
type: 'boolean',
|
|
490
|
+
description: 'Pick up new and moved mail before searching (default: true). Skipped while a background sync is running.',
|
|
491
|
+
default: true
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
required: []
|
|
495
|
+
}
|
|
496
|
+
},
|
|
453
497
|
{
|
|
454
498
|
name: 'sync_index',
|
|
455
|
-
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.',
|
|
499
|
+
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. If a sync is already running the call waits on that one; pass force: true to abort it first and start fresh, which is the remedy for a sync that index_status shows as running far longer than sync_max_minutes.',
|
|
456
500
|
inputSchema: {
|
|
457
501
|
type: 'object',
|
|
458
502
|
properties: {
|
|
@@ -465,6 +509,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
465
509
|
type: 'number',
|
|
466
510
|
description: 'How long to wait before returning while the sync continues in the background (default: 60)',
|
|
467
511
|
default: 60
|
|
512
|
+
},
|
|
513
|
+
force: {
|
|
514
|
+
type: 'boolean',
|
|
515
|
+
description: 'Abort a sync that is already running before starting this one (default: false). Safe: the index is read-only against the server. Use when index_status shows sync_running_seconds far beyond sync_max_minutes.',
|
|
516
|
+
default: false
|
|
468
517
|
}
|
|
469
518
|
},
|
|
470
519
|
required: []
|
|
@@ -670,7 +719,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
670
719
|
},
|
|
671
720
|
{
|
|
672
721
|
name: 'delete_email',
|
|
673
|
-
description: 'Delete an email by UID',
|
|
722
|
+
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
723
|
inputSchema: {
|
|
675
724
|
type: 'object',
|
|
676
725
|
properties: {
|
|
@@ -682,6 +731,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
682
731
|
type: 'string',
|
|
683
732
|
description: 'Folder name (default: INBOX)',
|
|
684
733
|
default: 'INBOX'
|
|
734
|
+
},
|
|
735
|
+
expunge: {
|
|
736
|
+
type: 'boolean',
|
|
737
|
+
description: 'Erase the message from the server instead of moving it to Trash. Permanent and not recoverable (default: false).',
|
|
738
|
+
default: false
|
|
685
739
|
}
|
|
686
740
|
},
|
|
687
741
|
required: ['uid']
|
|
@@ -812,7 +866,7 @@ async function connectIMAP() {
|
|
|
812
866
|
return await sessions.acquire();
|
|
813
867
|
}
|
|
814
868
|
|
|
815
|
-
const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE)';
|
|
869
|
+
const LIST_HEADER_FIELDS = 'HEADER.FIELDS (FROM TO CC SUBJECT DATE MESSAGE-ID)';
|
|
816
870
|
const MAX_LIMIT = 200;
|
|
817
871
|
|
|
818
872
|
function clampLimit(value, fallback = 20) {
|
|
@@ -839,6 +893,8 @@ async function summarize(connection, folder, uids, { withFlags = true } = {}) {
|
|
|
839
893
|
subject: h.subject?.[0]
|
|
840
894
|
};
|
|
841
895
|
if (h.cc?.length) item.cc = h.cc.join(', ');
|
|
896
|
+
const messageId = normalizeMessageId(h['message-id']?.[0]);
|
|
897
|
+
if (messageId) item.message_id = messageId;
|
|
842
898
|
if (withFlags) item.flags = rec.attributes.flags;
|
|
843
899
|
out.push(item);
|
|
844
900
|
}
|
|
@@ -850,6 +906,37 @@ function dateValue(item) {
|
|
|
850
906
|
return Number.isNaN(t) ? 0 : t;
|
|
851
907
|
}
|
|
852
908
|
|
|
909
|
+
/**
|
|
910
|
+
* Find the mailbox that holds deleted mail.
|
|
911
|
+
*
|
|
912
|
+
* Prefers the folder the server itself flags as \Trash, since that is what the
|
|
913
|
+
* user's other mail clients will treat as the bin, and falls back to the usual
|
|
914
|
+
* names. Returns null when nothing plausible exists, which the caller reports
|
|
915
|
+
* rather than guessing and writing mail somewhere unexpected.
|
|
916
|
+
*/
|
|
917
|
+
async function findTrashFolder(connection) {
|
|
918
|
+
const boxes = await connection.getBoxes();
|
|
919
|
+
const walk = (node, prefix = '', out = []) => {
|
|
920
|
+
for (const [name, box] of Object.entries(node || {})) {
|
|
921
|
+
const full = prefix ? `${prefix}${box.delimiter || '/'}${name}` : name;
|
|
922
|
+
const attribs = (box.attribs || []).map((a) => String(a).toLowerCase());
|
|
923
|
+
if (box.special_use_attrib) attribs.push(String(box.special_use_attrib).toLowerCase());
|
|
924
|
+
out.push({ name: full, attribs, leaf: name });
|
|
925
|
+
if (box.children) walk(box.children, full, out);
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
};
|
|
929
|
+
const all = walk(boxes);
|
|
930
|
+
const selectable = all.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
|
|
931
|
+
|
|
932
|
+
const flagged = selectable.find((b) => b.attribs.includes('\\trash'));
|
|
933
|
+
if (flagged) return flagged.name;
|
|
934
|
+
|
|
935
|
+
const known = ['trash', 'deleted items', 'deleted messages', 'bin', 'papierkorb', 'corbeille'];
|
|
936
|
+
const byName = selectable.find((b) => known.includes(b.leaf.toLowerCase()));
|
|
937
|
+
return byName ? byName.name : null;
|
|
938
|
+
}
|
|
939
|
+
|
|
853
940
|
// Helper to find drafts folder (handles different provider conventions)
|
|
854
941
|
async function findDraftsFolder(connection) {
|
|
855
942
|
const boxes = await connection.getBoxes();
|
|
@@ -986,6 +1073,7 @@ async function handleTool(name, args) {
|
|
|
986
1073
|
cc: parsed.cc?.text,
|
|
987
1074
|
subject: parsed.subject,
|
|
988
1075
|
date: parsed.date,
|
|
1076
|
+
...(normalizeMessageId(parsed.messageId) && { message_id: normalizeMessageId(parsed.messageId) }),
|
|
989
1077
|
text: (htmlOnly || !parsed.text) && parsed.html ? htmlToText(parsed.html) : (parsed.text || ''),
|
|
990
1078
|
...(htmlOnly && { text_source: 'converted from HTML' }),
|
|
991
1079
|
...(args.include_html && parsed.html && { html: stripDataUris(parsed.html) }),
|
|
@@ -1582,11 +1670,60 @@ async function handleTool(name, args) {
|
|
|
1582
1670
|
const connection = await connectIMAP();
|
|
1583
1671
|
|
|
1584
1672
|
try {
|
|
1673
|
+
// Default to the recoverable path: move to Trash. Erasing is opt-in,
|
|
1674
|
+
// because an expunge cannot be undone and a wrong uid is silent.
|
|
1675
|
+
const trash = args.expunge ? null : await findTrashFolder(connection);
|
|
1676
|
+
const alreadyInTrash = trash && trash.toLowerCase() === folder.toLowerCase();
|
|
1677
|
+
|
|
1585
1678
|
await connection.openBox(folder);
|
|
1679
|
+
|
|
1680
|
+
if (trash && !alreadyInTrash) {
|
|
1681
|
+
await new Promise((resolve, reject) => {
|
|
1682
|
+
connection.imap.move(args.uid, trash, (err) => (err ? reject(err) : resolve()));
|
|
1683
|
+
});
|
|
1684
|
+
return {
|
|
1685
|
+
content: [{
|
|
1686
|
+
type: 'text',
|
|
1687
|
+
text: JSON.stringify({
|
|
1688
|
+
success: true,
|
|
1689
|
+
uid: args.uid,
|
|
1690
|
+
folder,
|
|
1691
|
+
action: 'moved_to_trash',
|
|
1692
|
+
trash_folder: trash,
|
|
1693
|
+
recoverable: true,
|
|
1694
|
+
note: `Moved to ${trash}. Pass expunge: true to erase permanently instead.`
|
|
1695
|
+
}, null, 2)
|
|
1696
|
+
}]
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (!args.expunge && !trash) {
|
|
1701
|
+
// The caller asked for the recoverable path and it is not available.
|
|
1702
|
+
// Erasing anyway would be a permanent action nobody asked for.
|
|
1703
|
+
return {
|
|
1704
|
+
content: [{
|
|
1705
|
+
type: 'text',
|
|
1706
|
+
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.`
|
|
1707
|
+
}],
|
|
1708
|
+
isError: true
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1586
1712
|
await connection.addFlags(args.uid, ['\\Deleted']);
|
|
1587
1713
|
await connection.closeBox(true); // Expunge
|
|
1588
|
-
|
|
1589
|
-
|
|
1714
|
+
return {
|
|
1715
|
+
content: [{
|
|
1716
|
+
type: 'text',
|
|
1717
|
+
text: JSON.stringify({
|
|
1718
|
+
success: true,
|
|
1719
|
+
uid: args.uid,
|
|
1720
|
+
folder,
|
|
1721
|
+
action: 'expunged',
|
|
1722
|
+
recoverable: false,
|
|
1723
|
+
...(alreadyInTrash && { reason: 'message was already in Trash' })
|
|
1724
|
+
}, null, 2)
|
|
1725
|
+
}]
|
|
1726
|
+
};
|
|
1590
1727
|
} finally {
|
|
1591
1728
|
connection.end();
|
|
1592
1729
|
}
|
|
@@ -1831,10 +1968,50 @@ async function handleTool(name, args) {
|
|
|
1831
1968
|
return { content: [{ type: 'text', text: JSON.stringify(localIndex.status(), null, 2) }] };
|
|
1832
1969
|
}
|
|
1833
1970
|
|
|
1971
|
+
case 'find_duplicates': {
|
|
1972
|
+
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
1973
|
+
let refresh = null;
|
|
1974
|
+
if (args.refresh !== false) {
|
|
1975
|
+
if (localIndex.running) {
|
|
1976
|
+
refresh = 'skipped: background sync in progress';
|
|
1977
|
+
} else {
|
|
1978
|
+
const quick = localIndex.sync({ maxPerFolder: 300, passes: 1 });
|
|
1979
|
+
const outcome = await Promise.race([
|
|
1980
|
+
quick.then((r) => (r && r.skipped ? 'skipped' : 'done')),
|
|
1981
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 20000))
|
|
1982
|
+
]);
|
|
1983
|
+
refresh = {
|
|
1984
|
+
done: 'done',
|
|
1985
|
+
skipped: 'skipped: another server process is syncing the index',
|
|
1986
|
+
timeout: 'partial: refresh still running in background'
|
|
1987
|
+
}[outcome];
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const found = localIndex.findDuplicates({
|
|
1991
|
+
folders: args.folders || null,
|
|
1992
|
+
scope: args.scope || 'all',
|
|
1993
|
+
since_date: args.since_date || null,
|
|
1994
|
+
before_date: args.before_date || null,
|
|
1995
|
+
limit: args.limit,
|
|
1996
|
+
offset: args.offset
|
|
1997
|
+
});
|
|
1998
|
+
if (refresh) found.index = { ...found.index, refresh };
|
|
1999
|
+
return { content: [{ type: 'text', text: JSON.stringify(found, null, 2) }] };
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1834
2002
|
case 'sync_index': {
|
|
1835
2003
|
if (!localIndex) throw new Error(localIndexError || 'Local index is not enabled (set INDEX_ENABLED=true)');
|
|
1836
2004
|
const waitMs = Math.max(0, Number(args.wait_seconds ?? 60)) * 1000;
|
|
1837
|
-
|
|
2005
|
+
let alreadyRunning = !!localIndex.running;
|
|
2006
|
+
let forced = false;
|
|
2007
|
+
if (alreadyRunning && args.force) {
|
|
2008
|
+
const stuckFor = localIndex.runningSince ? Math.round((Date.now() - localIndex.runningSince) / 1000) : null;
|
|
2009
|
+
forced = await Promise.race([
|
|
2010
|
+
localIndex.abortSync(`forced by sync_index after ${stuckFor}s`),
|
|
2011
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 15000))
|
|
2012
|
+
]);
|
|
2013
|
+
alreadyRunning = !!localIndex.running;
|
|
2014
|
+
}
|
|
1838
2015
|
const run = localIndex.sync(args.folders?.length ? { folders: args.folders } : {});
|
|
1839
2016
|
const outcome = await Promise.race([
|
|
1840
2017
|
run.then((summary) => ({ finished: true, summary })),
|
|
@@ -1845,7 +2022,9 @@ async function handleTool(name, args) {
|
|
|
1845
2022
|
content: [{
|
|
1846
2023
|
type: 'text',
|
|
1847
2024
|
text: JSON.stringify({
|
|
1848
|
-
...(
|
|
2025
|
+
...(forced === true && { note: 'A running sync was aborted first (force: true) and a fresh one started.' }),
|
|
2026
|
+
...(forced === 'timeout' && { note: 'force: true was requested but the running sync did not release within 15s; waited on it instead.' }),
|
|
2027
|
+
...(alreadyRunning && !forced && { note: 'A sync was already running; waited on that one.' }),
|
|
1849
2028
|
...(skipped && { note: `Not started: ${outcome.summary.reason}. It keeps the index current; check index_status.` }),
|
|
1850
2029
|
finished: skipped ? false : outcome.finished,
|
|
1851
2030
|
...(outcome.summary && !skipped && { summary: outcome.summary }),
|
|
@@ -1881,7 +2060,8 @@ async function main() {
|
|
|
1881
2060
|
dbPath: process.env.INDEX_DB_PATH || undefined,
|
|
1882
2061
|
folders: list(process.env.INDEX_FOLDERS),
|
|
1883
2062
|
excludeFolders: list(process.env.INDEX_EXCLUDE_FOLDERS),
|
|
1884
|
-
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5')
|
|
2063
|
+
intervalMinutes: parseFloat(process.env.INDEX_SYNC_INTERVAL_MINUTES || '5'),
|
|
2064
|
+
maxRunMinutes: parseFloat(process.env.INDEX_SYNC_MAX_MINUTES || '15')
|
|
1885
2065
|
}).open();
|
|
1886
2066
|
localIndex.start();
|
|
1887
2067
|
console.error(`Local index enabled: ${localIndex.dbPath}`);
|
package/lib/imap-session.js
CHANGED
|
@@ -89,6 +89,9 @@ export class SessionManager {
|
|
|
89
89
|
this.lastUsedAt = 0;
|
|
90
90
|
this.tail = Promise.resolve();
|
|
91
91
|
this.idleTimer = null;
|
|
92
|
+
this.activeRelease = null;
|
|
93
|
+
this.generation = 0;
|
|
94
|
+
this.abortConnect = null;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
_isUsable(conn) {
|
|
@@ -113,15 +116,34 @@ export class SessionManager {
|
|
|
113
116
|
async _connect() {
|
|
114
117
|
if (this._isUsable(this.conn)) return this.conn;
|
|
115
118
|
if (!this.connecting) {
|
|
116
|
-
|
|
119
|
+
const generation = this.generation;
|
|
120
|
+
// dropCurrent() can reject this attempt at once, so a caller queued on a
|
|
121
|
+
// socket that will never answer does not wait out the connect timeout.
|
|
122
|
+
let abort;
|
|
123
|
+
const aborted = new Promise((_, reject) => { abort = reject; });
|
|
124
|
+
aborted.catch(() => {});
|
|
125
|
+
this.abortConnect = abort;
|
|
126
|
+
const dial = (async () => {
|
|
117
127
|
const conn = await imaps.connect(this.config);
|
|
128
|
+
if (generation !== this.generation) {
|
|
129
|
+
// The caller that wanted this socket is gone; do not hand it to anyone.
|
|
130
|
+
try { conn.imap.destroy(); } catch { /* ignore */ }
|
|
131
|
+
throw new Error('connection attempt superseded');
|
|
132
|
+
}
|
|
118
133
|
// Without an 'error' listener an idle socket error would crash the process.
|
|
119
134
|
conn.on('error', (err) => this._drop(conn, `error: ${err && err.message}`));
|
|
120
135
|
conn.imap.once('close', () => { if (this.conn === conn) this.conn = null; });
|
|
121
136
|
conn.imap.once('end', () => { if (this.conn === conn) this.conn = null; });
|
|
122
137
|
this.conn = conn;
|
|
123
138
|
return conn;
|
|
124
|
-
})()
|
|
139
|
+
})();
|
|
140
|
+
dial.catch(() => {});
|
|
141
|
+
const attempt = Promise.race([dial, aborted]);
|
|
142
|
+
attempt.then(() => {}, () => {}).then(() => {
|
|
143
|
+
if (this.connecting === attempt) this.connecting = null;
|
|
144
|
+
if (this.abortConnect === abort) this.abortConnect = null;
|
|
145
|
+
});
|
|
146
|
+
this.connecting = attempt;
|
|
125
147
|
}
|
|
126
148
|
return this.connecting;
|
|
127
149
|
}
|
|
@@ -172,6 +194,7 @@ export class SessionManager {
|
|
|
172
194
|
const release = () => {
|
|
173
195
|
if (released) return;
|
|
174
196
|
released = true;
|
|
197
|
+
if (this.activeRelease === release) this.activeRelease = null;
|
|
175
198
|
this.lastUsedAt = Date.now();
|
|
176
199
|
clearTimeout(watchdog);
|
|
177
200
|
if (!this._isUsable(conn)) this._drop(conn, 'unusable after call');
|
|
@@ -185,6 +208,7 @@ export class SessionManager {
|
|
|
185
208
|
release();
|
|
186
209
|
}, this.leaseTimeoutMs);
|
|
187
210
|
if (watchdog.unref) watchdog.unref();
|
|
211
|
+
this.activeRelease = release;
|
|
188
212
|
|
|
189
213
|
return new Proxy(conn, {
|
|
190
214
|
get(target, prop) {
|
|
@@ -196,12 +220,41 @@ export class SessionManager {
|
|
|
196
220
|
});
|
|
197
221
|
}
|
|
198
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Drop the current connection (if any) so the next acquire() opens a fresh
|
|
225
|
+
* one. Used to cut a sync loose from a socket that has gone silent.
|
|
226
|
+
*/
|
|
227
|
+
dropCurrent(reason) {
|
|
228
|
+
if (this.conn) this._drop(this.conn, reason);
|
|
229
|
+
// A connect attempt still pending belongs to the run being cut loose:
|
|
230
|
+
// fail it now and detach it, so the next acquire() starts its own.
|
|
231
|
+
this.generation++;
|
|
232
|
+
const abort = this.abortConnect;
|
|
233
|
+
this.abortConnect = null;
|
|
234
|
+
this.connecting = null;
|
|
235
|
+
if (abort) abort(new Error(`connection attempt aborted: ${reason}`));
|
|
236
|
+
// Release the lease the dropped connection was handed out under, so the
|
|
237
|
+
// next acquire() does not wait behind an owner that will never return.
|
|
238
|
+
if (this.activeRelease) this.activeRelease();
|
|
239
|
+
}
|
|
240
|
+
|
|
199
241
|
close() {
|
|
200
242
|
clearTimeout(this.idleTimer);
|
|
201
243
|
if (this.conn) this._drop(this.conn, null);
|
|
202
244
|
}
|
|
203
245
|
}
|
|
204
246
|
|
|
247
|
+
/** LIST all mailboxes, under the command time limit. */
|
|
248
|
+
export function listBoxes(connection) {
|
|
249
|
+
return withTimeout(connection, 'list folders', new Promise((resolve, reject) => {
|
|
250
|
+
try {
|
|
251
|
+
connection.imap.getBoxes((err, boxes) => (err ? reject(err) : resolve(boxes)));
|
|
252
|
+
} catch (err) {
|
|
253
|
+
reject(err);
|
|
254
|
+
}
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
205
258
|
/** Open a mailbox. readOnly uses EXAMINE, which never changes flags. */
|
|
206
259
|
export function openBox(connection, name, readOnly = false) {
|
|
207
260
|
return withTimeout(connection, `open ${name}`, new Promise((resolve, reject) => {
|
package/lib/local-index.js
CHANGED
|
@@ -19,7 +19,7 @@ import fs from 'fs';
|
|
|
19
19
|
import os from 'os';
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import crypto from 'crypto';
|
|
22
|
-
import { SessionManager, openBox, fetchMessages, headerOf, localDate, uidSearchAll, getOpTimeout } from './imap-session.js';
|
|
22
|
+
import { SessionManager, openBox, fetchMessages, headerOf, localDate, uidSearchAll, listBoxes, getOpTimeout } from './imap-session.js';
|
|
23
23
|
import { planParts, partsToText } from './mime-text.js';
|
|
24
24
|
|
|
25
25
|
const SCHEMA_VERSION = '1';
|
|
@@ -29,6 +29,7 @@ const REQUEST_RETRY_MS = 15 * 1000;
|
|
|
29
29
|
const STALL_MS = 5 * 60 * 1000;
|
|
30
30
|
const REQUEST_MAX_ATTEMPTS = 6;
|
|
31
31
|
const STALE_AFTER_MS = 15 * 60 * 1000;
|
|
32
|
+
const DEFAULT_MAX_RUN_MINUTES = 15;
|
|
32
33
|
|
|
33
34
|
function describeAge(ms) {
|
|
34
35
|
const minutes = Math.round(ms / 60000);
|
|
@@ -116,18 +117,41 @@ function ftsQuery(query, { match = 'all', prefix = false, field = 'any' } = {})
|
|
|
116
117
|
return expr;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Compare form for a Message-ID header.
|
|
122
|
+
*
|
|
123
|
+
* Strips the surrounding angle brackets and whitespace and nothing else.
|
|
124
|
+
* Case is preserved on purpose: the local part of a Message-ID is
|
|
125
|
+
* case-sensitive per RFC 5322, and this value decides whether two messages
|
|
126
|
+
* are reported as copies of each other. A missed duplicate is a nuisance; a
|
|
127
|
+
* wrongly matched pair could cost someone a message, so this errs strict.
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeMessageId(value) {
|
|
130
|
+
const s = String(value ?? '').trim();
|
|
131
|
+
if (!s) return null;
|
|
132
|
+
const m = s.match(/<([^<>]+)>/);
|
|
133
|
+
const id = (m ? m[1] : s.replace(/^<+|>+$/g, '')).trim();
|
|
134
|
+
return id || null;
|
|
135
|
+
}
|
|
136
|
+
|
|
119
137
|
export class LocalIndex {
|
|
120
|
-
constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
|
|
138
|
+
constructor({ imapConfig, dbPath, folders = null, excludeFolders = [], intervalMinutes = 5, maxRunMinutes = DEFAULT_MAX_RUN_MINUTES, batchSize = 100, maxBodyChars = 100000, log = console.error }) {
|
|
121
139
|
this.imapConfig = imapConfig;
|
|
122
140
|
this.dbPath = dbPath || defaultDbPath(imapConfig.imap.user, imapConfig.imap.host);
|
|
123
141
|
this.folders = folders && folders.length ? folders : null;
|
|
124
142
|
this.excludeFolders = excludeFolders.map((f) => f.toLowerCase());
|
|
125
143
|
this.intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
|
|
144
|
+
// Wall-clock cap on one sync run. 0 disables it. Fractions are allowed for tests.
|
|
145
|
+
const cap = Number(maxRunMinutes);
|
|
146
|
+
this.maxRunMs = Number.isFinite(cap) && cap >= 0 ? cap * 60 * 1000 : DEFAULT_MAX_RUN_MINUTES * 60 * 1000;
|
|
126
147
|
this.batchSize = batchSize;
|
|
127
148
|
this.maxBodyChars = maxBodyChars;
|
|
128
149
|
this.log = log;
|
|
129
150
|
this.db = null;
|
|
130
151
|
this.running = null;
|
|
152
|
+
this.runningSince = 0;
|
|
153
|
+
this.abortRun = null;
|
|
154
|
+
this.stopped = false;
|
|
131
155
|
this.timer = null;
|
|
132
156
|
this.progressAt = 0;
|
|
133
157
|
this.debounce = null;
|
|
@@ -177,6 +201,7 @@ export class LocalIndex {
|
|
|
177
201
|
);
|
|
178
202
|
CREATE INDEX IF NOT EXISTS messages_date ON messages (internal_date);
|
|
179
203
|
CREATE INDEX IF NOT EXISTS messages_folder_date ON messages (folder, internal_date);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS messages_message_id ON messages (message_id);
|
|
180
205
|
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5 (
|
|
181
206
|
subject, from_addr, to_addr, attachments, body,
|
|
182
207
|
tokenize = 'unicode61 remove_diacritics 2'
|
|
@@ -188,19 +213,31 @@ export class LocalIndex {
|
|
|
188
213
|
}
|
|
189
214
|
|
|
190
215
|
start() {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
216
|
+
// The next scheduled sync is armed when the previous one ends, so a slow
|
|
217
|
+
// or stuck run can never queue a pile-up behind itself.
|
|
218
|
+
const arm = (delay) => {
|
|
219
|
+
clearTimeout(this.timer);
|
|
220
|
+
if (this.stopped) return;
|
|
221
|
+
this.timer = setTimeout(kick, delay);
|
|
222
|
+
if (this.timer.unref) this.timer.unref();
|
|
223
|
+
};
|
|
224
|
+
const kick = async () => {
|
|
225
|
+
try {
|
|
226
|
+
const last = Number(this._meta('last_full_sync') || 0);
|
|
227
|
+
if (Date.now() - last >= this.intervalMs / 2) await this.sync();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
this.log(`[index] sync failed: ${err.message}`);
|
|
230
|
+
} finally {
|
|
231
|
+
arm(this.intervalMs);
|
|
232
|
+
}
|
|
195
233
|
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
this.timer = setInterval(kick, this.intervalMs);
|
|
199
|
-
if (this.timer.unref) this.timer.unref();
|
|
234
|
+
this.stopped = false;
|
|
235
|
+
arm(5000);
|
|
200
236
|
}
|
|
201
237
|
|
|
202
238
|
stop() {
|
|
203
|
-
|
|
239
|
+
this.stopped = true;
|
|
240
|
+
clearTimeout(this.timer);
|
|
204
241
|
clearTimeout(this.debounce);
|
|
205
242
|
this.releaseLock();
|
|
206
243
|
this.session.close();
|
|
@@ -314,7 +351,7 @@ export class LocalIndex {
|
|
|
314
351
|
}
|
|
315
352
|
|
|
316
353
|
async _targetFolders(connection) {
|
|
317
|
-
const boxes = flattenBoxes(await connection
|
|
354
|
+
const boxes = flattenBoxes(await listBoxes(connection));
|
|
318
355
|
const selectable = boxes.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
|
|
319
356
|
if (this.folders) {
|
|
320
357
|
const wanted = new Set(this.folders.map((f) => f.toLowerCase()));
|
|
@@ -335,6 +372,7 @@ export class LocalIndex {
|
|
|
335
372
|
*/
|
|
336
373
|
sync({ folders = null, maxPerFolder = 1000, passes = Infinity } = {}) {
|
|
337
374
|
if (this.running) return this.running;
|
|
375
|
+
this.runningSince = Date.now();
|
|
338
376
|
this.running = (async () => {
|
|
339
377
|
if (!this._acquireLock()) {
|
|
340
378
|
return { skipped: true, reason: `another server process (pid ${this.lockHolder}) is syncing the index` };
|
|
@@ -355,7 +393,7 @@ export class LocalIndex {
|
|
|
355
393
|
}, LOCK_HEARTBEAT_MS);
|
|
356
394
|
if (heartbeat.unref) heartbeat.unref();
|
|
357
395
|
try {
|
|
358
|
-
const summary = await this._sync({ folders, maxPerFolder, passes });
|
|
396
|
+
const summary = await this._runCapped(() => this._sync({ folders, maxPerFolder, passes }));
|
|
359
397
|
if (this.db) {
|
|
360
398
|
if (!folders && passes === Infinity && !summary.aborted) this._setMeta('last_full_sync', Date.now());
|
|
361
399
|
this._setMeta('last_run', JSON.stringify({
|
|
@@ -373,15 +411,58 @@ export class LocalIndex {
|
|
|
373
411
|
clearInterval(heartbeat);
|
|
374
412
|
this.releaseLock();
|
|
375
413
|
}
|
|
376
|
-
})().finally(() => { this.running = null; });
|
|
414
|
+
})().finally(() => { this.running = null; this.runningSince = 0; this.abortRun = null; });
|
|
377
415
|
return this.running;
|
|
378
416
|
}
|
|
379
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Run one sync body under a wall-clock cap and an abort handle. A run that
|
|
420
|
+
* outlives the cap, or is aborted through abortSync(), is cut loose: its
|
|
421
|
+
* connection is dropped so any command it is waiting on fails, the caller
|
|
422
|
+
* releases the lock, and the orphaned body cannot touch the index again
|
|
423
|
+
* because _assertLock() throws once the lock is gone.
|
|
424
|
+
*/
|
|
425
|
+
_runCapped(body) {
|
|
426
|
+
const started = Date.now();
|
|
427
|
+
let capTimer = null;
|
|
428
|
+
const cut = (reason) => ({ added: 0, removed: 0, folders: {}, aborted: reason, duration_ms: Date.now() - started, cut: true });
|
|
429
|
+
const stop = new Promise((resolve) => {
|
|
430
|
+
this.abortRun = (reason) => resolve(cut(reason));
|
|
431
|
+
if (this.maxRunMs > 0) {
|
|
432
|
+
const minutes = this.maxRunMs / 60000;
|
|
433
|
+
capTimer = setTimeout(() => this.abortRun(`sync exceeded ${minutes} minute${minutes === 1 ? '' : 's'}, aborted`), this.maxRunMs);
|
|
434
|
+
if (capTimer.unref) capTimer.unref();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
const guarded = body().catch((err) => ({ error: err }));
|
|
438
|
+
return Promise.race([guarded, stop]).then((summary) => {
|
|
439
|
+
clearTimeout(capTimer);
|
|
440
|
+
if (summary.error) throw summary.error;
|
|
441
|
+
if (summary.cut) {
|
|
442
|
+
delete summary.cut;
|
|
443
|
+
this.session.dropCurrent(summary.aborted);
|
|
444
|
+
this.log(`[index] sync stopped: ${summary.aborted}`);
|
|
445
|
+
}
|
|
446
|
+
return summary;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Abort a running sync so a fresh one can start. Resolves once the run has
|
|
452
|
+
* been released. Returns false when nothing was running.
|
|
453
|
+
*/
|
|
454
|
+
async abortSync(reason = 'aborted') {
|
|
455
|
+
if (!this.running) return false;
|
|
456
|
+
if (this.abortRun) this.abortRun(reason);
|
|
457
|
+
try { await this.running; } catch { /* the aborted run's own error is not interesting */ }
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
380
461
|
async _sync({ folders, maxPerFolder, passes }) {
|
|
381
462
|
const started = Date.now();
|
|
382
463
|
const summary = { added: 0, removed: 0, folders: {} };
|
|
383
464
|
let targets;
|
|
384
|
-
{
|
|
465
|
+
try {
|
|
385
466
|
const conn = await this.session.acquire();
|
|
386
467
|
try {
|
|
387
468
|
const all = await this._targetFolders(conn);
|
|
@@ -390,6 +471,14 @@ export class LocalIndex {
|
|
|
390
471
|
} finally {
|
|
391
472
|
conn.end();
|
|
392
473
|
}
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (err.code !== 'IMAP_TIMEOUT') throw err;
|
|
476
|
+
// The folder LIST on a dead connection: the run ends here, recorded like
|
|
477
|
+
// any other timed-out command, and the next scheduled sync reconnects.
|
|
478
|
+
summary.aborted = err.message;
|
|
479
|
+
summary.duration_ms = Date.now() - started;
|
|
480
|
+
this.log(`[index] sync stopped: ${err.message}`);
|
|
481
|
+
return summary;
|
|
393
482
|
}
|
|
394
483
|
|
|
395
484
|
let pass = 0;
|
|
@@ -520,6 +609,7 @@ export class LocalIndex {
|
|
|
520
609
|
|
|
521
610
|
async _indexBatch(conn, folder, uidvalidity, uids) {
|
|
522
611
|
const meta = await fetchMessages(conn, uids, { bodies: [HEADER_FIELDS], struct: true, size: true });
|
|
612
|
+
this._assertLock(); // a run cut loose by the cap must not write after the fact
|
|
523
613
|
const parseHeader = conn.imap.constructor.parseHeader;
|
|
524
614
|
|
|
525
615
|
// Group messages by the exact set of text parts they need, so each group
|
|
@@ -539,6 +629,7 @@ export class LocalIndex {
|
|
|
539
629
|
const fetched = await fetchMessages(conn, groupUids, { bodies: key.split(',') });
|
|
540
630
|
for (const [uid, rec] of fetched) bodies.set(uid, rec.parts);
|
|
541
631
|
}
|
|
632
|
+
this._assertLock();
|
|
542
633
|
|
|
543
634
|
const insMsg = this.db.prepare(`INSERT OR IGNORE INTO messages
|
|
544
635
|
(folder, uid, uidvalidity, internal_date, sent_date, from_addr, to_addr, cc_addr, subject, message_id, size, attachments, body_chars)
|
|
@@ -601,8 +692,13 @@ export class LocalIndex {
|
|
|
601
692
|
db_size_mb: Math.round(sizeBytes / 1024 / 102.4) / 10,
|
|
602
693
|
this_pid: process.pid,
|
|
603
694
|
sync_running: !!this.running || lockLive,
|
|
695
|
+
...(this.running && this.runningSince && {
|
|
696
|
+
sync_running_since: new Date(this.runningSince).toISOString(),
|
|
697
|
+
sync_running_seconds: Math.round((Date.now() - this.runningSince) / 1000)
|
|
698
|
+
}),
|
|
604
699
|
...(lockLive && lock.pid !== process.pid && { sync_running_in_pid: lock.pid }),
|
|
605
700
|
sync_interval_minutes: this.intervalMs / 60000,
|
|
701
|
+
sync_max_minutes: this.maxRunMs / 60000,
|
|
606
702
|
command_timeout_seconds: getOpTimeout() / 1000,
|
|
607
703
|
complete: folders.length > 0 && folders.every((f) => f.complete && !f.last_error),
|
|
608
704
|
last_full_sync: lastFull ? new Date(lastFull).toISOString() : null,
|
|
@@ -611,6 +707,152 @@ export class LocalIndex {
|
|
|
611
707
|
};
|
|
612
708
|
}
|
|
613
709
|
|
|
710
|
+
/** Sync health for a set of folders, shared by search() and findDuplicates(). */
|
|
711
|
+
_indexHealth(folderList = []) {
|
|
712
|
+
const st = this.status();
|
|
713
|
+
const scope = folderList.length ? st.folders.filter((f) => folderList.includes(f.folder)) : st.folders;
|
|
714
|
+
const warnings = [];
|
|
715
|
+
const lastFullMs = Number(this._meta('last_full_sync') || 0);
|
|
716
|
+
if (!lastFullMs) {
|
|
717
|
+
warnings.push('index has never completed a full sync, results may be missing mail');
|
|
718
|
+
} else if (Date.now() - lastFullMs > STALE_AFTER_MS) {
|
|
719
|
+
warnings.push(`index last synced ${describeAge(Date.now() - lastFullMs)} ago, results may be missing recent mail`);
|
|
720
|
+
}
|
|
721
|
+
const errored = scope.filter((f) => f.last_error);
|
|
722
|
+
if (errored.length) {
|
|
723
|
+
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`);
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
complete: scope.length > 0 && scope.every((f) => f.complete && !f.last_error),
|
|
727
|
+
sync_running: st.sync_running,
|
|
728
|
+
oldest_folder_sync: scope.reduce((min, f) => (f.last_sync && (!min || f.last_sync < min) ? f.last_sync : min), null),
|
|
729
|
+
incomplete_folders: scope.filter((f) => !f.complete || f.last_error).map((f) => f.folder),
|
|
730
|
+
...(warnings.length && { warning: warnings.join('; ') })
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Find messages that exist more than once, keyed on Message-ID.
|
|
736
|
+
*
|
|
737
|
+
* Message-ID is assigned by the sending server and does not change when mail
|
|
738
|
+
* is moved, so it is the only dependable identity for a message across
|
|
739
|
+
* folders. Matching on subject, sender and timestamp instead produces false
|
|
740
|
+
* pairs on fast back-and-forth threads, where two genuinely different
|
|
741
|
+
* messages can share everything but the second.
|
|
742
|
+
*
|
|
743
|
+
* scope:
|
|
744
|
+
* same_folder one folder holds the message more than once. A filing
|
|
745
|
+
* error: the extra copies carry nothing unique.
|
|
746
|
+
* cross_folder the message sits in more than one folder. Usually a split
|
|
747
|
+
* thread or a stray archive copy. Never resolve these by
|
|
748
|
+
* deleting; deleting one copy only relocates the problem.
|
|
749
|
+
* all either of the above (default).
|
|
750
|
+
*
|
|
751
|
+
* Messages with no Message-ID header cannot be compared and are reported
|
|
752
|
+
* under coverage rather than silently ignored.
|
|
753
|
+
*/
|
|
754
|
+
findDuplicates({ folders = null, scope = 'all', since_date = null, before_date = null, limit = 50, offset = 0 } = {}) {
|
|
755
|
+
if (!['all', 'same_folder', 'cross_folder'].includes(scope)) {
|
|
756
|
+
throw new Error(`Unknown scope: ${scope}. Use all, same_folder or cross_folder.`);
|
|
757
|
+
}
|
|
758
|
+
const folderList = Array.isArray(folders) ? folders.filter(Boolean) : (folders ? [folders] : []);
|
|
759
|
+
const where = [];
|
|
760
|
+
const params = [];
|
|
761
|
+
if (folderList.length) {
|
|
762
|
+
where.push(`folder IN (${folderList.map(() => '?').join(', ')})`);
|
|
763
|
+
params.push(...folderList);
|
|
764
|
+
}
|
|
765
|
+
if (since_date) {
|
|
766
|
+
where.push('internal_date >= ?');
|
|
767
|
+
params.push(localDate(since_date).getTime());
|
|
768
|
+
}
|
|
769
|
+
if (before_date) {
|
|
770
|
+
where.push('internal_date < ?');
|
|
771
|
+
params.push(localDate(before_date).getTime());
|
|
772
|
+
}
|
|
773
|
+
const scopeSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
774
|
+
const hasId = "message_id IS NOT NULL AND trim(message_id) <> ''";
|
|
775
|
+
const andHasId = where.length ? `${scopeSql} AND ${hasId}` : `WHERE ${hasId}`;
|
|
776
|
+
|
|
777
|
+
const inScope = this.db.prepare(`SELECT count(*) AS n FROM messages ${scopeSql}`).get(...params).n;
|
|
778
|
+
const withId = this.db.prepare(`SELECT count(*) AS n FROM messages ${andHasId}`).get(...params).n;
|
|
779
|
+
|
|
780
|
+
// Pre-filter in SQL to the ids that occur more than once, then regroup in
|
|
781
|
+
// JS on the normalized id so bracket differences cannot split a group.
|
|
782
|
+
const key = "trim(replace(replace(message_id, '<', ''), '>', ''))";
|
|
783
|
+
const rows = this.db.prepare(`
|
|
784
|
+
SELECT folder, uid, message_id, subject, from_addr, internal_date, sent_date
|
|
785
|
+
FROM messages
|
|
786
|
+
${andHasId} AND ${key} IN (
|
|
787
|
+
SELECT ${key} FROM messages ${andHasId} GROUP BY ${key} HAVING count(*) > 1
|
|
788
|
+
)
|
|
789
|
+
ORDER BY internal_date DESC, folder, uid`).all(...params, ...params);
|
|
790
|
+
|
|
791
|
+
const groups = new Map();
|
|
792
|
+
for (const r of rows) {
|
|
793
|
+
const id = normalizeMessageId(r.message_id);
|
|
794
|
+
if (!id) continue;
|
|
795
|
+
if (!groups.has(id)) groups.set(id, []);
|
|
796
|
+
groups.get(id).push(r);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const out = [];
|
|
800
|
+
for (const [message_id, copies] of groups) {
|
|
801
|
+
if (copies.length < 2) continue;
|
|
802
|
+
const byFolder = {};
|
|
803
|
+
for (const c of copies) byFolder[c.folder] = (byFolder[c.folder] || 0) + 1;
|
|
804
|
+
const sameFolder = Object.values(byFolder).some((n) => n > 1);
|
|
805
|
+
const crossFolder = Object.keys(byFolder).length > 1;
|
|
806
|
+
if (scope === 'same_folder' && !sameFolder) continue;
|
|
807
|
+
if (scope === 'cross_folder' && !crossFolder) continue;
|
|
808
|
+
const first = copies[0];
|
|
809
|
+
// Redundant copies only ever come from a folder holding the message more
|
|
810
|
+
// than once: within that folder keep the lowest uid and list the rest.
|
|
811
|
+
const redundant = [];
|
|
812
|
+
for (const [folder, n] of Object.entries(byFolder)) {
|
|
813
|
+
if (n < 2) continue;
|
|
814
|
+
const inFolder = copies.filter((c) => c.folder === folder).sort((a, b) => Number(a.uid) - Number(b.uid));
|
|
815
|
+
for (const c of inFolder.slice(1)) redundant.push({ folder, uid: Number(c.uid) });
|
|
816
|
+
}
|
|
817
|
+
out.push({
|
|
818
|
+
message_id,
|
|
819
|
+
subject: first.subject,
|
|
820
|
+
from: first.from_addr,
|
|
821
|
+
date: first.sent_date || (first.internal_date ? new Date(Number(first.internal_date)).toISOString() : null),
|
|
822
|
+
copies: copies.length,
|
|
823
|
+
same_folder_duplicates: sameFolder,
|
|
824
|
+
cross_folder: crossFolder,
|
|
825
|
+
by_folder: byFolder,
|
|
826
|
+
...(redundant.length && { redundant_copies: redundant }),
|
|
827
|
+
messages: copies.map((c) => ({
|
|
828
|
+
folder: c.folder,
|
|
829
|
+
uid: Number(c.uid),
|
|
830
|
+
date: c.sent_date || (c.internal_date ? new Date(Number(c.internal_date)).toISOString() : null)
|
|
831
|
+
}))
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
out.sort((a, b) => (b.copies - a.copies) || String(b.date || '').localeCompare(String(a.date || '')));
|
|
835
|
+
const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
|
|
836
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
837
|
+
const page = out.slice(skip, skip + cap);
|
|
838
|
+
return {
|
|
839
|
+
scope,
|
|
840
|
+
folders_searched: folderList.length ? folderList : 'all indexed folders',
|
|
841
|
+
coverage: {
|
|
842
|
+
messages_in_scope: inScope,
|
|
843
|
+
with_message_id: withId,
|
|
844
|
+
without_message_id: inScope - withId,
|
|
845
|
+
...(inScope > withId && { note: 'messages without a Message-ID header cannot be compared and are not included' })
|
|
846
|
+
},
|
|
847
|
+
total_groups: out.length,
|
|
848
|
+
returned: page.length,
|
|
849
|
+
offset: skip,
|
|
850
|
+
has_more: skip + page.length < out.length,
|
|
851
|
+
groups: page,
|
|
852
|
+
index: this._indexHealth(folderList)
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
614
856
|
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
857
|
const where = ['fts MATCH ?'];
|
|
616
858
|
const params = [ftsQuery(query, { match, prefix, field })];
|
|
@@ -638,37 +880,18 @@ export class LocalIndex {
|
|
|
638
880
|
|
|
639
881
|
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
882
|
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,
|
|
883
|
+
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
884
|
snippet(fts, -1, '[', ']', '...', 16) AS snippet
|
|
643
885
|
FROM fts JOIN messages m ON m.id = fts.rowid
|
|
644
886
|
WHERE ${whereSql}
|
|
645
887
|
ORDER BY ${order}
|
|
646
888
|
LIMIT ? OFFSET ?`).all(...params, cap, skip);
|
|
647
889
|
|
|
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
890
|
return {
|
|
662
891
|
total_matches: total,
|
|
663
892
|
returned: rows.length,
|
|
664
893
|
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
|
-
},
|
|
894
|
+
index: this._indexHealth(folderList),
|
|
672
895
|
results: rows.map((r) => ({
|
|
673
896
|
folder: r.folder,
|
|
674
897
|
uid: Number(r.uid),
|
|
@@ -676,6 +899,7 @@ export class LocalIndex {
|
|
|
676
899
|
from: r.from_addr,
|
|
677
900
|
to: r.to_addr,
|
|
678
901
|
subject: r.subject,
|
|
902
|
+
message_id: normalizeMessageId(r.message_id) || undefined,
|
|
679
903
|
attachments: r.attachments || undefined,
|
|
680
904
|
snippet: r.snippet
|
|
681
905
|
}))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengelhard/imap-email-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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",
|