@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 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 all draft emails |
169
- | `get_draft` | Get a specific draft by UID |
170
- | `create_draft` | Create a new email draft |
171
- | `update_draft` | Update an existing draft |
172
- | `send_email` | Send an email directly |
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', 'subject']
649
+ required: ['to']
629
650
  }
630
651
  },
631
652
  {
632
653
  name: 'update_draft',
633
- description: 'Update an existing draft by deleting old and creating new',
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', 'subject']
688
+ required: ['uid', 'to']
667
689
  }
668
690
  },
669
691
  {
670
692
  name: 'send_email',
671
- description: 'Send an email directly',
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', 'subject']
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 withTimeout(connection, `remove ${uid}`, new Promise((resolve, reject) => {
953
- connection.imap.addFlags(uid, ['\\Deleted'], (err) => {
954
- if (err) return reject(err);
955
- connection.imap.expunge(uid, (e) => (e ? reject(e) : resolve()));
956
- });
957
- }));
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 withTimeout(connection, `move ${uid}`, new Promise((resolve, reject) => {
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
- * Find the mailbox that holds deleted mail.
1018
- *
1019
- * Prefers the folder the server itself flags as \Trash, since that is what the
1020
- * user's other mail clients will treat as the bin, and falls back to the usual
1021
- * names. Returns null when nothing plausible exists, which the caller reports
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
- const boxes = await connection.getBoxes();
1026
- const walk = (node, prefix = '', out = []) => {
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
- const boxes = await connection.getBoxes();
1050
-
1051
- // Common drafts folder names across providers
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
- return 'Drafts'; // Default fallback
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
- const boxes = await connection.getBoxes();
1099
- const folders = [];
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 || 20;
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 connection.openBox(draftsFolder);
1277
-
1278
- const fetchOptions = {
1279
- bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
1280
- struct: true
1281
- };
1282
-
1283
- const messages = await connection.search(['ALL'], fetchOptions);
1284
- const results = messages.slice(-limit).reverse().map(msg => {
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 connection.openBox(draftsFolder);
1306
-
1307
- const fetchOptions = {
1308
- bodies: [''],
1309
- struct: true
1310
- };
1389
+ await openBox(connection, draftsFolder, true);
1311
1390
 
1312
- const messages = await connection.search([['UID', args.uid]], fetchOptions);
1313
-
1314
- if (messages.length === 0) {
1315
- return { content: [{ type: 'text', text: 'Draft not found' }] };
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 msg = messages[0];
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: msg.attributes.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
- // Build RFC 2822 compliant email message
1349
- const boundary = `----=_Part_${Date.now()}`;
1350
- let message = '';
1351
- message += `From: ${formatFrom(IMAP_CONFIG.imap.user)}\r\n`;
1352
- message += `To: ${args.to}\r\n`;
1353
- if (args.cc) message += `Cc: ${args.cc}\r\n`;
1354
- if (args.bcc) message += `Bcc: ${args.bcc}\r\n`;
1355
- message += `Subject: ${args.subject}\r\n`;
1356
- message += `Date: ${new Date().toUTCString()}\r\n`;
1357
- message += `MIME-Version: 1.0\r\n`;
1358
-
1359
- if (args.html) {
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
- await connection.openBox(draftsFolder);
1387
-
1388
- // Delete old draft
1389
- await connection.addFlags(args.uid, ['\\Deleted']);
1390
- await connection.closeBox(true); // Expunge
1391
-
1392
- // Create new draft
1393
- await connection.openBox(draftsFolder);
1394
-
1395
- const boundary = `----=_Part_${Date.now()}`;
1396
- let message = '';
1397
- message += `From: ${formatFrom(IMAP_CONFIG.imap.user)}\r\n`;
1398
- message += `To: ${args.to}\r\n`;
1399
- if (args.cc) message += `Cc: ${args.cc}\r\n`;
1400
- if (args.bcc) message += `Bcc: ${args.bcc}\r\n`;
1401
- message += `Subject: ${args.subject}\r\n`;
1402
- message += `Date: ${new Date().toUTCString()}\r\n`;
1403
- message += `MIME-Version: 1.0\r\n`;
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
- await connection.append(message, { mailbox: draftsFolder, flags: ['\\Draft'] });
1420
-
1421
- return { content: [{ type: 'text', text: 'Draft updated successfully' }] };
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
- const transporter = nodemailer.createTransport(SMTP_CONFIG);
1436
-
1437
- const mailOptions = {
1438
- from: SENDER_NAME
1439
- ? { name: SENDER_NAME, address: SMTP_CONFIG.auth.user }
1440
- : SMTP_CONFIG.auth.user,
1441
- to: args.to,
1442
- subject: args.subject,
1443
- text: args.body,
1444
- html: args.html,
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
- rawMessage += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
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
- // APPEND to Sent folder via IMAP
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
- // Try common Sent folder names in order
1486
- const sentFolderCandidates = ['Sent', 'INBOX.Sent', 'Sent Items', 'Sent Mail', '[Gmail]/Sent Mail'];
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
- messageId: info.messageId,
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 connection.openBox(folder);
1572
+ await openBox(connection, folder, true);
1529
1573
 
1530
1574
  // Fetch struct to locate the part
1531
- const structFetch = await connection.search([['UID', args.uid]], {
1532
- bodies: [],
1533
- struct: true
1534
- });
1535
-
1536
- if (structFetch.length === 0) {
1537
- return { content: [{ type: 'text', text: 'Email not found' }] };
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.search([['UID', args.uid]], {
1561
- bodies: [partID],
1562
- struct: true
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 connection.openBox(folder);
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
- if (structFetch.length === 0) {
1671
- return { content: [{ type: 'text', text: 'Email not found' }] };
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.search([['UID', args.uid]], {
1693
- bodies: [partID],
1694
- struct: true
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
- await connection.openBox(folder);
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 new Promise((resolve, reject) => {
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
- await connection.addFlags(args.uid, ['\\Deleted']);
1822
- await connection.closeBox(true); // Expunge
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 connection.openBox(fromFolder);
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
- await connection.openBox(folder);
1879
-
1880
- if (args.flagged) {
1881
- await connection.addFlags(args.uid, ['\\Flagged']);
1882
- } else {
1883
- await connection.delFlags(args.uid, ['\\Flagged']);
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: args.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
- await connection.openBox(folder);
1908
-
1909
- if (args.seen) {
1910
- await connection.addFlags(args.uid, ['\\Seen']);
1911
- } else {
1912
- await connection.delFlags(args.uid, ['\\Seen']);
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: args.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 connection.openBox(from_folder);
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 fetchOptions = {
1954
- bodies: ['HEADER.FIELDS (SUBJECT FROM)'],
1955
- struct: false
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 header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
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.attributes.uid);
2020
+ matchingUIDs.push(msg.uid);
1984
2021
  }
1985
2022
  }
1986
2023
 
@@ -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
@@ -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.9.2",
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",