@lengelhard/imap-email-mcp 1.2.0 → 1.3.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.
Files changed (2) hide show
  1. package/index.js +203 -1
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -461,11 +461,44 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
461
461
  },
462
462
  required: ['uid', 'seen']
463
463
  }
464
+ },
465
+ {
466
+ name: 'move_thread',
467
+ description: 'Move an entire email thread (all messages matching a subject) from one folder to another in a single call. Strips Re:/Fwd: prefixes for matching. Use move_email for single-message precision moves.',
468
+ inputSchema: {
469
+ type: 'object',
470
+ properties: {
471
+ subject: {
472
+ type: 'string',
473
+ description: 'Subject line of the thread to move. Re:/Fwd: prefixes are stripped automatically for matching.'
474
+ },
475
+ from_folder: {
476
+ type: 'string',
477
+ description: 'Source folder to search for thread messages'
478
+ },
479
+ to_folder: {
480
+ type: 'string',
481
+ description: 'Destination folder to move messages into'
482
+ },
483
+ from_address: {
484
+ type: 'string',
485
+ description: 'Optional sender address to disambiguate threads with generic subjects'
486
+ }
487
+ },
488
+ required: ['subject', 'from_folder', 'to_folder']
489
+ }
464
490
  }
465
491
  ]
466
492
  };
467
493
  });
468
494
 
495
+ // Strip Re:/Fwd:/Fw: prefixes from a subject line for thread matching.
496
+ // Handles nested prefixes like "Re: Re: Fwd: topic" → "topic"
497
+ function normalizeSubject(subject) {
498
+ if (!subject) return '';
499
+ return subject.replace(/^(\s*(Re|Fwd|Fw)\s*:\s*)+/i, '').trim();
500
+ }
501
+
469
502
  // Helper function to connect to IMAP
470
503
  async function connectIMAP() {
471
504
  if (!IMAP_CONFIG.imap.password) {
@@ -853,15 +886,75 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
853
886
  bcc: args.bcc
854
887
  };
855
888
 
889
+ // Send via SMTP and capture the raw RFC 5322 message
856
890
  const info = await transporter.sendMail(mailOptions);
857
891
 
892
+ // Build RFC 5322 message string to APPEND to Sent folder via IMAP
893
+ // Use the same content that was sent so the Sent copy is identical
894
+ const sentDate = new Date();
895
+ const boundary = `----=_Part_${Date.now()}`;
896
+ let rawMessage = '';
897
+ rawMessage += `From: ${SMTP_CONFIG.auth.user}\r\n`;
898
+ rawMessage += `To: ${args.to}\r\n`;
899
+ if (args.cc) rawMessage += `Cc: ${args.cc}\r\n`;
900
+ if (args.bcc) rawMessage += `Bcc: ${args.bcc}\r\n`;
901
+ rawMessage += `Subject: ${args.subject}\r\n`;
902
+ rawMessage += `Date: ${sentDate.toUTCString()}\r\n`;
903
+ rawMessage += `Message-ID: ${info.messageId}\r\n`;
904
+ rawMessage += `MIME-Version: 1.0\r\n`;
905
+
906
+ if (args.html) {
907
+ rawMessage += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
908
+ rawMessage += `--${boundary}\r\n`;
909
+ rawMessage += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
910
+ rawMessage += `${args.body || ''}\r\n`;
911
+ rawMessage += `--${boundary}\r\n`;
912
+ rawMessage += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
913
+ rawMessage += `${args.html}\r\n`;
914
+ rawMessage += `--${boundary}--\r\n`;
915
+ } else {
916
+ rawMessage += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
917
+ rawMessage += `${args.body || ''}\r\n`;
918
+ }
919
+
920
+ // APPEND to Sent folder via IMAP
921
+ let sentFolderResult = 'skipped';
922
+ try {
923
+ const connection = await connectIMAP();
924
+ try {
925
+ // Try common Sent folder names in order
926
+ const sentFolderCandidates = ['Sent', 'INBOX.Sent', 'Sent Items', 'Sent Mail', '[Gmail]/Sent Mail'];
927
+ let sentFolder = 'Sent'; // default fallback
928
+
929
+ const folders = await connection.getBoxes();
930
+ const flatFolders = Object.keys(folders);
931
+ for (const candidate of sentFolderCandidates) {
932
+ if (flatFolders.some(f => f.toLowerCase() === candidate.toLowerCase())) {
933
+ sentFolder = candidate;
934
+ break;
935
+ }
936
+ }
937
+
938
+ await connection.append(rawMessage, {
939
+ mailbox: sentFolder,
940
+ flags: ['\\Seen']
941
+ });
942
+ sentFolderResult = `saved to ${sentFolder}`;
943
+ } finally {
944
+ connection.end();
945
+ }
946
+ } catch (appendErr) {
947
+ sentFolderResult = `failed: ${appendErr.message}`;
948
+ }
949
+
858
950
  return {
859
951
  content: [{
860
952
  type: 'text',
861
953
  text: JSON.stringify({
862
954
  success: true,
863
955
  messageId: info.messageId,
864
- response: info.response
956
+ response: info.response,
957
+ sentFolder: sentFolderResult
865
958
  }, null, 2)
866
959
  }]
867
960
  };
@@ -1048,6 +1141,115 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1048
1141
  }
1049
1142
  }
1050
1143
 
1144
+ case 'move_thread': {
1145
+ const { subject, from_folder, to_folder, from_address } = args;
1146
+ const normalizedTarget = normalizeSubject(subject);
1147
+
1148
+ if (!normalizedTarget) {
1149
+ return {
1150
+ content: [{ type: 'text', text: 'Error: subject cannot be empty' }],
1151
+ isError: true
1152
+ };
1153
+ }
1154
+
1155
+ const connection = await connectIMAP();
1156
+
1157
+ try {
1158
+ await connection.openBox(from_folder);
1159
+
1160
+ // Build IMAP search criteria: match subject header (and optionally sender)
1161
+ const searchCriteria = [['SUBJECT', normalizedTarget]];
1162
+ if (from_address) {
1163
+ searchCriteria.push(['FROM', from_address]);
1164
+ }
1165
+
1166
+ const fetchOptions = {
1167
+ bodies: ['HEADER.FIELDS (SUBJECT FROM)'],
1168
+ struct: false
1169
+ };
1170
+
1171
+ const messages = await connection.search(searchCriteria, fetchOptions);
1172
+
1173
+ if (messages.length === 0) {
1174
+ return {
1175
+ content: [{
1176
+ type: 'text',
1177
+ text: JSON.stringify({
1178
+ success: false,
1179
+ moved: 0,
1180
+ failed: 0,
1181
+ from_folder,
1182
+ to_folder,
1183
+ message: `No messages found matching subject "${normalizedTarget}" in ${from_folder}`
1184
+ }, null, 2)
1185
+ }]
1186
+ };
1187
+ }
1188
+
1189
+ // IMAP SUBJECT search is a substring match, so post-filter to ensure
1190
+ // each message's normalized subject matches the target exactly.
1191
+ const matchingUIDs = [];
1192
+ for (const msg of messages) {
1193
+ const header = msg.parts.find(p => p.which.includes('HEADER'))?.body || {};
1194
+ const msgSubject = normalizeSubject((header.subject || [])[0] || '');
1195
+ if (msgSubject.toLowerCase() === normalizedTarget.toLowerCase()) {
1196
+ matchingUIDs.push(msg.attributes.uid);
1197
+ }
1198
+ }
1199
+
1200
+ if (matchingUIDs.length === 0) {
1201
+ return {
1202
+ content: [{
1203
+ type: 'text',
1204
+ text: JSON.stringify({
1205
+ success: false,
1206
+ moved: 0,
1207
+ failed: 0,
1208
+ from_folder,
1209
+ to_folder,
1210
+ message: `IMAP returned results but none matched the exact subject "${normalizedTarget}" after normalization`
1211
+ }, null, 2)
1212
+ }]
1213
+ };
1214
+ }
1215
+
1216
+ // Move each matching UID using the same imap.move() pattern as move_email
1217
+ let moved = 0;
1218
+ const failures = [];
1219
+ for (const uid of matchingUIDs) {
1220
+ try {
1221
+ await new Promise((resolve, reject) => {
1222
+ connection.imap.move(uid, to_folder, (err) => {
1223
+ if (err) reject(err);
1224
+ else resolve();
1225
+ });
1226
+ });
1227
+ moved++;
1228
+ } catch (moveErr) {
1229
+ failures.push({ uid, error: moveErr.message });
1230
+ }
1231
+ }
1232
+
1233
+ return {
1234
+ content: [{
1235
+ type: 'text',
1236
+ text: JSON.stringify({
1237
+ success: failures.length === 0,
1238
+ moved,
1239
+ failed: failures.length,
1240
+ total_found: matchingUIDs.length,
1241
+ from_folder,
1242
+ to_folder,
1243
+ subject: normalizedTarget,
1244
+ ...(failures.length > 0 && { failures })
1245
+ }, null, 2)
1246
+ }]
1247
+ };
1248
+ } finally {
1249
+ connection.end();
1250
+ }
1251
+ }
1252
+
1051
1253
  default:
1052
1254
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
1053
1255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",