@chrimerss/apple-mcp-enhanced
Version:
Enhanced Apple MCP tools with email folder management, contacts, notes, messages, and mail integration
1,011 lines (890 loc) • 31.7 kB
text/typescript
import { run } from "@jxa/run";
import { runAppleScript } from "run-applescript";
async function checkMailAccess(): Promise<boolean> {
try {
// First check if Mail is running
const isRunning = await runAppleScript(`
tell application "System Events"
return application process "Mail" exists
end tell`);
if (isRunning !== "true") {
console.error("Mail app is not running, attempting to launch...");
try {
await runAppleScript(`
tell application "Mail" to activate
delay 2`);
} catch (activateError) {
console.error("Error activating Mail app:", activateError);
throw new Error(
"Could not activate Mail app. Please start it manually.",
);
}
}
// Try to get the count of mailboxes as a simple test
try {
await runAppleScript(`
tell application "Mail"
count every mailbox
end tell`);
return true;
} catch (mailboxError) {
console.error("Error accessing mailboxes:", mailboxError);
// Try an alternative check
try {
const mailVersion = await runAppleScript(`
tell application "Mail"
return its version
end tell`);
console.error("Mail version:", mailVersion);
return true;
} catch (versionError) {
console.error("Error getting Mail version:", versionError);
throw new Error(
"Mail app is running but cannot access mailboxes. Please check permissions and configuration.",
);
}
}
} catch (error) {
console.error("Mail access check failed:", error);
throw new Error(
`Cannot access Mail app. Please make sure Mail is running and properly configured. Error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
interface EmailMessage {
subject: string;
sender: string;
dateSent: string;
content: string;
isRead: boolean;
mailbox: string;
}
async function getUnreadMails(limit = 10): Promise<EmailMessage[]> {
try {
if (!(await checkMailAccess())) {
return [];
}
// First, try with AppleScript which might be more reliable for this case
try {
const script = `
tell application "Mail"
set allMailboxes to every mailbox
set resultList to {}
repeat with m in allMailboxes
try
set unreadMessages to (messages of m whose read status is false)
if (count of unreadMessages) > 0 then
set msgLimit to ${limit}
if (count of unreadMessages) < msgLimit then
set msgLimit to (count of unreadMessages)
end if
repeat with i from 1 to msgLimit
try
set currentMsg to item i of unreadMessages
set msgData to {subject:(subject of currentMsg), sender:(sender of currentMsg), ¬
date:(date sent of currentMsg) as string, mailbox:(name of m)}
try
set msgContent to content of currentMsg
if length of msgContent > 500 then
set msgContent to (text 1 thru 500 of msgContent) & "..."
end if
set msgData to msgData & {content:msgContent}
on error
set msgData to msgData & {content:"[Content not available]"}
end try
set end of resultList to msgData
end try
end repeat
if (count of resultList) ≥ ${limit} then exit repeat
end if
end try
end repeat
return resultList
end tell`;
const asResult = await runAppleScript(script);
// If we got results, parse them
if (asResult && asResult.toString().trim().length > 0) {
try {
// Try to parse as JSON if the result looks like JSON
if (asResult.startsWith("{") || asResult.startsWith("[")) {
const parsedResults = JSON.parse(asResult);
if (Array.isArray(parsedResults) && parsedResults.length > 0) {
return parsedResults.map((msg) => ({
subject: msg.subject || "No subject",
sender: msg.sender || "Unknown sender",
dateSent: msg.date || new Date().toString(),
content: msg.content || "[Content not available]",
isRead: false, // These are all unread by definition
mailbox: msg.mailbox || "Unknown mailbox",
}));
}
}
// If it's not in JSON format, try to parse the plist/record format
const parsedEmails: EmailMessage[] = [];
// Very simple parsing for the record format that AppleScript might return
// This is a best-effort attempt and might not be perfect
const matches = asResult.match(/\{([^}]+)\}/g);
if (matches && matches.length > 0) {
for (const match of matches) {
try {
// Parse key-value pairs
const props = match.substring(1, match.length - 1).split(",");
const emailData: { [key: string]: string } = {};
for (const prop of props) {
const parts = prop.split(":");
if (parts.length >= 2) {
const key = parts[0].trim();
const value = parts.slice(1).join(":").trim();
emailData[key] = value;
}
}
if (emailData.subject || emailData.sender) {
parsedEmails.push({
subject: emailData.subject || "No subject",
sender: emailData.sender || "Unknown sender",
dateSent: emailData.date || new Date().toString(),
content: emailData.content || "[Content not available]",
isRead: false,
mailbox: emailData.mailbox || "Unknown mailbox",
});
}
} catch (parseError) {
console.error("Error parsing email match:", parseError);
}
}
}
if (parsedEmails.length > 0) {
return parsedEmails;
}
} catch (parseError) {
console.error("Error parsing AppleScript result:", parseError);
// If parsing failed, continue to the JXA approach
}
}
// If the raw result contains useful info but parsing failed
if (
asResult.includes("subject") &&
asResult.includes("sender")
) {
console.error("Returning raw AppleScript result for debugging");
return [
{
subject: "Raw AppleScript Output",
sender: "Mail System",
dateSent: new Date().toString(),
content: `Could not parse Mail data properly. Raw output: ${asResult}`,
isRead: false,
mailbox: "Debug",
},
];
}
} catch (asError) {
// Continue to JXA approach as fallback
}
console.error("Trying JXA approach for unread emails...");
// Check Mail accounts as a different approach
const accounts = await runAppleScript(`
tell application "Mail"
set accts to {}
repeat with a in accounts
set end of accts to name of a
end repeat
return accts
end tell`);
console.error("Available accounts:", accounts);
// Try using direct AppleScript to check for unread messages across all accounts
const unreadInfo = await runAppleScript(`
tell application "Mail"
set unreadInfo to {}
repeat with m in every mailbox
try
set unreadCount to count (messages of m whose read status is false)
if unreadCount > 0 then
set end of unreadInfo to {name of m, unreadCount}
end if
end try
end repeat
return unreadInfo
end tell`);
console.error("Mailboxes with unread messages:", unreadInfo);
// Fallback to JXA approach
const unreadMails: EmailMessage[] = await run((limit: number) => {
const Mail = Application("Mail");
const results = [];
try {
const accounts = Mail.accounts();
for (const account of accounts) {
try {
const accountName = account.name();
try {
const accountMailboxes = account.mailboxes();
for (const mailbox of accountMailboxes) {
try {
const boxName = mailbox.name();
// biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
let unreadMessages;
try {
unreadMessages = mailbox.messages.whose({
readStatus: false,
})();
const count = Math.min(
unreadMessages.length,
limit - results.length,
);
for (let i = 0; i < count; i++) {
try {
const msg = unreadMessages[i];
results.push({
subject: msg.subject(),
sender: msg.sender(),
dateSent: msg.dateSent().toString(),
content: msg.content()
? msg.content().substring(0, 500)
: "[No content]",
isRead: false,
mailbox: `${accountName} - ${boxName}`,
});
} catch (msgError) {}
}
} catch (unreadError) {}
} catch (boxError) {}
if (results.length >= limit) {
break;
}
}
} catch (mbError) {}
if (results.length >= limit) {
break;
}
} catch (accError) {}
}
} catch (error) {}
return results;
}, limit);
return unreadMails;
} catch (error) {
console.error("Error in getUnreadMails:", error);
throw new Error(
`Error accessing mail: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function searchMails(
searchTerm: string,
limit = 10,
): Promise<EmailMessage[]> {
try {
if (!(await checkMailAccess())) {
return [];
}
// Ensure Mail app is running
await runAppleScript(`
if application "Mail" is not running then
tell application "Mail" to activate
delay 2
end if`);
// First try the AppleScript approach which might be more reliable
try {
const script = `
tell application "Mail"
set searchString to "${searchTerm.replace(/"/g, '\\"')}"
set foundMsgs to {}
set allBoxes to every mailbox
repeat with currentBox in allBoxes
try
set boxMsgs to (messages of currentBox whose (subject contains searchString) or (content contains searchString))
set foundMsgs to foundMsgs & boxMsgs
if (count of foundMsgs) ≥ ${limit} then exit repeat
end try
end repeat
set resultList to {}
set msgCount to (count of foundMsgs)
if msgCount > ${limit} then set msgCount to ${limit}
repeat with i from 1 to msgCount
try
set currentMsg to item i of foundMsgs
set msgInfo to {subject:subject of currentMsg, sender:sender of currentMsg, ¬
date:(date sent of currentMsg) as string, isRead:read status of currentMsg, ¬
boxName:name of (mailbox of currentMsg)}
set end of resultList to msgInfo
end try
end repeat
return resultList
end tell`;
const asResult = await runAppleScript(script);
// If we got results, parse them
if (asResult && asResult.length > 0) {
try {
const parsedResults = JSON.parse(asResult);
if (Array.isArray(parsedResults) && parsedResults.length > 0) {
return parsedResults.map((msg) => ({
subject: msg.subject || "No subject",
sender: msg.sender || "Unknown sender",
dateSent: msg.date || new Date().toString(),
content: "[Content not available through AppleScript method]",
isRead: msg.isRead || false,
mailbox: msg.boxName || "Unknown mailbox",
}));
}
} catch (parseError) {
console.error("Error parsing AppleScript result:", parseError);
// Continue to JXA approach if parsing fails
}
}
} catch (asError) {
// Continue to JXA approach
}
// JXA approach as fallback
const searchResults: EmailMessage[] = await run(
(searchTerm: string, limit: number) => {
const Mail = Application("Mail");
const results = [];
try {
const mailboxes = Mail.mailboxes();
for (const mailbox of mailboxes) {
try {
// biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
let messages;
try {
messages = mailbox.messages.whose({
_or: [
{ subject: { _contains: searchTerm } },
{ content: { _contains: searchTerm } },
],
})();
const count = Math.min(messages.length, limit);
for (let i = 0; i < count; i++) {
try {
const msg = messages[i];
results.push({
subject: msg.subject(),
sender: msg.sender(),
dateSent: msg.dateSent().toString(),
content: msg.content()
? msg.content().substring(0, 500)
: "[No content]", // Limit content length
isRead: msg.readStatus(),
mailbox: mailbox.name(),
});
} catch (msgError) {}
}
if (results.length >= limit) {
break;
}
} catch (queryError) {
}
} catch (boxError) {}
}
} catch (mbError) {}
return results.slice(0, limit);
},
searchTerm,
limit,
);
return searchResults;
} catch (error) {
console.error("Error in searchMails:", error);
throw new Error(
`Error searching mail: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function sendMail(
to: string,
subject: string,
body: string,
cc?: string,
bcc?: string,
): Promise<string | undefined> {
try {
if (!(await checkMailAccess())) {
throw new Error("Could not access Mail app");
}
// Ensure Mail app is running
await runAppleScript(`
if application "Mail" is not running then
tell application "Mail" to activate
delay 2
end if`);
// Escape special characters in strings for AppleScript
const escapedTo = to.replace(/"/g, '\\"');
const escapedSubject = subject.replace(/"/g, '\\"');
const escapedBody = body.replace(/"/g, '\\"');
const escapedCc = cc ? cc.replace(/"/g, '\\"') : "";
const escapedBcc = bcc ? bcc.replace(/"/g, '\\"') : "";
let script = `
tell application "Mail"
set newMessage to make new outgoing message with properties {subject:"${escapedSubject}", content:"${escapedBody}", visible:true}
tell newMessage
make new to recipient with properties {address:"${escapedTo}"}
`;
if (cc) {
script += ` make new cc recipient with properties {address:"${escapedCc}"}\n`;
}
if (bcc) {
script += ` make new bcc recipient with properties {address:"${escapedBcc}"}\n`;
}
script += ` end tell
send newMessage
return "success"
end tell
`;
try {
const result = await runAppleScript(script);
if (result === "success") {
return `Email sent to ${to} with subject "${subject}"`;
// biome-ignore lint/style/noUselessElse: <explanation>
} else {
}
} catch (asError) {
console.error("Error in AppleScript send:", asError);
const jxaResult: string = await run(
(to, subject, body, cc, bcc) => {
try {
const Mail = Application("Mail");
const msg = Mail.OutgoingMessage().make();
msg.subject = subject;
msg.content = body;
msg.visible = true;
// Add recipients
const toRecipient = Mail.ToRecipient().make();
toRecipient.address = to;
msg.toRecipients.push(toRecipient);
if (cc) {
const ccRecipient = Mail.CcRecipient().make();
ccRecipient.address = cc;
msg.ccRecipients.push(ccRecipient);
}
if (bcc) {
const bccRecipient = Mail.BccRecipient().make();
bccRecipient.address = bcc;
msg.bccRecipients.push(bccRecipient);
}
msg.send();
return "JXA send completed";
} catch (error) {
return `JXA error: ${error}`;
}
},
to,
subject,
body,
cc,
bcc,
);
if (jxaResult.startsWith("JXA error:")) {
throw new Error(jxaResult);
}
return `Email sent to ${to} with subject "${subject}"`;
}
} catch (error) {
console.error("Error in sendMail:", error);
throw new Error(
`Error sending mail: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function getMailboxes(): Promise<string[]> {
try {
if (!(await checkMailAccess())) {
return [];
}
// Ensure Mail app is running
await runAppleScript(`
if application "Mail" is not running then
tell application "Mail" to activate
delay 2
end if`);
const mailboxes: string[] = await run(() => {
const Mail = Application("Mail");
try {
const mailboxes = Mail.mailboxes();
if (!mailboxes || mailboxes.length === 0) {
try {
const result = Mail.execute({
withObjectModel: "Mail Suite",
withCommand: "get name of every mailbox",
});
if (result && result.length > 0) {
return result;
}
} catch (execError) {}
return [];
}
return mailboxes.map((box: unknown) => {
try {
return (box as { name: () => string }).name();
} catch (nameError) {
return "Unknown mailbox";
}
});
} catch (error) {
return [];
}
});
return mailboxes;
} catch (error) {
console.error("Error in getMailboxes:", error);
throw new Error(
`Error getting mailboxes: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function getAccounts(): Promise<string[]> {
try {
if (!(await checkMailAccess())) {
return [];
}
const accounts = await runAppleScript(`
tell application "Mail"
set acctNames to {}
repeat with a in accounts
set end of acctNames to name of a
end repeat
return acctNames
end tell`);
return accounts ? accounts.split(", ") : [];
} catch (error) {
console.error("Error getting accounts:", error);
throw new Error(
`Error getting mail accounts: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function getMailboxesForAccount(accountName: string): Promise<string[]> {
try {
if (!(await checkMailAccess())) {
return [];
}
const mailboxes = await runAppleScript(`
tell application "Mail"
set boxNames to {}
try
set targetAccount to first account whose name is "${accountName.replace(/"/g, '\\"')}"
set acctMailboxes to every mailbox of targetAccount
repeat with mb in acctMailboxes
set end of boxNames to name of mb
end repeat
on error errMsg
return "Error: " & errMsg
end try
return boxNames
end tell`);
if (mailboxes?.startsWith("Error:")) {
console.error(mailboxes);
return [];
}
return mailboxes ? mailboxes.split(", ") : [];
} catch (error) {
console.error("Error getting mailboxes for account:", error);
throw new Error(
`Error getting mailboxes for account ${accountName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
interface EmailIdentifier {
subject?: string;
sender?: string;
dateSent?: string;
messageId?: string;
}
/**
* Creates a new mailbox/folder in the Mail app
* @param mailboxName - Name of the mailbox to create
* @param accountName - Optional account name to create the mailbox in
* @param parentMailboxName - Optional parent mailbox to create this mailbox under
* @returns Promise that resolves to a success message
*/
async function createMailbox(
mailboxName: string,
accountName?: string,
parentMailboxName?: string,
): Promise<string> {
try {
if (!(await checkMailAccess())) {
throw new Error("Could not access Mail app");
}
// Ensure Mail app is running
await runAppleScript(`
if application "Mail" is not running then
tell application "Mail" to activate
delay 2
end if`);
const escapedMailboxName = mailboxName.replace(/"/g, '\\"');
const escapedAccountName = accountName ? accountName.replace(/"/g, '\\"') : "";
const escapedParentMailboxName = parentMailboxName ? parentMailboxName.replace(/"/g, '\\"') : "";
let script = '';
if (accountName) {
if (parentMailboxName) {
// Create mailbox under a parent mailbox in specific account
script = `
tell application "Mail"
try
set targetAccount to first account whose name is "${escapedAccountName}"
set parentBox to first mailbox of targetAccount whose name is "${escapedParentMailboxName}"
make new mailbox with properties {name:"${escapedMailboxName}"} at parentBox
return "success"
on error errMsg
return "Error: " & errMsg
end try
end tell`;
} else {
// Create mailbox at root level of specific account
script = `
tell application "Mail"
try
set targetAccount to first account whose name is "${escapedAccountName}"
make new mailbox with properties {name:"${escapedMailboxName}"} at targetAccount
return "success"
on error errMsg
return "Error: " & errMsg
end try
end tell`;
}
} else {
// Create mailbox in first available account
script = `
tell application "Mail"
try
set firstAccount to first account
make new mailbox with properties {name:"${escapedMailboxName}"} at firstAccount
return "success"
on error errMsg
return "Error: " & errMsg
end try
end tell`;
}
const result = await runAppleScript(script);
if (result === "success") {
const location = accountName
? (parentMailboxName ? `${accountName}/${parentMailboxName}` : accountName)
: "default account";
return `Mailbox "${mailboxName}" created successfully in ${location}`;
} else if (result.startsWith("Error:")) {
throw new Error(result);
} else {
throw new Error(`Unexpected result: ${result}`);
}
} catch (error) {
console.error("Error in createMailbox:", error);
throw new Error(
`Error creating mailbox: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Helper function to create EmailIdentifier from EmailMessage
* @param email - EmailMessage object to convert
* @returns EmailIdentifier object
*/
function createEmailIdentifier(email: EmailMessage): EmailIdentifier {
return {
subject: email.subject,
sender: email.sender,
dateSent: email.dateSent,
};
}
/**
* Moves emails to a specific mailbox/folder
* @param emailIdentifiers - Array of email identifiers to find and move
* @param targetMailboxName - Name of the target mailbox to move emails to
* @param sourceMailboxName - Optional source mailbox to search in (if not provided, searches all mailboxes)
* @returns Promise that resolves to a summary of the move operation
*/
async function moveEmailsToMailbox(
emailIdentifiers: EmailIdentifier[],
targetMailboxName: string,
sourceMailboxName?: string,
): Promise<string> {
try {
if (!(await checkMailAccess())) {
throw new Error("Could not access Mail app");
}
// Ensure Mail app is running and give it time to refresh mailboxes
await runAppleScript(`
if application "Mail" is not running then
tell application "Mail" to activate
delay 2
end if
-- Force refresh of mailboxes to ensure newly created ones are available
tell application "Mail"
try
-- Trigger a mailbox refresh by getting the count
set mailboxCount to count of every mailbox
delay 1
end try
end tell`);
const escapedTargetMailbox = targetMailboxName.replace(/"/g, '\\"');
const escapedSourceMailbox = sourceMailboxName ? sourceMailboxName.replace(/"/g, '\\"') : "";
let movedCount = 0;
const errors: string[] = [];
for (const identifier of emailIdentifiers) {
try {
let searchConditions: string[] = [];
if (identifier.subject) {
searchConditions.push(`subject contains "${identifier.subject.replace(/"/g, '\\"')}"`);
}
if (identifier.sender) {
searchConditions.push(`sender contains "${identifier.sender.replace(/"/g, '\\"')}"`);
}
if (identifier.dateSent) {
searchConditions.push(`date sent is "${identifier.dateSent.replace(/"/g, '\\"')}"`);
}
if (searchConditions.length === 0) {
errors.push("No valid identifier provided for one email");
continue;
}
const whereClause = searchConditions.join(" and ");
let script = '';
if (sourceMailboxName) {
// Search within specific mailbox
script = `
tell application "Mail"
try
-- Find source mailbox with better error handling
set sourceBox to missing value
set allMailboxes to every mailbox
repeat with mb in allMailboxes
if name of mb is "${escapedSourceMailbox}" then
set sourceBox to mb
exit repeat
end if
end repeat
if sourceBox is missing value then
return "Error: Source mailbox '${escapedSourceMailbox}' not found"
end if
-- Find target mailbox with better error handling
set targetBox to missing value
repeat with mb in allMailboxes
if name of mb is "${escapedTargetMailbox}" then
set targetBox to mb
exit repeat
end if
end repeat
if targetBox is missing value then
return "Error: Target mailbox '${escapedTargetMailbox}' not found"
end if
set foundMessages to (messages of sourceBox whose ${whereClause})
if (count of foundMessages) > 0 then
repeat with msg in foundMessages
move msg to targetBox
end repeat
return "moved:" & (count of foundMessages)
else
return "notfound"
end if
on error errMsg
return "Error: " & errMsg
end try
end tell`;
} else {
// Search across all mailboxes
script = `
tell application "Mail"
try
-- Get all mailboxes and find target
set allMailboxes to every mailbox
set targetBox to missing value
repeat with mb in allMailboxes
if name of mb is "${escapedTargetMailbox}" then
set targetBox to mb
exit repeat
end if
end repeat
if targetBox is missing value then
return "Error: Target mailbox '${escapedTargetMailbox}' not found. Available mailboxes: " & (name of every mailbox as string)
end if
set foundMessages to {}
repeat with currentBox in allMailboxes
try
set boxMessages to (messages of currentBox whose ${whereClause})
set foundMessages to foundMessages & boxMessages
on error
-- Skip problematic mailboxes
end try
end repeat
if (count of foundMessages) > 0 then
repeat with msg in foundMessages
move msg to targetBox
end repeat
return "moved:" & (count of foundMessages)
else
return "notfound"
end if
on error errMsg
return "Error: " & errMsg
end try
end tell`;
}
const result = await runAppleScript(script);
if (result.startsWith("moved:")) {
const count = parseInt(result.split(":")[1]);
movedCount += count;
} else if (result === "notfound") {
errors.push(`Email not found with criteria: ${JSON.stringify(identifier)}`);
} else if (result.startsWith("Error:")) {
errors.push(`Error moving email: ${result}`);
}
} catch (emailError) {
errors.push(`Error processing email ${JSON.stringify(identifier)}: ${emailError}`);
}
}
let resultMessage = `Moved ${movedCount} email(s) to "${targetMailboxName}"`;
if (errors.length > 0) {
resultMessage += `\nErrors encountered: ${errors.join("; ")}`;
}
return resultMessage;
} catch (error) {
console.error("Error in moveEmailsToMailbox:", error);
throw new Error(
`Error moving emails: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Helper function to move emails by search criteria (simpler interface)
* @param searchCriteria - Simple search criteria object
* @param targetMailboxName - Name of the target mailbox to move emails to
* @param sourceMailboxName - Optional source mailbox to search in
* @returns Promise that resolves to a summary of the move operation
*/
async function moveEmailsBySearch(
searchCriteria: {
subject?: string;
sender?: string;
keyword?: string;
},
targetMailboxName: string,
sourceMailboxName?: string,
): Promise<string> {
// Convert simple criteria to EmailIdentifier format
const emailIdentifiers: EmailIdentifier[] = [];
if (searchCriteria.keyword) {
// If keyword provided, search by subject containing keyword
emailIdentifiers.push({ subject: searchCriteria.keyword });
} else {
// Use provided subject and/or sender
const identifier: EmailIdentifier = {};
if (searchCriteria.subject) identifier.subject = searchCriteria.subject;
if (searchCriteria.sender) identifier.sender = searchCriteria.sender;
if (Object.keys(identifier).length > 0) {
emailIdentifiers.push(identifier);
}
}
if (emailIdentifiers.length === 0) {
throw new Error("No valid search criteria provided");
}
return moveEmailsToMailbox(emailIdentifiers, targetMailboxName, sourceMailboxName);
}
export default {
getUnreadMails,
searchMails,
sendMail,
getMailboxes,
getAccounts,
getMailboxesForAccount,
createMailbox,
createEmailIdentifier,
moveEmailsToMailbox,
moveEmailsBySearch,
};