mcp-icloud 2.1.1

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/email/index.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Email module for iCloud MCP
3
+ * Provides email tools via IMAP/SMTP
4
+ */
5
+
6
+ const { z } = require('zod');
7
+ const cloudClient = require('./imap-client');
8
+ const smtpClient = require('./smtp-client');
9
+ const localClient = require('./local-client');
10
+ const { isLocalMode } = require('../mode');
11
+ const { formatSuccess, formatError, withErrorHandler } = require('../utils/error-handler');
12
+ const { listOutput, listResult } = require('../utils/schemas');
13
+ const { formatDate, formatRelative } = require('../utils/date-utils');
14
+ const config = require('../config');
15
+
16
+ /**
17
+ * Normalize a message summary to one shape regardless of mode.
18
+ *
19
+ * Cloud (IMAP) returns a uid plus a raw `flags` array; local (Mail.app)
20
+ * returns an id plus a boolean `read`. Downstream sees `ref` (the handle you
21
+ * pass back to read/mark) and `unread`.
22
+ */
23
+ function normalizeEmail(email, local) {
24
+ if (local) {
25
+ return { ...email, ref: email.id, unread: email.read === false };
26
+ }
27
+ return { ...email, ref: email.uid, unread: !(email.flags || []).includes('\\Seen') };
28
+ }
29
+
30
+ /**
31
+ * Handler: List emails
32
+ */
33
+ async function handleListEmails(args) {
34
+ const folder = args.folder || 'inbox';
35
+ const count = Math.min(args.count || 25, config.DEFAULTS.MAX_RESULTS);
36
+
37
+ const local = isLocalMode();
38
+ const client = local ? localClient : cloudClient;
39
+
40
+ const emails = (await client.listEmails(folder, count)).map(e => normalizeEmail(e, local));
41
+
42
+ if (emails.length === 0) {
43
+ return formatSuccess(`No emails found in ${folder}.`, listResult([]));
44
+ }
45
+
46
+ const lines = emails.map((email, i) => {
47
+ const unread = email.unread ? '[UNREAD] ' : '';
48
+ const date = formatRelative(new Date(email.date));
49
+ return `${i + 1}. ${unread}${email.subject}\n From: ${email.from}\n Date: ${date}\n ${local ? 'ID' : 'UID'}: ${email.ref}`;
50
+ });
51
+
52
+ return formatSuccess(`Emails in ${folder} (${emails.length}):\n\n${lines.join('\n\n')}`, listResult(emails));
53
+ }
54
+
55
+ /**
56
+ * Handler: Read email
57
+ */
58
+ async function handleReadEmail(args) {
59
+ if (!args.uid) {
60
+ return formatError(new Error('Email UID is required'));
61
+ }
62
+
63
+ const folder = args.folder || 'inbox';
64
+ const local = isLocalMode();
65
+
66
+ const email = local
67
+ ? await localClient.readEmail(args.uid)
68
+ : await cloudClient.readEmail(args.uid, folder);
69
+
70
+ // Mail.app returns the body as `body`; mailparser returns `text`.
71
+ let body = email.text || email.body || '';
72
+ if (body.length > config.DEFAULTS.EMAIL_BODY_MAX_LENGTH) {
73
+ body = body.substring(0, config.DEFAULTS.EMAIL_BODY_MAX_LENGTH) + '\n... (truncated)';
74
+ }
75
+
76
+ const attachments = email.attachments || [];
77
+ const attachmentInfo = attachments.length > 0
78
+ ? `\n\nAttachments (${attachments.length}):\n${attachments.map(a => `- ${a.filename}${a.size ? ` (${Math.round(a.size / 1024)}KB)` : ''}`).join('\n')}`
79
+ : '';
80
+
81
+ return formatSuccess(
82
+ `Subject: ${email.subject}
83
+ From: ${email.from}
84
+ To: ${email.to}${email.cc ? `\nCC: ${email.cc}` : ''}
85
+ Date: ${formatDate(email.date)}
86
+ ${local ? 'ID' : 'UID'}: ${email.uid || email.id}
87
+
88
+ ---
89
+
90
+ ${body}${attachmentInfo}`
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Handler: Send email
96
+ */
97
+ async function handleSendEmail(args) {
98
+ if (!args.to) {
99
+ return formatError(new Error('Recipient (to) is required'));
100
+ }
101
+ if (!args.subject) {
102
+ return formatError(new Error('Subject is required'));
103
+ }
104
+ if (!args.body) {
105
+ return formatError(new Error('Body is required'));
106
+ }
107
+
108
+ const result = await (isLocalMode() ? localClient : smtpClient).sendEmail({
109
+ to: args.to,
110
+ cc: args.cc,
111
+ bcc: args.bcc,
112
+ subject: args.subject,
113
+ body: args.body,
114
+ isHtml: args.isHtml || false
115
+ });
116
+
117
+ if (result.success) {
118
+ return formatSuccess(
119
+ `Email sent successfully!\n\nTo: ${args.to}${args.cc ? `\nCC: ${args.cc}` : ''}\nSubject: ${args.subject}${result.messageId ? `\nMessage ID: ${result.messageId}` : ''}`
120
+ );
121
+ } else {
122
+ return formatError(new Error('Failed to send email'));
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Handler: Search emails
128
+ */
129
+ async function handleSearchEmails(args) {
130
+ const folder = args.folder || 'inbox';
131
+ const count = Math.min(args.count || 25, config.DEFAULTS.MAX_RESULTS);
132
+
133
+ const criteria = {};
134
+ if (args.from) criteria.from = args.from;
135
+ if (args.subject) criteria.subject = args.subject;
136
+ if (args.query) criteria.text = args.query;
137
+ if (args.unreadOnly) criteria.unseen = true;
138
+
139
+ const local = isLocalMode();
140
+ const raw = local
141
+ ? await localClient.searchEmails({ query: args.query, from: args.from, subject: args.subject, folder, count })
142
+ : await cloudClient.searchEmails(criteria, folder, count);
143
+ const emails = raw.map(e => normalizeEmail(e, local));
144
+
145
+ if (emails.length === 0) {
146
+ return formatSuccess(`No emails found matching your search criteria in ${folder}.`, listResult([]));
147
+ }
148
+
149
+ const lines = emails.map((email, i) => {
150
+ const unread = email.unread ? '[UNREAD] ' : '';
151
+ const date = formatRelative(new Date(email.date));
152
+ return `${i + 1}. ${unread}${email.subject}\n From: ${email.from}\n Date: ${date}\n ${local ? 'ID' : 'UID'}: ${email.ref}`;
153
+ });
154
+
155
+ return formatSuccess(`Search results in ${folder} (${emails.length}):\n\n${lines.join('\n\n')}`, listResult(emails));
156
+ }
157
+
158
+ /**
159
+ * Handler: Mark as read
160
+ */
161
+ async function handleMarkAsRead(args) {
162
+ if (!args.uid) {
163
+ return formatError(new Error('Email UID is required'));
164
+ }
165
+
166
+ const folder = args.folder || 'inbox';
167
+ const isRead = args.isRead !== false;
168
+
169
+ if (isLocalMode()) {
170
+ await localClient.markAsRead(args.uid, isRead);
171
+ } else {
172
+ await cloudClient.markAsRead(args.uid, folder, isRead);
173
+ }
174
+
175
+ return formatSuccess(`Email ${args.uid} marked as ${isRead ? 'read' : 'unread'}.`);
176
+ }
177
+
178
+ /**
179
+ * Handler: List folders
180
+ */
181
+ async function handleListFolders() {
182
+ const folders = isLocalMode()
183
+ ? await localClient.listFolders()
184
+ : await cloudClient.listFolders();
185
+
186
+ const lines = folders.map(f => `- ${f.name}`);
187
+
188
+ return formatSuccess(`Email folders:\n\n${lines.join('\n')}`, listResult(folders));
189
+ }
190
+
191
+ // Tool definitions
192
+ const emailTools = [
193
+ {
194
+ name: 'list-emails',
195
+ outputSchema: listOutput('Emails'),
196
+ title: 'List Emails',
197
+ description: 'Lists emails from a folder (default: inbox)',
198
+ inputSchema: {
199
+ folder: z.string().optional().describe('Email folder (inbox, sent, drafts, trash, archive, junk)'),
200
+ count: z.number().int().min(1).max(50).optional().describe('Number of emails to retrieve (default: 25, max: 50)')
201
+ },
202
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":true},
203
+ handler: withErrorHandler(handleListEmails, 'list-emails')
204
+ },
205
+ {
206
+ name: 'read-email',
207
+ title: 'Read Email',
208
+ description: 'Reads the full content of an email by UID',
209
+ inputSchema: {
210
+ uid: z.string().describe('Email handle: the UID from list-emails in cloud mode, or the message ID in local mode'),
211
+ folder: z.string().optional().describe('Email folder (default: inbox)')
212
+ },
213
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":true},
214
+ handler: withErrorHandler(handleReadEmail, 'read-email')
215
+ },
216
+ {
217
+ name: 'send-email',
218
+ title: 'Send Email',
219
+ description: 'Composes and sends an email',
220
+ inputSchema: {
221
+ to: z.string().describe('Recipient email address(es), comma-separated'),
222
+ cc: z.string().optional().describe('CC recipient(s), comma-separated'),
223
+ bcc: z.string().optional().describe('BCC recipient(s), comma-separated'),
224
+ subject: z.string().describe('Email subject'),
225
+ body: z.string().describe('Email body content'),
226
+ isHtml: z.boolean().optional().describe('Whether the body is HTML (default: false)')
227
+ },
228
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},
229
+ handler: withErrorHandler(handleSendEmail, 'send-email')
230
+ },
231
+ {
232
+ name: 'search-emails',
233
+ outputSchema: listOutput('Matching emails'),
234
+ title: 'Search Emails',
235
+ description: 'Search for emails by criteria',
236
+ inputSchema: {
237
+ query: z.string().optional().describe('Text to search in email content'),
238
+ from: z.string().optional().describe('Filter by sender'),
239
+ subject: z.string().optional().describe('Filter by subject'),
240
+ folder: z.string().optional().describe('Email folder to search (default: inbox)'),
241
+ unreadOnly: z.boolean().optional().describe('Only show unread emails'),
242
+ count: z.number().int().min(1).max(50).optional().describe('Max results (default: 25, max: 50)')
243
+ },
244
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":true},
245
+ handler: withErrorHandler(handleSearchEmails, 'search-emails')
246
+ },
247
+ {
248
+ name: 'mark-as-read',
249
+ title: 'Mark Email Read/Unread',
250
+ description: 'Marks an email as read or unread',
251
+ inputSchema: {
252
+ uid: z.string().describe('Email handle: the UID from list-emails in cloud mode, or the message ID in local mode'),
253
+ folder: z.string().optional().describe('Email folder (default: inbox)'),
254
+ isRead: z.boolean().optional().describe('Mark as read (true) or unread (false). Default: true')
255
+ },
256
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true},
257
+ handler: withErrorHandler(handleMarkAsRead, 'mark-as-read')
258
+ },
259
+ {
260
+ name: 'list-folders',
261
+ outputSchema: listOutput('Mail folders'),
262
+ title: 'List Mail Folders',
263
+ description: 'Lists all email folders',
264
+ inputSchema: {},
265
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":true},
266
+ handler: withErrorHandler(handleListFolders, 'list-folders')
267
+ }
268
+ ];
269
+
270
+ module.exports = {
271
+ emailTools,
272
+ handleListEmails,
273
+ handleReadEmail,
274
+ handleSendEmail,
275
+ handleSearchEmails,
276
+ handleMarkAsRead,
277
+ handleListFolders
278
+ };
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Local Email Client
3
+ * Accesses Mail.app via AppleScript
4
+ */
5
+
6
+ const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, asBool } = require('../utils/applescript');
7
+ const config = require('../config');
8
+
9
+ /**
10
+ * List emails from a mailbox
11
+ * @param {string} folder - Folder name (inbox, sent, drafts, etc.)
12
+ * @param {number} count - Number of emails to retrieve
13
+ * @returns {Promise<Array>} - List of emails
14
+ */
15
+ async function listEmails(folder = 'inbox', count = 25) {
16
+ const mailboxName = getMailboxName(folder);
17
+
18
+ const script = `
19
+ ObjC.import('Foundation');
20
+ const mail = Application('Mail');
21
+ const accounts = mail.accounts();
22
+ let emails = [];
23
+
24
+ for (let account of accounts) {
25
+ try {
26
+ const mailbox = account.mailboxes.byName('${escapeJXA(mailboxName)}');
27
+ const messages = mailbox.messages();
28
+ const limit = Math.min(${asInt(count, 25)}, messages.length);
29
+
30
+ for (let i = 0; i < limit; i++) {
31
+ const msg = messages[i];
32
+ emails.push({
33
+ id: msg.id(),
34
+ subject: msg.subject(),
35
+ from: msg.sender(),
36
+ date: msg.dateReceived().toISOString(),
37
+ read: msg.readStatus(),
38
+ account: account.name()
39
+ });
40
+ }
41
+ } catch (e) {
42
+ // Mailbox might not exist in this account
43
+ }
44
+ }
45
+
46
+ JSON.stringify(emails.slice(0, ${count}));
47
+ `;
48
+
49
+ const result = await runJXA(script);
50
+ return result ? JSON.parse(result) : [];
51
+ }
52
+
53
+ /**
54
+ * Read a specific email
55
+ * @param {string} emailId - Email ID
56
+ * @returns {Promise<Object>} - Email content
57
+ */
58
+ async function readEmail(emailId) {
59
+ const script = `
60
+ const mail = Application('Mail');
61
+ const msg = mail.messages.byId(${asInt(emailId)});
62
+
63
+ JSON.stringify({
64
+ id: msg.id(),
65
+ subject: msg.subject(),
66
+ from: msg.sender(),
67
+ to: msg.toRecipients().map(r => r.address()),
68
+ cc: msg.ccRecipients().map(r => r.address()),
69
+ date: msg.dateReceived().toISOString(),
70
+ body: msg.content(),
71
+ read: msg.readStatus()
72
+ });
73
+ `;
74
+
75
+ const result = await runJXA(script);
76
+ return result ? JSON.parse(result) : null;
77
+ }
78
+
79
+ /**
80
+ * Send an email
81
+ * @param {Object} options - Email options
82
+ * @returns {Promise<Object>} - Send result
83
+ */
84
+ async function sendEmail({ to, cc, bcc, subject, body }) {
85
+ const toRecipients = Array.isArray(to) ? to : [to];
86
+ const ccRecipients = cc ? (Array.isArray(cc) ? cc : [cc]) : [];
87
+ const bccRecipients = bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : [];
88
+
89
+ let script = `
90
+ tell application "Mail"
91
+ set newMessage to make new outgoing message with properties {subject:"${escapeAppleScript(subject)}", content:"${escapeAppleScript(body)}", visible:false}
92
+ tell newMessage
93
+ `;
94
+
95
+ // Add To recipients
96
+ for (const recipient of toRecipients) {
97
+ script += `\n make new to recipient with properties {address:"${escapeAppleScript(recipient)}"}`;
98
+ }
99
+
100
+ // Add CC recipients
101
+ for (const recipient of ccRecipients) {
102
+ script += `\n make new cc recipient with properties {address:"${escapeAppleScript(recipient)}"}`;
103
+ }
104
+
105
+ // Add BCC recipients
106
+ for (const recipient of bccRecipients) {
107
+ script += `\n make new bcc recipient with properties {address:"${escapeAppleScript(recipient)}"}`;
108
+ }
109
+
110
+ script += `
111
+ send
112
+ end tell
113
+ end tell
114
+ return "sent"
115
+ `;
116
+
117
+ await runAppleScript(script);
118
+ return { success: true, message: 'Email sent successfully' };
119
+ }
120
+
121
+ /**
122
+ * Search emails
123
+ * @param {Object} options - Search options
124
+ * @returns {Promise<Array>} - Matching emails
125
+ */
126
+ async function searchEmails({ query, from, subject, folder = 'inbox', count = 25 }) {
127
+ const mailboxName = getMailboxName(folder);
128
+
129
+ let conditions = [];
130
+ if (query) conditions.push(`(msg.subject().toLowerCase().includes("${escapeJXA(query.toLowerCase())}") || msg.content().toLowerCase().includes("${escapeJXA(query.toLowerCase())}"))`);
131
+ if (from) conditions.push(`msg.sender().toLowerCase().includes("${escapeJXA(from.toLowerCase())}")`);
132
+ if (subject) conditions.push(`msg.subject().toLowerCase().includes("${escapeJXA(subject.toLowerCase())}")`);
133
+
134
+ const filterCondition = conditions.length > 0 ? conditions.join(' && ') : 'true';
135
+
136
+ const script = `
137
+ const mail = Application('Mail');
138
+ const accounts = mail.accounts();
139
+ let emails = [];
140
+
141
+ for (let account of accounts) {
142
+ try {
143
+ const mailbox = account.mailboxes.byName('${escapeJXA(mailboxName)}');
144
+ const messages = mailbox.messages();
145
+
146
+ for (let i = 0; i < messages.length && emails.length < ${asInt(count, 25)}; i++) {
147
+ const msg = messages[i];
148
+ if (${filterCondition}) {
149
+ emails.push({
150
+ id: msg.id(),
151
+ subject: msg.subject(),
152
+ from: msg.sender(),
153
+ date: msg.dateReceived().toISOString(),
154
+ read: msg.readStatus(),
155
+ account: account.name()
156
+ });
157
+ }
158
+ }
159
+ } catch (e) {}
160
+ }
161
+
162
+ JSON.stringify(emails);
163
+ `;
164
+
165
+ const result = await runJXA(script);
166
+ return result ? JSON.parse(result) : [];
167
+ }
168
+
169
+ /**
170
+ * Mark email as read/unread
171
+ * @param {string} emailId - Email ID
172
+ * @param {boolean} isRead - Read status
173
+ * @returns {Promise<Object>} - Result
174
+ */
175
+ async function markAsRead(emailId, isRead = true) {
176
+ const script = `
177
+ tell application "Mail"
178
+ set theMessage to message id ${asInt(emailId)}
179
+ set read status of theMessage to ${asBool(isRead, true)}
180
+ end tell
181
+ return "done"
182
+ `;
183
+
184
+ await runAppleScript(script);
185
+ return { success: true, message: `Email marked as ${isRead ? 'read' : 'unread'}` };
186
+ }
187
+
188
+ /**
189
+ * List mail folders/mailboxes
190
+ * @returns {Promise<Array>} - List of folders
191
+ */
192
+ async function listFolders() {
193
+ const script = `
194
+ const mail = Application('Mail');
195
+ const accounts = mail.accounts();
196
+ let folders = [];
197
+
198
+ for (let account of accounts) {
199
+ const mailboxes = account.mailboxes();
200
+ for (let mb of mailboxes) {
201
+ folders.push({
202
+ name: mb.name(),
203
+ account: account.name(),
204
+ unreadCount: mb.unreadCount()
205
+ });
206
+ }
207
+ }
208
+
209
+ JSON.stringify(folders);
210
+ `;
211
+
212
+ const result = await runJXA(script);
213
+ return result ? JSON.parse(result) : [];
214
+ }
215
+
216
+ /**
217
+ * Delete an email
218
+ * @param {string} emailId - Email ID
219
+ * @returns {Promise<Object>} - Result
220
+ */
221
+ async function deleteEmail(emailId) {
222
+ const script = `
223
+ tell application "Mail"
224
+ set theMessage to message id ${asInt(emailId)}
225
+ delete theMessage
226
+ end tell
227
+ return "deleted"
228
+ `;
229
+
230
+ await runAppleScript(script);
231
+ return { success: true, message: 'Email deleted' };
232
+ }
233
+
234
+ /**
235
+ * Map folder names to Mail.app mailbox names
236
+ */
237
+ function getMailboxName(folder) {
238
+ const mapping = {
239
+ 'inbox': 'INBOX',
240
+ 'sent': 'Sent Messages',
241
+ 'drafts': 'Drafts',
242
+ 'trash': 'Deleted Messages',
243
+ 'archive': 'Archive',
244
+ 'junk': 'Junk'
245
+ };
246
+ return mapping[folder.toLowerCase()] || folder;
247
+ }
248
+
249
+ module.exports = {
250
+ listEmails,
251
+ readEmail,
252
+ sendEmail,
253
+ searchEmails,
254
+ markAsRead,
255
+ listFolders,
256
+ deleteEmail
257
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * SMTP client wrapper for sending iCloud Mail
3
+ */
4
+
5
+ const nodemailer = require('nodemailer');
6
+ const config = require('../config');
7
+ const { getCredentials } = require('../auth');
8
+
9
+ /**
10
+ * Create SMTP transporter
11
+ */
12
+ function createTransporter() {
13
+ const creds = getCredentials();
14
+
15
+ return nodemailer.createTransport({
16
+ host: config.SMTP.HOST,
17
+ port: config.SMTP.PORT,
18
+ secure: config.SMTP.SECURE,
19
+ auth: {
20
+ user: creds.email,
21
+ pass: creds.password
22
+ }
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Send an email
28
+ */
29
+ async function sendEmail({ to, cc, bcc, subject, body, isHtml = false }) {
30
+ const creds = getCredentials();
31
+ const transporter = createTransporter();
32
+
33
+ const mailOptions = {
34
+ from: creds.email,
35
+ to,
36
+ subject,
37
+ [isHtml ? 'html' : 'text']: body
38
+ };
39
+
40
+ if (cc) mailOptions.cc = cc;
41
+ if (bcc) mailOptions.bcc = bcc;
42
+
43
+ try {
44
+ const info = await transporter.sendMail(mailOptions);
45
+ return {
46
+ success: true,
47
+ messageId: info.messageId,
48
+ accepted: info.accepted,
49
+ rejected: info.rejected
50
+ };
51
+ } catch (error) {
52
+ if (error.message?.includes('auth') || error.code === 'EAUTH') {
53
+ throw new Error('UNAUTHORIZED');
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Verify SMTP connection
61
+ */
62
+ async function verifyConnection() {
63
+ const transporter = createTransporter();
64
+
65
+ try {
66
+ await transporter.verify();
67
+ return true;
68
+ } catch (error) {
69
+ if (error.message?.includes('auth') || error.code === 'EAUTH') {
70
+ throw new Error('UNAUTHORIZED');
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ module.exports = {
77
+ sendEmail,
78
+ verifyConnection
79
+ };