@lengelhard/imap-email-mcp 1.1.0 → 1.2.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 +106 -64
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -83,7 +83,7 @@ function findAttachmentParts(struct, parts = []) {
83
83
  );
84
84
 
85
85
  if (isAttachment) {
86
- parts.push({ node, filename, contentType, disposition });
86
+ parts.push({ node, filename, contentType, disposition, partID: node.partID });
87
87
  }
88
88
  }
89
89
 
@@ -347,6 +347,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
347
347
  required: ['to', 'subject']
348
348
  }
349
349
  },
350
+ {
351
+ name: 'download_attachment',
352
+ description: 'Download a specific email attachment as base64-encoded content. Use the partID from get_email response.',
353
+ inputSchema: {
354
+ type: 'object',
355
+ properties: {
356
+ uid: {
357
+ type: 'number',
358
+ description: 'Email UID'
359
+ },
360
+ part_id: {
361
+ type: 'string',
362
+ description: 'MIME part ID of the attachment (from get_email response)'
363
+ },
364
+ filename: {
365
+ type: 'string',
366
+ description: 'Filename of the attachment (used as fallback to find part_id if not provided)'
367
+ },
368
+ folder: {
369
+ type: 'string',
370
+ description: 'Folder name (default: INBOX)',
371
+ default: 'INBOX'
372
+ }
373
+ },
374
+ required: ['uid']
375
+ }
376
+ },
350
377
  {
351
378
  name: 'delete_email',
352
379
  description: 'Delete an email by UID',
@@ -826,80 +853,97 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
826
853
  bcc: args.bcc
827
854
  };
828
855
 
829
- // Send via SMTP and capture the raw RFC 5322 message
830
856
  const info = await transporter.sendMail(mailOptions);
831
857
 
832
- // Build RFC 5322 message string to APPEND to Sent folder via IMAP
833
- // Use the same content that was sent so the Sent copy is identical
834
- const sentDate = new Date();
835
- const boundary = `----=_Part_${Date.now()}`;
836
- let rawMessage = '';
837
- rawMessage += `From: ${SMTP_CONFIG.auth.user}\r\n`;
838
- rawMessage += `To: ${args.to}\r\n`;
839
- if (args.cc) rawMessage += `Cc: ${args.cc}\r\n`;
840
- if (args.bcc) rawMessage += `Bcc: ${args.bcc}\r\n`;
841
- rawMessage += `Subject: ${args.subject}\r\n`;
842
- rawMessage += `Date: ${sentDate.toUTCString()}\r\n`;
843
- rawMessage += `Message-ID: ${info.messageId}\r\n`;
844
- rawMessage += `MIME-Version: 1.0\r\n`;
845
-
846
- if (args.html) {
847
- rawMessage += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
848
- rawMessage += `--${boundary}\r\n`;
849
- rawMessage += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
850
- rawMessage += `${args.body || ''}\r\n`;
851
- rawMessage += `--${boundary}\r\n`;
852
- rawMessage += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
853
- rawMessage += `${args.html}\r\n`;
854
- rawMessage += `--${boundary}--\r\n`;
855
- } else {
856
- rawMessage += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
857
- rawMessage += `${args.body || ''}\r\n`;
858
- }
859
-
860
- // APPEND to Sent folder via IMAP
861
- let sentFolderResult = 'skipped';
862
- try {
863
- const connection = await connectIMAP();
864
- try {
865
- // Try common Sent folder names in order
866
- const sentFolderCandidates = ['Sent', 'INBOX.Sent', 'Sent Items', 'Sent Mail', '[Gmail]/Sent Mail'];
867
- let sentFolder = 'Sent'; // default fallback
868
-
869
- const folders = await connection.getBoxes();
870
- const flatFolders = Object.keys(folders);
871
- for (const candidate of sentFolderCandidates) {
872
- if (flatFolders.some(f => f.toLowerCase() === candidate.toLowerCase())) {
873
- sentFolder = candidate;
874
- break;
875
- }
876
- }
877
-
878
- await connection.append(rawMessage, {
879
- mailbox: sentFolder,
880
- flags: ['\\Seen']
881
- });
882
- sentFolderResult = `saved to ${sentFolder}`;
883
- } finally {
884
- connection.end();
885
- }
886
- } catch (appendErr) {
887
- sentFolderResult = `failed: ${appendErr.message}`;
888
- }
889
-
890
858
  return {
891
859
  content: [{
892
860
  type: 'text',
893
861
  text: JSON.stringify({
894
862
  success: true,
895
863
  messageId: info.messageId,
896
- response: info.response,
897
- sentFolder: sentFolderResult
864
+ response: info.response
898
865
  }, null, 2)
899
866
  }]
900
867
  };
901
868
  }
902
869
 
870
+ case 'download_attachment': {
871
+ const folder = args.folder || 'INBOX';
872
+ const connection = await connectIMAP();
873
+
874
+ try {
875
+ await connection.openBox(folder);
876
+
877
+ // Fetch struct to locate the part
878
+ const structFetch = await connection.search([['UID', args.uid]], {
879
+ bodies: [],
880
+ struct: true
881
+ });
882
+
883
+ if (structFetch.length === 0) {
884
+ return { content: [{ type: 'text', text: 'Email not found' }] };
885
+ }
886
+
887
+ const msg = structFetch[0];
888
+ let partID = args.part_id;
889
+
890
+ // If no partID given, find it by filename
891
+ if (!partID && args.filename) {
892
+ const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
893
+ const match = attachmentParts.find(p =>
894
+ (p.filename || '').toLowerCase() === args.filename.toLowerCase()
895
+ );
896
+ if (match) partID = match.partID;
897
+ }
898
+
899
+ if (!partID) {
900
+ return {
901
+ content: [{ type: 'text', text: 'Could not determine part ID. Please provide part_id from get_email response.' }],
902
+ isError: true
903
+ };
904
+ }
905
+
906
+ // Fetch the specific MIME part
907
+ const partFetch = await connection.search([['UID', args.uid]], {
908
+ bodies: [partID],
909
+ struct: true
910
+ });
911
+
912
+ if (partFetch.length === 0) {
913
+ return { content: [{ type: 'text', text: 'Attachment part not found' }] };
914
+ }
915
+
916
+ const partData = partFetch[0].parts.find(p => p.which === partID);
917
+
918
+ if (!partData) {
919
+ return { content: [{ type: 'text', text: `Part ${partID} not found in message` }] };
920
+ }
921
+
922
+ // imap-simple returns the body already base64-encoded by the mail server.
923
+ // Re-encoding would corrupt the output — strip whitespace and return directly.
924
+ const base64Content = String(partData.body).replace(/\s+/g, '');
925
+
926
+ const attachmentParts = findAttachmentParts(msg.attributes.struct || []);
927
+ const attachmentInfo = attachmentParts.find(p => p.partID === partID);
928
+
929
+ return {
930
+ content: [{
931
+ type: 'text',
932
+ text: JSON.stringify({
933
+ uid: args.uid,
934
+ partID,
935
+ filename: attachmentInfo?.filename || args.filename || 'attachment',
936
+ contentType: attachmentInfo?.contentType || 'application/octet-stream',
937
+ encoding: 'base64',
938
+ data: base64Content
939
+ }, null, 2)
940
+ }]
941
+ };
942
+ } finally {
943
+ connection.end();
944
+ }
945
+ }
946
+
903
947
  case 'delete_email': {
904
948
  const folder = args.folder || 'INBOX';
905
949
  const connection = await connectIMAP();
@@ -923,8 +967,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
923
967
  try {
924
968
  await connection.openBox(fromFolder);
925
969
 
926
- // node-imap exposes move() on the underlying imap object.
927
- // We wrap it in a Promise so it fits the async/await flow.
928
970
  await new Promise((resolve, reject) => {
929
971
  connection.imap.move(args.uid, toFolder, (err) => {
930
972
  if (err) reject(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengelhard/imap-email-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",