@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.
- package/index.js +203 -1
- 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.
|
|
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",
|