@lengelhard/imap-email-mcp 1.9.2 → 1.10.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 +18 -0
- package/README.md +18 -6
- package/index.js +368 -331
- package/lib/imap-session.js +107 -0
- package/lib/local-index.js +25 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.10.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Replies stay in their thread.** `send_email`, `create_draft` and `update_draft` take `reply_to_uid` (with `reply_to_folder`), `in_reply_to` and `references`. Until now the connector had no way to set In-Reply-To or References, so every message it sent was a new conversation in the recipient's mail client and in Missive, even with the thread's subject line (seen 28 Sep 2026). With `reply_to_uid` the server reads the original's Message-ID, References and Subject and builds the headers as RFC 5322 §3.6.4 describes: In-Reply-To is the parent's Message-ID, References is the parent's References followed by its Message-ID. With `in_reply_to` alone, the chain is completed from the local index when it holds the parent, and is that id alone otherwise; the response says which. The subject is optional when replying and defaults to `Re: ` + the original subject; a caller-supplied subject without a reply prefix gets `Re: ` added, an existing `RE:`/`AW:` is kept. A stale `reply_to_uid` fails before anything is sent. Responses report `message_id`, `in_reply_to`, `references` and `threaded_from`, or `threaded: false`.
|
|
7
|
+
- `list_drafts` and `get_draft` return `message_id`, `in_reply_to` and (on `get_draft`) `references`, and `list_drafts` reports `total`.
|
|
8
|
+
- `findSentFolder`, `findDraftsFolder` and `findTrashFolder` all prefer the mailbox the server flags with SPECIAL-USE (`\Sent`, `\Drafts`, `\Trash`) and fall back to the usual names, so the copy of a sent message lands where the user's other clients look for it.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Outgoing messages are built by nodemailer's MailComposer**, in `send_email`, `create_draft` and `update_draft`. The hand-assembled RFC 5322 strings put non-ASCII subjects into headers as raw UTF-8, sent bodies with no Content-Transfer-Encoding, and let a value containing a newline inject a header. Headers are now RFC 2047 encoded and folded, the body is encoded, and Message-ID and Date are set. The copy saved to Sent is the message that went on the wire (same Message-ID, same bytes) plus the Bcc header, which is stripped from the wire copy; previously it was a second, differently structured message assembled by hand.
|
|
12
|
+
- `update_draft` appends the replacement draft first and erases the old one only once the new one is on the server. Previously the old draft was expunged before the new one was appended, so a failed append lost the draft. A `uid` that does not exist now changes nothing and says so.
|
|
13
|
+
- Every tool now runs its IMAP commands under the `IMAP_OP_TIMEOUT_SECONDS` limit introduced in 1.7.3. `list_drafts`, `get_draft`, `create_draft`, `update_draft`, `download_attachment`, `extract_text`, `delete_email`, `flag_email`, `mark_seen`, `move_thread`, `list_folders` and the special-folder lookups still called the underlying library directly, so on a silent connection they waited out the 5-minute lease instead of failing in two.
|
|
14
|
+
- `download_attachment`, `extract_text` and the draft readers open their folder read-only (EXAMINE), so reading never changes flags.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **`delete_email` with `expunge: true` and `update_draft` erased every message in the folder flagged `\Deleted`, not just the one asked for.** Both flagged the target and then closed the mailbox with expunge, which is a mailbox-wide operation: a message another client had flagged `\Deleted` but not yet expunged was erased too. They now use UID EXPUNGE (UIDPLUS), scoped to the one message. On a server without UIDPLUS the call fails and takes the flag back off rather than erasing mail nobody asked about. `move_email`/`move_thread` share the same helper, which also closes the 1.9.2 gap where a missing UIDPLUS would have thrown outside the promise.
|
|
18
|
+
- `flag_email`, `mark_seen` and `delete_email` reported `success: true` for a UID that does not exist, because IMAP STORE on a missing UID is a silent no-op. They now check the UID first and fail with the same "UIDs change when a message is moved" hint as `get_email`.
|
|
19
|
+
- `list_folders` joined nested folder names with `.` regardless of the server's hierarchy delimiter, so a nested folder on a `/`-delimited server (Dovecot, Migadu) came back under a name no other tool would accept. It now uses each mailbox's own delimiter.
|
|
20
|
+
|
|
3
21
|
## 1.9.2
|
|
4
22
|
|
|
5
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -74,6 +74,7 @@ Add to your config file:
|
|
|
74
74
|
- **Local full-text index** - Optional read-only SQLite index for reliable content search, independent of the server's body search
|
|
75
75
|
- **Compose** - Create and save email drafts
|
|
76
76
|
- **Send** - Send emails directly via SMTP
|
|
77
|
+
- **Reply in thread** - Replies carry In-Reply-To and References, so mail clients keep them in the conversation instead of starting a new one
|
|
77
78
|
- **Manage drafts** - List, read, update, and delete drafts
|
|
78
79
|
- **Find duplicates** - Spot messages filed twice in one folder, or the same message sitting in several folders, matched on Message-ID
|
|
79
80
|
- **Delete emails** - Move to Trash by default, with an explicit opt-in to erase permanently
|
|
@@ -140,6 +141,16 @@ A conversation can end up spread across folders without any message being duplic
|
|
|
140
141
|
- **Honest coverage.** Rows indexed before 1.9.0 have no threading headers until the background sync backfills them, which it does at the end of each scheduled run for up to `INDEX_BACKFILL_SECONDS`, smallest folders first, without ever delaying the indexing of new mail. `coverage.without_thread_headers` and `index_status.thread_headers_pending` show how many are still waiting, and a thread touching such rows is marked `threading: "partial"`.
|
|
141
142
|
- **Nothing is moved.** The tool only reports. `move_email` or `move_thread` are the tools for acting on it.
|
|
142
143
|
|
|
144
|
+
### Replying in a thread
|
|
145
|
+
|
|
146
|
+
Mail clients group messages into conversations by the In-Reply-To and References headers, not by subject. A message sent without them is a new conversation in every client, however familiar its subject looks. `send_email`, `create_draft` and `update_draft` therefore take:
|
|
147
|
+
|
|
148
|
+
- **`reply_to_uid` + `reply_to_folder`** — the message being answered. The server reads its Message-ID, References and Subject and writes `In-Reply-To` and `References` itself (the parent's References followed by the parent's Message-ID, as RFC 5322 requires). Always pass the folder: UIDs are folder-scoped.
|
|
149
|
+
- **`in_reply_to`** — the parent's Message-ID, as returned by `list_emails`, `search_emails` or `get_email`. When the local index holds that message the References chain is completed from it; otherwise References is that id alone, and the response says so.
|
|
150
|
+
- **`references`** — an explicit chain, rarely needed.
|
|
151
|
+
|
|
152
|
+
The subject is optional when replying and defaults to `Re: ` + the original subject. A caller-supplied subject with no `Re:`/`AW:`-style prefix gets `Re: ` added. The response reports `message_id`, `in_reply_to`, `references` and `threaded_from`, or `threaded: false` when no reply target was given. Messages are composed with nodemailer's MailComposer: non-ASCII headers are RFC 2047 encoded, bodies carry a Content-Transfer-Encoding, and the copy saved to Sent is the message that went out (plus the Bcc header, which is stripped on the wire).
|
|
153
|
+
|
|
143
154
|
### Provider Settings
|
|
144
155
|
|
|
145
156
|
| Provider | IMAP_HOST | SMTP_HOST | Notes |
|
|
@@ -165,12 +176,12 @@ A conversation can end up spread across folders without any message being duplic
|
|
|
165
176
|
| `find_split_threads` | Conversations spread across folders, threaded on In-Reply-To and References so renamed replies stay with their thread |
|
|
166
177
|
| `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
178
|
| `move_thread` | Move every message of a thread by subject in one call, with the same duplicate-safe behaviour |
|
|
168
|
-
| `list_drafts` | List
|
|
169
|
-
| `get_draft` | Get a specific draft by UID |
|
|
170
|
-
| `create_draft` | Create a
|
|
171
|
-
| `update_draft` |
|
|
172
|
-
| `send_email` | Send
|
|
173
|
-
| `delete_email` | Delete an email by UID. Moves to Trash unless `expunge: true
|
|
179
|
+
| `list_drafts` | List drafts newest first, with `message_id` and `in_reply_to` |
|
|
180
|
+
| `get_draft` | Get a specific draft by UID, with its threading headers |
|
|
181
|
+
| `create_draft` | Create a draft; `reply_to_uid`/`in_reply_to` make it a threaded reply |
|
|
182
|
+
| `update_draft` | Replace a draft: the new one is appended before the old one is erased |
|
|
183
|
+
| `send_email` | Send via SMTP and save to Sent; `reply_to_uid`/`in_reply_to` keep a reply in its thread |
|
|
184
|
+
| `delete_email` | Delete an email by UID. Moves to Trash unless `expunge: true`, which erases that one message (UID EXPUNGE) |
|
|
174
185
|
| `download_attachment` | Download an attachment: ≤700KB returns inline base64, larger saves to disk (ATTACHMENT_DIR) |
|
|
175
186
|
| `extract_text` | Extract plain text from a PDF, xlsx/xlsm/xls, docx, or text attachment in one call, parsed server-side |
|
|
176
187
|
|
|
@@ -181,6 +192,7 @@ Once configured, use natural language:
|
|
|
181
192
|
- "Check my inbox for unread emails"
|
|
182
193
|
- "Search for emails from john@example.com"
|
|
183
194
|
- "Create a draft email to sarah@example.com about the meeting tomorrow"
|
|
195
|
+
- "Reply to the latest message from Alicia in Action and confirm the figures" (the reply stays in her thread)
|
|
184
196
|
- "Show me my drafts folder"
|
|
185
197
|
|
|
186
198
|
## Security Best Practices
|
package/index.js
CHANGED
|
@@ -21,15 +21,24 @@ import fs from 'fs';
|
|
|
21
21
|
import path from 'path';
|
|
22
22
|
import os from 'os';
|
|
23
23
|
import { createRequire } from 'module';
|
|
24
|
+
import MailComposer from 'nodemailer/lib/mail-composer/index.js';
|
|
25
|
+
import { randomUUID } from 'crypto';
|
|
24
26
|
import {
|
|
25
27
|
setOpTimeout,
|
|
26
|
-
withTimeout,
|
|
27
28
|
SessionManager,
|
|
29
|
+
listBoxes,
|
|
28
30
|
openBox,
|
|
29
31
|
queryUids,
|
|
30
32
|
fetchMessages,
|
|
31
33
|
headerOf,
|
|
32
|
-
buildCriteria
|
|
34
|
+
buildCriteria,
|
|
35
|
+
storeFlags,
|
|
36
|
+
expungeUids,
|
|
37
|
+
moveUids,
|
|
38
|
+
appendMessage,
|
|
39
|
+
uidExists,
|
|
40
|
+
flattenBoxTree,
|
|
41
|
+
findSpecialBox
|
|
33
42
|
} from './lib/imap-session.js';
|
|
34
43
|
import { htmlToText, stripDataUris, leafParts } from './lib/mime-text.js';
|
|
35
44
|
import { LocalIndex, normalizeMessageId, parseMessageIdList } from './lib/local-index.js';
|
|
@@ -136,16 +145,6 @@ const SMTP_CONFIG = {
|
|
|
136
145
|
// and the From header is the bare address.
|
|
137
146
|
const SENDER_NAME = process.env.SENDER_NAME || '';
|
|
138
147
|
|
|
139
|
-
// Build an RFC 5322 From value. ASCII names are quoted; anything else is
|
|
140
|
-
// encoded as an RFC 2047 encoded-word so accents survive transport.
|
|
141
|
-
function formatFrom(address) {
|
|
142
|
-
if (!SENDER_NAME) return address;
|
|
143
|
-
const name = /^[\x20-\x7E]*$/.test(SENDER_NAME)
|
|
144
|
-
? `"${SENDER_NAME.replace(/["\\]/g, '\\$&')}"`
|
|
145
|
-
: `=?UTF-8?B?${Buffer.from(SENDER_NAME, 'utf8').toString('base64')}?=`;
|
|
146
|
-
return `${name} <${address}>`;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
148
|
// Traverse the raw IMAP MIME struct to find all attachment parts.
|
|
150
149
|
//
|
|
151
150
|
// Detection rules (in priority order):
|
|
@@ -217,6 +216,27 @@ function validateConfig() {
|
|
|
217
216
|
}
|
|
218
217
|
}
|
|
219
218
|
|
|
219
|
+
// Parameters shared by send_email, create_draft and update_draft that make the
|
|
220
|
+
// outgoing message part of an existing conversation.
|
|
221
|
+
const THREADING_PROPERTIES = {
|
|
222
|
+
reply_to_uid: {
|
|
223
|
+
type: 'number',
|
|
224
|
+
description: 'UID of the message being answered. The server reads its Message-ID, References and Subject and builds In-Reply-To and References itself. Always pass reply_to_folder with it.'
|
|
225
|
+
},
|
|
226
|
+
reply_to_folder: {
|
|
227
|
+
type: 'string',
|
|
228
|
+
description: 'Folder holding reply_to_uid (default: INBOX). UIDs are folder-scoped, so pass the folder the message was listed from.'
|
|
229
|
+
},
|
|
230
|
+
in_reply_to: {
|
|
231
|
+
type: 'string',
|
|
232
|
+
description: 'Message-ID of the message being answered (as returned by list_emails, search_emails or get_email; angle brackets optional). Used when reply_to_uid is not given. References is completed from the local index when it holds that message, else it is this id alone.'
|
|
233
|
+
},
|
|
234
|
+
references: {
|
|
235
|
+
type: 'string',
|
|
236
|
+
description: 'Explicit References chain, space-separated Message-IDs oldest first. Overrides what the server would build. Rarely needed.'
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
220
240
|
const server = new Server(
|
|
221
241
|
{
|
|
222
242
|
name: 'imap-email-mcp',
|
|
@@ -596,7 +616,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
596
616
|
},
|
|
597
617
|
{
|
|
598
618
|
name: 'create_draft',
|
|
599
|
-
description: 'Create a new draft email',
|
|
619
|
+
description: 'Create a new draft email. To draft a reply that stays in its thread, pass reply_to_uid (with reply_to_folder) or in_reply_to; see send_email for how threading is resolved.',
|
|
600
620
|
inputSchema: {
|
|
601
621
|
type: 'object',
|
|
602
622
|
properties: {
|
|
@@ -606,7 +626,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
606
626
|
},
|
|
607
627
|
subject: {
|
|
608
628
|
type: 'string',
|
|
609
|
-
description: 'Email subject'
|
|
629
|
+
description: 'Email subject. Optional when replying (reply_to_uid or in_reply_to): defaults to "Re: " + the original subject.'
|
|
610
630
|
},
|
|
611
631
|
body: {
|
|
612
632
|
type: 'string',
|
|
@@ -623,14 +643,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
623
643
|
bcc: {
|
|
624
644
|
type: 'string',
|
|
625
645
|
description: 'BCC recipients, comma-separated'
|
|
626
|
-
}
|
|
646
|
+
},
|
|
647
|
+
...THREADING_PROPERTIES
|
|
627
648
|
},
|
|
628
|
-
required: ['to'
|
|
649
|
+
required: ['to']
|
|
629
650
|
}
|
|
630
651
|
},
|
|
631
652
|
{
|
|
632
653
|
name: 'update_draft',
|
|
633
|
-
description: '
|
|
654
|
+
description: 'Replace an existing draft: the new draft is appended first, then the old one is erased (UID EXPUNGE), so a failure never loses the draft. Accepts the same threading parameters as send_email.',
|
|
634
655
|
inputSchema: {
|
|
635
656
|
type: 'object',
|
|
636
657
|
properties: {
|
|
@@ -644,7 +665,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
644
665
|
},
|
|
645
666
|
subject: {
|
|
646
667
|
type: 'string',
|
|
647
|
-
description: 'Email subject'
|
|
668
|
+
description: 'Email subject. Optional when replying (reply_to_uid or in_reply_to): defaults to "Re: " + the original subject.'
|
|
648
669
|
},
|
|
649
670
|
body: {
|
|
650
671
|
type: 'string',
|
|
@@ -661,14 +682,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
661
682
|
bcc: {
|
|
662
683
|
type: 'string',
|
|
663
684
|
description: 'BCC recipients'
|
|
664
|
-
}
|
|
685
|
+
},
|
|
686
|
+
...THREADING_PROPERTIES
|
|
665
687
|
},
|
|
666
|
-
required: ['uid', 'to'
|
|
688
|
+
required: ['uid', 'to']
|
|
667
689
|
}
|
|
668
690
|
},
|
|
669
691
|
{
|
|
670
692
|
name: 'send_email',
|
|
671
|
-
description: 'Send an email
|
|
693
|
+
description: 'Send an email via SMTP and save a copy to Sent. A reply must carry In-Reply-To and References or every mail client shows it as a new conversation: pass reply_to_uid (plus reply_to_folder) and the server fetches the original\'s Message-ID and References and builds the headers, or pass in_reply_to (a Message-ID from list_emails/get_email) and the chain is completed from the local index when available. The subject defaults to "Re: " + the original subject when replying, and a caller-supplied subject gets "Re: " prefixed if it lacks one. The response reports message_id, in_reply_to and references as sent.',
|
|
672
694
|
inputSchema: {
|
|
673
695
|
type: 'object',
|
|
674
696
|
properties: {
|
|
@@ -678,7 +700,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
678
700
|
},
|
|
679
701
|
subject: {
|
|
680
702
|
type: 'string',
|
|
681
|
-
description: 'Email subject'
|
|
703
|
+
description: 'Email subject. Optional when replying (reply_to_uid or in_reply_to): defaults to "Re: " + the original subject.'
|
|
682
704
|
},
|
|
683
705
|
body: {
|
|
684
706
|
type: 'string',
|
|
@@ -695,9 +717,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
695
717
|
bcc: {
|
|
696
718
|
type: 'string',
|
|
697
719
|
description: 'BCC recipients'
|
|
698
|
-
}
|
|
720
|
+
},
|
|
721
|
+
...THREADING_PROPERTIES
|
|
699
722
|
},
|
|
700
|
-
required: ['to'
|
|
723
|
+
required: ['to']
|
|
701
724
|
}
|
|
702
725
|
},
|
|
703
726
|
{
|
|
@@ -892,6 +915,132 @@ function normalizeSubject(subject) {
|
|
|
892
915
|
return subject.replace(/^(\s*(Re|Fwd|Fw)\s*:\s*)+/i, '').trim();
|
|
893
916
|
}
|
|
894
917
|
|
|
918
|
+
// Does the subject already read as a reply? Covers the usual localised prefixes.
|
|
919
|
+
const REPLY_PREFIX = /^\s*(re|aw|sv|antw|vs|r)\s*(\[\d+\])?\s*:/i;
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Work out In-Reply-To, References and the subject for an outgoing message.
|
|
923
|
+
*
|
|
924
|
+
* Without these headers every mail client files the message as a brand-new
|
|
925
|
+
* conversation, however familiar the subject looks. RFC 5322 §3.6.4: a reply
|
|
926
|
+
* carries In-Reply-To = the parent's Message-ID and References = the parent's
|
|
927
|
+
* References followed by the parent's Message-ID (or its In-Reply-To when it
|
|
928
|
+
* has no References).
|
|
929
|
+
*
|
|
930
|
+
* `connection` may be null when only in_reply_to / references were given.
|
|
931
|
+
* Returns { inReplyTo, references, subject, source } with ids bracket-free
|
|
932
|
+
* (MailComposer adds the brackets), or subject alone when not a reply.
|
|
933
|
+
*/
|
|
934
|
+
async function resolveThreading(connection, args) {
|
|
935
|
+
let parent = null;
|
|
936
|
+
let source = null;
|
|
937
|
+
|
|
938
|
+
if (args.reply_to_uid != null) {
|
|
939
|
+
const folder = args.reply_to_folder || 'INBOX';
|
|
940
|
+
const uid = Number(args.reply_to_uid);
|
|
941
|
+
await openBox(connection, folder, true);
|
|
942
|
+
const fetched = await fetchMessages(connection, [uid], { bodies: ['HEADER.FIELDS (MESSAGE-ID IN-REPLY-TO REFERENCES SUBJECT)'] });
|
|
943
|
+
const rec = fetched.get(uid);
|
|
944
|
+
if (!rec) {
|
|
945
|
+
throw new Error(`reply_to_uid ${uid} not found in ${folder}. UIDs change when a message is moved; list or search again to get the current uid, and pass reply_to_folder.`);
|
|
946
|
+
}
|
|
947
|
+
const h = headerOf(rec, connection.imap.constructor.parseHeader);
|
|
948
|
+
parent = {
|
|
949
|
+
message_id: normalizeMessageId(h['message-id']?.[0]),
|
|
950
|
+
in_reply_to: parseMessageIdList(h['in-reply-to'])[0] || null,
|
|
951
|
+
references: parseMessageIdList(h.references),
|
|
952
|
+
subject: h.subject?.[0] || ''
|
|
953
|
+
};
|
|
954
|
+
if (!parent.message_id) {
|
|
955
|
+
throw new Error(`Message uid ${uid} in ${folder} has no Message-ID header, so a reply cannot be threaded to it. Send without reply_to_uid if a new conversation is acceptable.`);
|
|
956
|
+
}
|
|
957
|
+
source = `reply_to_uid ${uid} in ${folder}`;
|
|
958
|
+
} else if (args.in_reply_to) {
|
|
959
|
+
const id = normalizeMessageId(args.in_reply_to);
|
|
960
|
+
if (!id) throw new Error('in_reply_to is empty');
|
|
961
|
+
const indexed = localIndex ? localIndex.lookupMessageId(id) : null;
|
|
962
|
+
parent = indexed
|
|
963
|
+
? { message_id: id, in_reply_to: indexed.in_reply_to, references: indexed.references, subject: indexed.subject }
|
|
964
|
+
: { message_id: id, in_reply_to: null, references: [], subject: '' };
|
|
965
|
+
source = indexed ? `in_reply_to (chain completed from the local index)` : 'in_reply_to (parent not in the local index; References is that id alone)';
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (!parent) {
|
|
969
|
+
if (args.references) {
|
|
970
|
+
// References without a parent: honour it as given, In-Reply-To is the last id.
|
|
971
|
+
const refs = parseMessageIdList(args.references);
|
|
972
|
+
if (refs.length) {
|
|
973
|
+
return { inReplyTo: refs[refs.length - 1], references: refs, subject: args.subject, source: 'references' };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return { subject: args.subject };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
let references;
|
|
980
|
+
if (args.references) {
|
|
981
|
+
references = parseMessageIdList(args.references);
|
|
982
|
+
} else {
|
|
983
|
+
references = parent.references.length ? [...parent.references] : (parent.in_reply_to ? [parent.in_reply_to] : []);
|
|
984
|
+
if (!references.includes(parent.message_id)) references.push(parent.message_id);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
let subject = args.subject;
|
|
988
|
+
if (subject == null || String(subject).trim() === '') {
|
|
989
|
+
const base = normalizeSubject(parent.subject);
|
|
990
|
+
if (!base) throw new Error('subject is required: the original message has no subject to derive "Re: ..." from');
|
|
991
|
+
subject = `Re: ${base}`;
|
|
992
|
+
} else if (!REPLY_PREFIX.test(subject)) {
|
|
993
|
+
subject = `Re: ${subject}`;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return { inReplyTo: parent.message_id, references, subject, source };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Build an RFC 5322 message with nodemailer's MailComposer: headers are
|
|
1001
|
+
* encoded (RFC 2047 for non-ASCII, folded, no injection through a value with
|
|
1002
|
+
* a newline), the body gets a proper Content-Transfer-Encoding, and a
|
|
1003
|
+
* Message-ID and Date are set. `keepBcc` keeps the Bcc header in the output,
|
|
1004
|
+
* which is right for a draft or a Sent copy and wrong for the wire.
|
|
1005
|
+
* Resolves to { raw, envelope, messageId }.
|
|
1006
|
+
*/
|
|
1007
|
+
async function composeMessage(fields, threading, { keepBcc = false, messageId = null, date = null } = {}) {
|
|
1008
|
+
const opts = {
|
|
1009
|
+
from: SENDER_NAME ? { name: SENDER_NAME, address: fields.from } : fields.from,
|
|
1010
|
+
to: fields.to,
|
|
1011
|
+
subject: threading.subject,
|
|
1012
|
+
text: fields.body ?? undefined,
|
|
1013
|
+
html: fields.html ?? undefined,
|
|
1014
|
+
cc: fields.cc || undefined,
|
|
1015
|
+
bcc: fields.bcc || undefined,
|
|
1016
|
+
date: date || new Date(),
|
|
1017
|
+
messageId: messageId || undefined
|
|
1018
|
+
};
|
|
1019
|
+
if (threading.inReplyTo) opts.inReplyTo = `<${threading.inReplyTo}>`;
|
|
1020
|
+
if (threading.references && threading.references.length) opts.references = threading.references.map((r) => `<${r}>`);
|
|
1021
|
+
if (!opts.text && !opts.html) opts.text = '';
|
|
1022
|
+
const compiled = new MailComposer(opts).compile();
|
|
1023
|
+
// keepBcc is a MimeNode option, not a mail option: set it on the built root.
|
|
1024
|
+
compiled.keepBcc = !!keepBcc;
|
|
1025
|
+
const envelope = compiled.getEnvelope();
|
|
1026
|
+
const id = compiled.messageId();
|
|
1027
|
+
const raw = await compiled.build();
|
|
1028
|
+
return { raw, envelope, messageId: id };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Fresh Message-ID for a message that is composed twice (wire copy without
|
|
1032
|
+
// Bcc, Sent copy with it) so both carry the same id.
|
|
1033
|
+
function newMessageId(address) {
|
|
1034
|
+
const domain = String(address || '').split('@')[1] || os.hostname();
|
|
1035
|
+
return `<${randomUUID()}@${domain}>`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function describeThreading(threading) {
|
|
1039
|
+
return threading.inReplyTo
|
|
1040
|
+
? { in_reply_to: threading.inReplyTo, references: threading.references, threaded_from: threading.source }
|
|
1041
|
+
: { threaded: false, note: 'No reply_to_uid or in_reply_to given, so this message starts a new conversation.' };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
895
1044
|
setOpTimeout(parseFloat(process.env.IMAP_OP_TIMEOUT_SECONDS || '120') * 1000);
|
|
896
1045
|
|
|
897
1046
|
// Shared IMAP session. connectIMAP() returns a lease on one persistent
|
|
@@ -949,17 +1098,10 @@ async function moveWithoutDuplicates(connection, fromFolder, uids, toFolder) {
|
|
|
949
1098
|
for (const uid of uids) {
|
|
950
1099
|
try {
|
|
951
1100
|
if (present.has(uid)) {
|
|
952
|
-
await
|
|
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
|
-
}));
|
|
1101
|
+
await expungeUids(connection, [uid]);
|
|
958
1102
|
results.push({ uid, action: 'already_present', existing_uid: present.get(uid), message_id: idsByUid.get(uid) });
|
|
959
1103
|
} else {
|
|
960
|
-
await
|
|
961
|
-
connection.imap.move(uid, toFolder, (err) => (err ? reject(err) : resolve()));
|
|
962
|
-
}));
|
|
1104
|
+
await moveUids(connection, [uid], toFolder);
|
|
963
1105
|
results.push({ uid, action: 'moved' });
|
|
964
1106
|
}
|
|
965
1107
|
} catch (err) {
|
|
@@ -1014,62 +1156,25 @@ function dateValue(item) {
|
|
|
1014
1156
|
}
|
|
1015
1157
|
|
|
1016
1158
|
/**
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
* rather than guessing and writing mail somewhere unexpected.
|
|
1159
|
+
* Special mailboxes. Each prefers the folder the server itself flags
|
|
1160
|
+
* (\Trash, \Drafts, \Sent) when it advertises SPECIAL-USE, since that is what
|
|
1161
|
+
* the user's other mail clients use, and falls back to the usual names.
|
|
1162
|
+
* findTrashFolder returns null when nothing plausible exists, which the
|
|
1163
|
+
* caller reports rather than guessing and writing mail somewhere unexpected.
|
|
1023
1164
|
*/
|
|
1024
1165
|
async function findTrashFolder(connection) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
for (const [name, box] of Object.entries(node || {})) {
|
|
1028
|
-
const full = prefix ? `${prefix}${box.delimiter || '/'}${name}` : name;
|
|
1029
|
-
const attribs = (box.attribs || []).map((a) => String(a).toLowerCase());
|
|
1030
|
-
if (box.special_use_attrib) attribs.push(String(box.special_use_attrib).toLowerCase());
|
|
1031
|
-
out.push({ name: full, attribs, leaf: name });
|
|
1032
|
-
if (box.children) walk(box.children, full, out);
|
|
1033
|
-
}
|
|
1034
|
-
return out;
|
|
1035
|
-
};
|
|
1036
|
-
const all = walk(boxes);
|
|
1037
|
-
const selectable = all.filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
|
|
1038
|
-
|
|
1039
|
-
const flagged = selectable.find((b) => b.attribs.includes('\\trash'));
|
|
1040
|
-
if (flagged) return flagged.name;
|
|
1041
|
-
|
|
1042
|
-
const known = ['trash', 'deleted items', 'deleted messages', 'bin', 'papierkorb', 'corbeille'];
|
|
1043
|
-
const byName = selectable.find((b) => known.includes(b.leaf.toLowerCase()));
|
|
1044
|
-
return byName ? byName.name : null;
|
|
1166
|
+
return findSpecialBox(await listBoxes(connection), '\\trash',
|
|
1167
|
+
['Trash', 'Deleted Items', 'Deleted Messages', 'Bin', 'Papierkorb', 'Corbeille']);
|
|
1045
1168
|
}
|
|
1046
1169
|
|
|
1047
|
-
// Helper to find drafts folder (handles different provider conventions)
|
|
1048
1170
|
async function findDraftsFolder(connection) {
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
const draftNames = [
|
|
1053
|
-
'Drafts',
|
|
1054
|
-
'INBOX.Drafts',
|
|
1055
|
-
'[Gmail]/Drafts',
|
|
1056
|
-
'[Google Mail]/Drafts',
|
|
1057
|
-
'Draft',
|
|
1058
|
-
'INBOX/Drafts'
|
|
1059
|
-
];
|
|
1060
|
-
|
|
1061
|
-
for (const name of draftNames) {
|
|
1062
|
-
if (boxes[name] || name.split('.').reduce((acc, part) => acc?.[part], boxes)) {
|
|
1063
|
-
return name;
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// Check for nested Drafts under INBOX
|
|
1068
|
-
if (boxes.INBOX && boxes.INBOX.children && boxes.INBOX.children.Drafts) {
|
|
1069
|
-
return 'INBOX.Drafts';
|
|
1070
|
-
}
|
|
1171
|
+
return findSpecialBox(await listBoxes(connection), '\\drafts',
|
|
1172
|
+
['Drafts', 'INBOX.Drafts', 'INBOX/Drafts', '[Gmail]/Drafts', '[Google Mail]/Drafts', 'Draft']) || 'Drafts';
|
|
1173
|
+
}
|
|
1071
1174
|
|
|
1072
|
-
|
|
1175
|
+
async function findSentFolder(connection) {
|
|
1176
|
+
return findSpecialBox(await listBoxes(connection), '\\sent',
|
|
1177
|
+
['Sent', 'INBOX.Sent', 'INBOX/Sent', 'Sent Items', 'Sent Mail', 'Sent Messages', '[Gmail]/Sent Mail']) || 'Sent';
|
|
1073
1178
|
}
|
|
1074
1179
|
|
|
1075
1180
|
// Tool handlers
|
|
@@ -1095,20 +1200,9 @@ async function handleTool(name, args) {
|
|
|
1095
1200
|
case 'list_folders': {
|
|
1096
1201
|
const connection = await connectIMAP();
|
|
1097
1202
|
try {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
function extractFolders(obj, prefix = '') {
|
|
1102
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
1103
|
-
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
1104
|
-
folders.push(fullPath);
|
|
1105
|
-
if (value.children) {
|
|
1106
|
-
extractFolders(value.children, fullPath);
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
extractFolders(boxes);
|
|
1203
|
+
// Names are joined with each box's own delimiter, so a nested folder
|
|
1204
|
+
// comes back exactly as the other tools expect it.
|
|
1205
|
+
const folders = flattenBoxTree(await listBoxes(connection)).map((b) => b.name);
|
|
1112
1206
|
return { content: [{ type: 'text', text: JSON.stringify(folders, null, 2) }] };
|
|
1113
1207
|
} finally {
|
|
1114
1208
|
connection.end();
|
|
@@ -1268,30 +1362,20 @@ async function handleTool(name, args) {
|
|
|
1268
1362
|
}
|
|
1269
1363
|
|
|
1270
1364
|
case 'list_drafts': {
|
|
1271
|
-
const limit = args.limit
|
|
1365
|
+
const limit = clampLimit(args.limit);
|
|
1272
1366
|
const connection = await connectIMAP();
|
|
1273
1367
|
|
|
1274
1368
|
try {
|
|
1275
1369
|
const draftsFolder = await findDraftsFolder(connection);
|
|
1276
|
-
await
|
|
1277
|
-
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
const header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
|
|
1286
|
-
return {
|
|
1287
|
-
uid: msg.attributes.uid,
|
|
1288
|
-
date: header.date?.[0],
|
|
1289
|
-
to: header.to?.[0],
|
|
1290
|
-
subject: header.subject?.[0]
|
|
1291
|
-
};
|
|
1292
|
-
});
|
|
1293
|
-
|
|
1294
|
-
return { content: [{ type: 'text', text: JSON.stringify({ folder: draftsFolder, drafts: results }, null, 2) }] };
|
|
1370
|
+
await openBox(connection, draftsFolder, true);
|
|
1371
|
+
const uids = await queryUids(connection, ['ALL'], { order: 'arrival' });
|
|
1372
|
+
const results = await summarize(connection, draftsFolder, uids.slice(0, limit), { withFlags: false });
|
|
1373
|
+
const drafts = results.map(({ uid, date, to, subject, message_id, in_reply_to }) => ({
|
|
1374
|
+
uid, date, to, subject,
|
|
1375
|
+
...(message_id && { message_id }),
|
|
1376
|
+
...(in_reply_to && { in_reply_to })
|
|
1377
|
+
}));
|
|
1378
|
+
return { content: [{ type: 'text', text: JSON.stringify({ folder: draftsFolder, total: uids.length, drafts }, null, 2) }] };
|
|
1295
1379
|
} finally {
|
|
1296
1380
|
connection.end();
|
|
1297
1381
|
}
|
|
@@ -1302,33 +1386,31 @@ async function handleTool(name, args) {
|
|
|
1302
1386
|
|
|
1303
1387
|
try {
|
|
1304
1388
|
const draftsFolder = await findDraftsFolder(connection);
|
|
1305
|
-
await
|
|
1306
|
-
|
|
1307
|
-
const fetchOptions = {
|
|
1308
|
-
bodies: [''],
|
|
1309
|
-
struct: true
|
|
1310
|
-
};
|
|
1389
|
+
await openBox(connection, draftsFolder, true);
|
|
1311
1390
|
|
|
1312
|
-
const
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1391
|
+
const uid = Number(args.uid);
|
|
1392
|
+
const fetched = await fetchMessages(connection, [uid], { bodies: [''] });
|
|
1393
|
+
const rec = fetched.get(uid);
|
|
1394
|
+
if (!rec) {
|
|
1395
|
+
return { content: [{ type: 'text', text: `Draft not found: uid ${uid} in ${draftsFolder}` }], isError: true };
|
|
1316
1396
|
}
|
|
1317
1397
|
|
|
1318
|
-
const
|
|
1319
|
-
const rawBody = msg.parts.find(p => p.which === '')?.body;
|
|
1320
|
-
const parsed = await simpleParser(rawBody);
|
|
1398
|
+
const parsed = await simpleParser(rec.parts[''] ?? rec.parts[Object.keys(rec.parts)[0]]);
|
|
1321
1399
|
|
|
1322
1400
|
return {
|
|
1323
1401
|
content: [{
|
|
1324
1402
|
type: 'text',
|
|
1325
1403
|
text: JSON.stringify({
|
|
1326
|
-
uid
|
|
1404
|
+
uid,
|
|
1405
|
+
folder: draftsFolder,
|
|
1327
1406
|
to: parsed.to?.text,
|
|
1328
1407
|
cc: parsed.cc?.text,
|
|
1329
1408
|
bcc: parsed.bcc?.text,
|
|
1330
1409
|
subject: parsed.subject,
|
|
1331
1410
|
date: parsed.date,
|
|
1411
|
+
...(normalizeMessageId(parsed.messageId) && { message_id: normalizeMessageId(parsed.messageId) }),
|
|
1412
|
+
...(parseMessageIdList(parsed.inReplyTo)[0] && { in_reply_to: parseMessageIdList(parsed.inReplyTo)[0] }),
|
|
1413
|
+
...(parseMessageIdList(parsed.references).length && { references: parseMessageIdList(parsed.references) }),
|
|
1332
1414
|
text: parsed.text,
|
|
1333
1415
|
html: parsed.html
|
|
1334
1416
|
}, null, 2)
|
|
@@ -1344,35 +1426,26 @@ async function handleTool(name, args) {
|
|
|
1344
1426
|
|
|
1345
1427
|
try {
|
|
1346
1428
|
const draftsFolder = await findDraftsFolder(connection);
|
|
1429
|
+
const threading = await resolveThreading(connection, args);
|
|
1430
|
+
if (!threading.subject) throw new Error('subject is required unless replying (reply_to_uid or in_reply_to)');
|
|
1431
|
+
const { raw, messageId } = await composeMessage(
|
|
1432
|
+
{ from: IMAP_CONFIG.imap.user, to: args.to, cc: args.cc, bcc: args.bcc, body: args.body, html: args.html },
|
|
1433
|
+
threading, { keepBcc: true }
|
|
1434
|
+
);
|
|
1435
|
+
await appendMessage(connection, raw, draftsFolder, ['\\Draft']);
|
|
1347
1436
|
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
message += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
|
|
1361
|
-
message += `--${boundary}\r\n`;
|
|
1362
|
-
message += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
|
1363
|
-
message += `${args.body || ''}\r\n`;
|
|
1364
|
-
message += `--${boundary}\r\n`;
|
|
1365
|
-
message += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
|
|
1366
|
-
message += `${args.html}\r\n`;
|
|
1367
|
-
message += `--${boundary}--\r\n`;
|
|
1368
|
-
} else {
|
|
1369
|
-
message += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
|
1370
|
-
message += `${args.body || ''}\r\n`;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
await connection.append(message, { mailbox: draftsFolder, flags: ['\\Draft'] });
|
|
1374
|
-
|
|
1375
|
-
return { content: [{ type: 'text', text: `Draft created successfully in ${draftsFolder}` }] };
|
|
1437
|
+
return {
|
|
1438
|
+
content: [{
|
|
1439
|
+
type: 'text',
|
|
1440
|
+
text: JSON.stringify({
|
|
1441
|
+
success: true,
|
|
1442
|
+
folder: draftsFolder,
|
|
1443
|
+
subject: threading.subject,
|
|
1444
|
+
message_id: normalizeMessageId(messageId),
|
|
1445
|
+
...describeThreading(threading)
|
|
1446
|
+
}, null, 2)
|
|
1447
|
+
}]
|
|
1448
|
+
};
|
|
1376
1449
|
} finally {
|
|
1377
1450
|
connection.end();
|
|
1378
1451
|
}
|
|
@@ -1383,42 +1456,41 @@ async function handleTool(name, args) {
|
|
|
1383
1456
|
|
|
1384
1457
|
try {
|
|
1385
1458
|
const draftsFolder = await findDraftsFolder(connection);
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
//
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
await
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
if (
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
if (args.html) {
|
|
1406
|
-
message += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
|
|
1407
|
-
message += `--${boundary}\r\n`;
|
|
1408
|
-
message += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
|
1409
|
-
message += `${args.body || ''}\r\n`;
|
|
1410
|
-
message += `--${boundary}\r\n`;
|
|
1411
|
-
message += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
|
|
1412
|
-
message += `${args.html}\r\n`;
|
|
1413
|
-
message += `--${boundary}--\r\n`;
|
|
1414
|
-
} else {
|
|
1415
|
-
message += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
|
1416
|
-
message += `${args.body || ''}\r\n`;
|
|
1459
|
+
const oldUid = Number(args.uid);
|
|
1460
|
+
|
|
1461
|
+
// Build and append the replacement first. Only once it is safely in
|
|
1462
|
+
// the folder is the old draft erased, and only that one uid, so a
|
|
1463
|
+
// failure part-way never loses the draft or touches other mail.
|
|
1464
|
+
const threading = await resolveThreading(connection, args);
|
|
1465
|
+
if (!threading.subject) throw new Error('subject is required unless replying (reply_to_uid or in_reply_to)');
|
|
1466
|
+
const { raw, messageId } = await composeMessage(
|
|
1467
|
+
{ from: IMAP_CONFIG.imap.user, to: args.to, cc: args.cc, bcc: args.bcc, body: args.body, html: args.html },
|
|
1468
|
+
threading, { keepBcc: true }
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
await openBox(connection, draftsFolder, false);
|
|
1472
|
+
if (!(await uidExists(connection, oldUid))) {
|
|
1473
|
+
return {
|
|
1474
|
+
content: [{ type: 'text', text: `Draft not found: uid ${oldUid} in ${draftsFolder}. Nothing was changed; use list_drafts to get the current uid.` }],
|
|
1475
|
+
isError: true
|
|
1476
|
+
};
|
|
1417
1477
|
}
|
|
1478
|
+
await appendMessage(connection, raw, draftsFolder, ['\\Draft']);
|
|
1479
|
+
await expungeUids(connection, [oldUid]);
|
|
1418
1480
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1481
|
+
return {
|
|
1482
|
+
content: [{
|
|
1483
|
+
type: 'text',
|
|
1484
|
+
text: JSON.stringify({
|
|
1485
|
+
success: true,
|
|
1486
|
+
folder: draftsFolder,
|
|
1487
|
+
replaced_uid: oldUid,
|
|
1488
|
+
subject: threading.subject,
|
|
1489
|
+
message_id: normalizeMessageId(messageId),
|
|
1490
|
+
...describeThreading(threading)
|
|
1491
|
+
}, null, 2)
|
|
1492
|
+
}]
|
|
1493
|
+
};
|
|
1422
1494
|
} finally {
|
|
1423
1495
|
connection.end();
|
|
1424
1496
|
}
|
|
@@ -1432,73 +1504,40 @@ async function handleTool(name, args) {
|
|
|
1432
1504
|
};
|
|
1433
1505
|
}
|
|
1434
1506
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
cc: args.cc,
|
|
1446
|
-
bcc: args.bcc
|
|
1447
|
-
};
|
|
1448
|
-
|
|
1449
|
-
// Send via SMTP and capture the raw RFC 5322 message
|
|
1450
|
-
const info = await transporter.sendMail(mailOptions);
|
|
1451
|
-
|
|
1452
|
-
// Build RFC 5322 message string to APPEND to Sent folder via IMAP
|
|
1453
|
-
// Use the same content that was sent so the Sent copy is identical
|
|
1454
|
-
const sentDate = new Date();
|
|
1455
|
-
const boundary = `----=_Part_${Date.now()}`;
|
|
1456
|
-
let rawMessage = '';
|
|
1457
|
-
rawMessage += `From: ${formatFrom(SMTP_CONFIG.auth.user)}\r\n`;
|
|
1458
|
-
rawMessage += `To: ${args.to}\r\n`;
|
|
1459
|
-
if (args.cc) rawMessage += `Cc: ${args.cc}\r\n`;
|
|
1460
|
-
if (args.bcc) rawMessage += `Bcc: ${args.bcc}\r\n`;
|
|
1461
|
-
rawMessage += `Subject: ${args.subject}\r\n`;
|
|
1462
|
-
rawMessage += `Date: ${sentDate.toUTCString()}\r\n`;
|
|
1463
|
-
rawMessage += `Message-ID: ${info.messageId}\r\n`;
|
|
1464
|
-
rawMessage += `MIME-Version: 1.0\r\n`;
|
|
1465
|
-
|
|
1466
|
-
if (args.html) {
|
|
1467
|
-
rawMessage += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
|
|
1468
|
-
rawMessage += `--${boundary}\r\n`;
|
|
1469
|
-
rawMessage += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
|
1470
|
-
rawMessage += `${args.body || ''}\r\n`;
|
|
1471
|
-
rawMessage += `--${boundary}\r\n`;
|
|
1472
|
-
rawMessage += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
|
|
1473
|
-
rawMessage += `${args.html}\r\n`;
|
|
1474
|
-
rawMessage += `--${boundary}--\r\n`;
|
|
1507
|
+
// Resolve threading first (needs IMAP when reply_to_uid is given), so a
|
|
1508
|
+
// bad uid fails before anything is sent.
|
|
1509
|
+
let threading;
|
|
1510
|
+
if (args.reply_to_uid != null) {
|
|
1511
|
+
const connection = await connectIMAP();
|
|
1512
|
+
try {
|
|
1513
|
+
threading = await resolveThreading(connection, args);
|
|
1514
|
+
} finally {
|
|
1515
|
+
connection.end();
|
|
1516
|
+
}
|
|
1475
1517
|
} else {
|
|
1476
|
-
|
|
1477
|
-
rawMessage += `${args.body || ''}\r\n`;
|
|
1518
|
+
threading = await resolveThreading(null, args);
|
|
1478
1519
|
}
|
|
1520
|
+
if (!threading.subject) throw new Error('subject is required unless replying (reply_to_uid or in_reply_to)');
|
|
1521
|
+
|
|
1522
|
+
// One Message-ID and Date for both copies: the wire copy has no Bcc
|
|
1523
|
+
// header, the Sent copy keeps it, everything else is byte-identical.
|
|
1524
|
+
const from = SMTP_CONFIG.auth.user;
|
|
1525
|
+
const messageId = newMessageId(from);
|
|
1526
|
+
const date = new Date();
|
|
1527
|
+
const fields = { from, to: args.to, cc: args.cc, bcc: args.bcc, body: args.body, html: args.html };
|
|
1528
|
+
const wire = await composeMessage(fields, threading, { keepBcc: false, messageId, date });
|
|
1529
|
+
|
|
1530
|
+
const transporter = nodemailer.createTransport(SMTP_CONFIG);
|
|
1531
|
+
const info = await transporter.sendMail({ envelope: wire.envelope, raw: wire.raw });
|
|
1479
1532
|
|
|
1480
|
-
//
|
|
1533
|
+
// Save a copy to Sent via IMAP
|
|
1481
1534
|
let sentFolderResult = 'skipped';
|
|
1482
1535
|
try {
|
|
1536
|
+
const sentCopy = args.bcc ? await composeMessage(fields, threading, { keepBcc: true, messageId, date }) : wire;
|
|
1483
1537
|
const connection = await connectIMAP();
|
|
1484
1538
|
try {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
let sentFolder = 'Sent'; // default fallback
|
|
1488
|
-
|
|
1489
|
-
const folders = await connection.getBoxes();
|
|
1490
|
-
const flatFolders = Object.keys(folders);
|
|
1491
|
-
for (const candidate of sentFolderCandidates) {
|
|
1492
|
-
if (flatFolders.some(f => f.toLowerCase() === candidate.toLowerCase())) {
|
|
1493
|
-
sentFolder = candidate;
|
|
1494
|
-
break;
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
await connection.append(rawMessage, {
|
|
1499
|
-
mailbox: sentFolder,
|
|
1500
|
-
flags: ['\\Seen']
|
|
1501
|
-
});
|
|
1539
|
+
const sentFolder = await findSentFolder(connection);
|
|
1540
|
+
await appendMessage(connection, sentCopy.raw, sentFolder, ['\\Seen']);
|
|
1502
1541
|
sentFolderResult = `saved to ${sentFolder}`;
|
|
1503
1542
|
} finally {
|
|
1504
1543
|
connection.end();
|
|
@@ -1512,7 +1551,12 @@ async function handleTool(name, args) {
|
|
|
1512
1551
|
type: 'text',
|
|
1513
1552
|
text: JSON.stringify({
|
|
1514
1553
|
success: true,
|
|
1515
|
-
|
|
1554
|
+
message_id: normalizeMessageId(messageId),
|
|
1555
|
+
messageId,
|
|
1556
|
+
subject: threading.subject,
|
|
1557
|
+
...describeThreading(threading),
|
|
1558
|
+
accepted: info.accepted,
|
|
1559
|
+
rejected: info.rejected,
|
|
1516
1560
|
response: info.response,
|
|
1517
1561
|
sentFolder: sentFolderResult
|
|
1518
1562
|
}, null, 2)
|
|
@@ -1525,19 +1569,19 @@ async function handleTool(name, args) {
|
|
|
1525
1569
|
const connection = await connectIMAP();
|
|
1526
1570
|
|
|
1527
1571
|
try {
|
|
1528
|
-
await
|
|
1572
|
+
await openBox(connection, folder, true);
|
|
1529
1573
|
|
|
1530
1574
|
// Fetch struct to locate the part
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1575
|
+
const uid = Number(args.uid);
|
|
1576
|
+
const structFetch = await fetchMessages(connection, [uid], { bodies: [], struct: true });
|
|
1577
|
+
const msg = structFetch.get(uid);
|
|
1578
|
+
if (!msg) {
|
|
1579
|
+
return {
|
|
1580
|
+
content: [{ type: 'text', text: `Email not found: uid ${uid} in ${folder}. UIDs change when a message is moved; search again to get the current uid.` }],
|
|
1581
|
+
isError: true
|
|
1582
|
+
};
|
|
1538
1583
|
}
|
|
1539
1584
|
|
|
1540
|
-
const msg = structFetch[0];
|
|
1541
1585
|
let partID = args.part_id;
|
|
1542
1586
|
|
|
1543
1587
|
// If no partID given, find it by filename
|
|
@@ -1557,20 +1601,12 @@ async function handleTool(name, args) {
|
|
|
1557
1601
|
}
|
|
1558
1602
|
|
|
1559
1603
|
// Fetch the specific MIME part
|
|
1560
|
-
const partFetch = await connection
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
if (partFetch.length === 0) {
|
|
1566
|
-
return { content: [{ type: 'text', text: 'Attachment part not found' }] };
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
const partData = partFetch[0].parts.find(p => p.which === partID);
|
|
1570
|
-
|
|
1571
|
-
if (!partData) {
|
|
1572
|
-
return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
|
|
1604
|
+
const partFetch = await fetchMessages(connection, [uid], { bodies: [partID] });
|
|
1605
|
+
const partBody = partFetch.get(uid)?.parts?.[partID];
|
|
1606
|
+
if (partBody == null) {
|
|
1607
|
+
return { content: [{ type: 'text', text: `Part ${partID} not found in message uid ${uid}` }], isError: true };
|
|
1573
1608
|
}
|
|
1609
|
+
const partData = { body: partBody };
|
|
1574
1610
|
|
|
1575
1611
|
const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
|
|
1576
1612
|
const attachmentInfo = attachmentParts.find(p => p.partID === partID);
|
|
@@ -1660,18 +1696,19 @@ async function handleTool(name, args) {
|
|
|
1660
1696
|
const connection = await connectIMAP();
|
|
1661
1697
|
|
|
1662
1698
|
try {
|
|
1663
|
-
await
|
|
1664
|
-
|
|
1665
|
-
const structFetch = await connection.search([['UID', args.uid]], {
|
|
1666
|
-
bodies: [],
|
|
1667
|
-
struct: true
|
|
1668
|
-
});
|
|
1699
|
+
await openBox(connection, folder, true);
|
|
1669
1700
|
|
|
1670
|
-
|
|
1671
|
-
|
|
1701
|
+
// Fetch struct to locate the part
|
|
1702
|
+
const uid = Number(args.uid);
|
|
1703
|
+
const structFetch = await fetchMessages(connection, [uid], { bodies: [], struct: true });
|
|
1704
|
+
const msg = structFetch.get(uid);
|
|
1705
|
+
if (!msg) {
|
|
1706
|
+
return {
|
|
1707
|
+
content: [{ type: 'text', text: `Email not found: uid ${uid} in ${folder}. UIDs change when a message is moved; search again to get the current uid.` }],
|
|
1708
|
+
isError: true
|
|
1709
|
+
};
|
|
1672
1710
|
}
|
|
1673
1711
|
|
|
1674
|
-
const msg = structFetch[0];
|
|
1675
1712
|
let partID = args.part_id;
|
|
1676
1713
|
|
|
1677
1714
|
if (!partID && args.filename) {
|
|
@@ -1689,20 +1726,12 @@ async function handleTool(name, args) {
|
|
|
1689
1726
|
};
|
|
1690
1727
|
}
|
|
1691
1728
|
|
|
1692
|
-
const partFetch = await connection
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
if (partFetch.length === 0) {
|
|
1698
|
-
return { content: [{ type: 'text', text: 'Attachment part not found' }] };
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
const partData = partFetch[0].parts.find(p => p.which === partID);
|
|
1702
|
-
|
|
1703
|
-
if (!partData) {
|
|
1704
|
-
return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
|
|
1729
|
+
const partFetch = await fetchMessages(connection, [uid], { bodies: [partID] });
|
|
1730
|
+
const partBody = partFetch.get(uid)?.parts?.[partID];
|
|
1731
|
+
if (partBody == null) {
|
|
1732
|
+
return { content: [{ type: 'text', text: `Part ${partID} not found in message uid ${uid}` }], isError: true };
|
|
1705
1733
|
}
|
|
1734
|
+
const partData = { body: partBody };
|
|
1706
1735
|
|
|
1707
1736
|
const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
|
|
1708
1737
|
const attachmentInfo = attachmentParts.find(p => p.partID === partID);
|
|
@@ -1784,12 +1813,17 @@ async function handleTool(name, args) {
|
|
|
1784
1813
|
const trash = args.expunge ? null : await findTrashFolder(connection);
|
|
1785
1814
|
const alreadyInTrash = trash && trash.toLowerCase() === folder.toLowerCase();
|
|
1786
1815
|
|
|
1787
|
-
|
|
1816
|
+
const uid = Number(args.uid);
|
|
1817
|
+
await openBox(connection, folder, false);
|
|
1818
|
+
if (!(await uidExists(connection, uid))) {
|
|
1819
|
+
return {
|
|
1820
|
+
content: [{ type: 'text', text: `Email not found: uid ${uid} in ${folder}. Nothing was deleted. UIDs change when a message is moved; search again to get the current uid.` }],
|
|
1821
|
+
isError: true
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1788
1824
|
|
|
1789
1825
|
if (trash && !alreadyInTrash) {
|
|
1790
|
-
await
|
|
1791
|
-
connection.imap.move(args.uid, trash, (err) => (err ? reject(err) : resolve()));
|
|
1792
|
-
});
|
|
1826
|
+
await moveUids(connection, [uid], trash);
|
|
1793
1827
|
return {
|
|
1794
1828
|
content: [{
|
|
1795
1829
|
type: 'text',
|
|
@@ -1818,8 +1852,8 @@ async function handleTool(name, args) {
|
|
|
1818
1852
|
};
|
|
1819
1853
|
}
|
|
1820
1854
|
|
|
1821
|
-
|
|
1822
|
-
await connection
|
|
1855
|
+
// UID EXPUNGE: erases this one message, never the folder's other \Deleted mail.
|
|
1856
|
+
await expungeUids(connection, [uid]);
|
|
1823
1857
|
return {
|
|
1824
1858
|
content: [{
|
|
1825
1859
|
type: 'text',
|
|
@@ -1844,7 +1878,7 @@ async function handleTool(name, args) {
|
|
|
1844
1878
|
const connection = await connectIMAP();
|
|
1845
1879
|
|
|
1846
1880
|
try {
|
|
1847
|
-
await
|
|
1881
|
+
await openBox(connection, fromFolder, false);
|
|
1848
1882
|
|
|
1849
1883
|
const [r] = await moveWithoutDuplicates(connection, fromFolder, [Number(args.uid)], toFolder);
|
|
1850
1884
|
if (r.action === 'failed') throw new Error(r.error);
|
|
@@ -1875,22 +1909,26 @@ async function handleTool(name, args) {
|
|
|
1875
1909
|
const connection = await connectIMAP();
|
|
1876
1910
|
|
|
1877
1911
|
try {
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1912
|
+
const uid = Number(args.uid);
|
|
1913
|
+
await openBox(connection, folder, false);
|
|
1914
|
+
// STORE on a uid that does not exist is a silent no-op, so check first
|
|
1915
|
+
// rather than report success for a stale uid.
|
|
1916
|
+
if (!(await uidExists(connection, uid))) {
|
|
1917
|
+
return {
|
|
1918
|
+
content: [{ type: 'text', text: `Email not found: uid ${uid} in ${folder}. Nothing was changed. UIDs change when a message is moved; search again to get the current uid.` }],
|
|
1919
|
+
isError: true
|
|
1920
|
+
};
|
|
1884
1921
|
}
|
|
1922
|
+
await storeFlags(connection, [uid], ['\\Flagged'], !!args.flagged);
|
|
1885
1923
|
|
|
1886
1924
|
return {
|
|
1887
1925
|
content: [{
|
|
1888
1926
|
type: 'text',
|
|
1889
1927
|
text: JSON.stringify({
|
|
1890
1928
|
success: true,
|
|
1891
|
-
uid
|
|
1929
|
+
uid,
|
|
1892
1930
|
folder,
|
|
1893
|
-
flagged: args.flagged
|
|
1931
|
+
flagged: !!args.flagged
|
|
1894
1932
|
}, null, 2)
|
|
1895
1933
|
}]
|
|
1896
1934
|
};
|
|
@@ -1904,22 +1942,24 @@ async function handleTool(name, args) {
|
|
|
1904
1942
|
const connection = await connectIMAP();
|
|
1905
1943
|
|
|
1906
1944
|
try {
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
if (
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1945
|
+
const uid = Number(args.uid);
|
|
1946
|
+
await openBox(connection, folder, false);
|
|
1947
|
+
if (!(await uidExists(connection, uid))) {
|
|
1948
|
+
return {
|
|
1949
|
+
content: [{ type: 'text', text: `Email not found: uid ${uid} in ${folder}. Nothing was changed. UIDs change when a message is moved; search again to get the current uid.` }],
|
|
1950
|
+
isError: true
|
|
1951
|
+
};
|
|
1913
1952
|
}
|
|
1953
|
+
await storeFlags(connection, [uid], ['\\Seen'], !!args.seen);
|
|
1914
1954
|
|
|
1915
1955
|
return {
|
|
1916
1956
|
content: [{
|
|
1917
1957
|
type: 'text',
|
|
1918
1958
|
text: JSON.stringify({
|
|
1919
1959
|
success: true,
|
|
1920
|
-
uid
|
|
1960
|
+
uid,
|
|
1921
1961
|
folder,
|
|
1922
|
-
seen: args.seen
|
|
1962
|
+
seen: !!args.seen
|
|
1923
1963
|
}, null, 2)
|
|
1924
1964
|
}]
|
|
1925
1965
|
};
|
|
@@ -1942,7 +1982,7 @@ async function handleTool(name, args) {
|
|
|
1942
1982
|
const connection = await connectIMAP();
|
|
1943
1983
|
|
|
1944
1984
|
try {
|
|
1945
|
-
await
|
|
1985
|
+
await openBox(connection, from_folder, false);
|
|
1946
1986
|
|
|
1947
1987
|
// Build IMAP search criteria: match subject header (and optionally sender)
|
|
1948
1988
|
const searchCriteria = [['SUBJECT', normalizedTarget]];
|
|
@@ -1950,12 +1990,10 @@ async function handleTool(name, args) {
|
|
|
1950
1990
|
searchCriteria.push(['FROM', from_address]);
|
|
1951
1991
|
}
|
|
1952
1992
|
|
|
1953
|
-
const
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
};
|
|
1957
|
-
|
|
1958
|
-
const messages = await connection.search(searchCriteria, fetchOptions);
|
|
1993
|
+
const candidateUids = await queryUids(connection, searchCriteria, { order: 'arrival' });
|
|
1994
|
+
const fetched = await fetchMessages(connection, candidateUids, { bodies: ['HEADER.FIELDS (SUBJECT FROM)'] });
|
|
1995
|
+
const parseHeader = connection.imap.constructor.parseHeader;
|
|
1996
|
+
const messages = candidateUids.filter((u) => fetched.has(u)).map((u) => ({ uid: u, header: headerOf(fetched.get(u), parseHeader) }));
|
|
1959
1997
|
|
|
1960
1998
|
if (messages.length === 0) {
|
|
1961
1999
|
return {
|
|
@@ -1977,10 +2015,9 @@ async function handleTool(name, args) {
|
|
|
1977
2015
|
// each message's normalized subject matches the target exactly.
|
|
1978
2016
|
const matchingUIDs = [];
|
|
1979
2017
|
for (const msg of messages) {
|
|
1980
|
-
const
|
|
1981
|
-
const msgSubject = normalizeSubject((header.subject || [])[0] || '');
|
|
2018
|
+
const msgSubject = normalizeSubject((msg.header.subject || [])[0] || '');
|
|
1982
2019
|
if (msgSubject.toLowerCase() === normalizedTarget.toLowerCase()) {
|
|
1983
|
-
matchingUIDs.push(msg.
|
|
2020
|
+
matchingUIDs.push(msg.uid);
|
|
1984
2021
|
}
|
|
1985
2022
|
}
|
|
1986
2023
|
|
package/lib/imap-session.js
CHANGED
|
@@ -281,6 +281,113 @@ export function serverSupports(connection, cap) {
|
|
|
281
281
|
try { return !!connection.imap.serverSupports(cap); } catch { return false; }
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/** STORE flags on one UID or a list, under the command time limit. add=false removes them. */
|
|
285
|
+
export function storeFlags(connection, uids, flags, add = true) {
|
|
286
|
+
const list = [].concat(uids).map(Number);
|
|
287
|
+
if (!list.length) return Promise.resolve();
|
|
288
|
+
const verb = add ? 'addFlags' : 'delFlags';
|
|
289
|
+
return withTimeout(connection, `${add ? 'set' : 'clear'} flags on ${list.length} message(s)`, new Promise((resolve, reject) => {
|
|
290
|
+
try {
|
|
291
|
+
connection.imap[verb](list, flags, (err) => (err ? reject(err) : resolve()));
|
|
292
|
+
} catch (err) {
|
|
293
|
+
reject(err);
|
|
294
|
+
}
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Erase exactly these UIDs from the open mailbox and nothing else.
|
|
300
|
+
*
|
|
301
|
+
* CLOSE and a bare EXPUNGE erase every message in the mailbox that carries
|
|
302
|
+
* \Deleted, including ones another client flagged and has not expunged yet.
|
|
303
|
+
* UID EXPUNGE (UIDPLUS) is scoped to the given set. On a server without
|
|
304
|
+
* UIDPLUS the flag is taken back off and the call fails, rather than erasing
|
|
305
|
+
* mail nobody asked about.
|
|
306
|
+
*/
|
|
307
|
+
export function expungeUids(connection, uids) {
|
|
308
|
+
const list = [].concat(uids).map(Number);
|
|
309
|
+
if (!list.length) return Promise.resolve();
|
|
310
|
+
return withTimeout(connection, `expunge ${list.length} message(s)`, new Promise((resolve, reject) => {
|
|
311
|
+
try {
|
|
312
|
+
connection.imap.addFlags(list, ['\\Deleted'], (err) => {
|
|
313
|
+
if (err) return reject(err);
|
|
314
|
+
if (!serverSupports(connection, 'UIDPLUS')) {
|
|
315
|
+
connection.imap.delFlags(list, ['\\Deleted'], () => reject(new Error('server does not support UIDPLUS, so a single message cannot be expunged without erasing every other message flagged \\Deleted; nothing was erased')));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
connection.imap.expunge(list, (e) => (e ? reject(e) : resolve()));
|
|
320
|
+
} catch (e) {
|
|
321
|
+
reject(e);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
reject(err);
|
|
326
|
+
}
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** MOVE one UID or a list to another mailbox, under the command time limit. */
|
|
331
|
+
export function moveUids(connection, uids, mailbox) {
|
|
332
|
+
const list = [].concat(uids).map(Number);
|
|
333
|
+
if (!list.length) return Promise.resolve();
|
|
334
|
+
return withTimeout(connection, `move ${list.length} message(s) to ${mailbox}`, new Promise((resolve, reject) => {
|
|
335
|
+
try {
|
|
336
|
+
connection.imap.move(list, mailbox, (err) => (err ? reject(err) : resolve()));
|
|
337
|
+
} catch (err) {
|
|
338
|
+
reject(err);
|
|
339
|
+
}
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** APPEND a raw RFC 5322 message to a mailbox, under the command time limit. */
|
|
344
|
+
export function appendMessage(connection, raw, mailbox, flags = []) {
|
|
345
|
+
return withTimeout(connection, `append to ${mailbox}`, new Promise((resolve, reject) => {
|
|
346
|
+
try {
|
|
347
|
+
connection.imap.append(raw, { mailbox, flags }, (err) => (err ? reject(err) : resolve()));
|
|
348
|
+
} catch (err) {
|
|
349
|
+
reject(err);
|
|
350
|
+
}
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** True when the UID exists in the open mailbox. STORE on a missing UID is a silent no-op, so check first. */
|
|
355
|
+
export async function uidExists(connection, uid) {
|
|
356
|
+
const fetched = await fetchMessages(connection, [Number(uid)], { bodies: [] });
|
|
357
|
+
return fetched.has(Number(uid));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Flatten node-imap's nested mailbox tree into a list of
|
|
362
|
+
* { name, leaf, delimiter, attribs } using each box's own delimiter, so a
|
|
363
|
+
* nested folder comes back under the name the server expects.
|
|
364
|
+
*/
|
|
365
|
+
export function flattenBoxTree(boxes, prefix = '', out = []) {
|
|
366
|
+
for (const [leaf, box] of Object.entries(boxes || {})) {
|
|
367
|
+
const delimiter = box.delimiter || '/';
|
|
368
|
+
const name = prefix ? `${prefix}${delimiter}${leaf}` : leaf;
|
|
369
|
+
const attribs = (box.attribs || []).map((a) => String(a).toLowerCase());
|
|
370
|
+
if (box.special_use_attrib) attribs.push(String(box.special_use_attrib).toLowerCase());
|
|
371
|
+
out.push({ name, leaf, delimiter, attribs });
|
|
372
|
+
if (box.children) flattenBoxTree(box.children, name, out);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Find a special mailbox: the one the server flags with `specialUse`
|
|
379
|
+
* (\Trash, \Drafts, \Sent) when it advertises SPECIAL-USE, else the first
|
|
380
|
+
* selectable box whose leaf name is in `names`. Returns null when neither.
|
|
381
|
+
*/
|
|
382
|
+
export function findSpecialBox(boxes, specialUse, names) {
|
|
383
|
+
const all = flattenBoxTree(boxes).filter((b) => !b.attribs.includes('\\noselect') && !b.attribs.includes('\\nonexistent'));
|
|
384
|
+
const flagged = all.find((b) => b.attribs.includes(specialUse.toLowerCase()));
|
|
385
|
+
if (flagged) return flagged.name;
|
|
386
|
+
const wanted = names.map((n) => n.toLowerCase());
|
|
387
|
+
const byName = all.find((b) => wanted.includes(b.leaf.toLowerCase()) || wanted.includes(b.name.toLowerCase()));
|
|
388
|
+
return byName ? byName.name : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
284
391
|
/**
|
|
285
392
|
* Return matching UIDs, newest first.
|
|
286
393
|
* order 'arrival' uses the server's internal date (delivery time, preserved on
|
package/lib/local-index.js
CHANGED
|
@@ -1078,6 +1078,31 @@ export class LocalIndex {
|
|
|
1078
1078
|
};
|
|
1079
1079
|
}
|
|
1080
1080
|
|
|
1081
|
+
/**
|
|
1082
|
+
* Find one indexed message by Message-ID and return its threading headers,
|
|
1083
|
+
* so a reply sent by Message-ID alone can carry the full References chain.
|
|
1084
|
+
* Returns null when the index does not hold it (or holds it without
|
|
1085
|
+
* threading headers yet).
|
|
1086
|
+
*/
|
|
1087
|
+
lookupMessageId(messageId) {
|
|
1088
|
+
const id = normalizeMessageId(messageId);
|
|
1089
|
+
if (!id || !this.db) return null;
|
|
1090
|
+
const r = this.db.prepare(`
|
|
1091
|
+
SELECT folder, uid, subject, message_id, in_reply_to, references_ids, thread_headers
|
|
1092
|
+
FROM messages WHERE message_id = ? OR message_id = ? LIMIT 1
|
|
1093
|
+
`).get(`<${id}>`, id);
|
|
1094
|
+
if (!r) return null;
|
|
1095
|
+
return {
|
|
1096
|
+
folder: r.folder,
|
|
1097
|
+
uid: r.uid,
|
|
1098
|
+
subject: r.subject || '',
|
|
1099
|
+
message_id: id,
|
|
1100
|
+
in_reply_to: r.in_reply_to || null,
|
|
1101
|
+
references: r.references_ids ? r.references_ids.split(' ').filter(Boolean) : [],
|
|
1102
|
+
thread_headers: !!r.thread_headers
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1081
1106
|
search({ query, folders = null, from = null, since_date = null, before_date = null, field = 'any', match = 'all', prefix = false, sort = 'date', limit = 20, offset = 0 }) {
|
|
1082
1107
|
const where = ['fts MATCH ?'];
|
|
1083
1108
|
const params = [ftsQuery(query, { match, prefix, field })];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengelhard/imap-email-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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",
|