7e619d0454
Python MCP server (FastMCP) providing Claude Code/CoWork access to Apple Reminders, Calendar, Mail, Contacts, Find My, and Maps via AppleScript. Includes a native SwiftUI config/installer app and a compiled EventKit helper for fast reminder queries on large databases (8,000+ items). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
271 lines
9.6 KiB
Python
271 lines
9.6 KiB
Python
"""Apple Mail tools."""
|
|
|
|
from helpers import run_applescript, safe_applescript_string, parse_applescript_date
|
|
|
|
|
|
def list_mailboxes() -> list[dict]:
|
|
"""List all mail accounts and their mailboxes."""
|
|
script = '''
|
|
tell application "Mail"
|
|
set output to ""
|
|
repeat with acct in accounts
|
|
set acctName to name of acct
|
|
repeat with mb in mailboxes of acct
|
|
set mbName to name of mb
|
|
set msgCount to count of messages of mb
|
|
set output to output & acctName & ">>>" & mbName & "|||" & (msgCount as string) & linefeed
|
|
end repeat
|
|
end repeat
|
|
return output
|
|
end tell'''
|
|
raw = run_applescript(script, timeout=30)
|
|
results = []
|
|
for line in raw.strip().split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("|||")
|
|
if len(parts) < 2:
|
|
continue
|
|
name_part = parts[0]
|
|
account = None
|
|
if ">>>" in name_part:
|
|
account, name_part = name_part.split(">>>", 1)
|
|
results.append({
|
|
"account": account.strip() if account else None,
|
|
"mailbox": name_part.strip(),
|
|
"message_count": int(parts[1].strip()) if parts[1].strip().isdigit() else 0,
|
|
})
|
|
return results
|
|
|
|
|
|
def get_messages(mailbox_name: str = "INBOX", account_name: str | None = None,
|
|
count: int = 20) -> list[dict]:
|
|
"""Get recent messages from a mailbox.
|
|
|
|
Args:
|
|
mailbox_name: Mailbox name (default INBOX).
|
|
account_name: Specific account. If None, uses first account.
|
|
count: Number of recent messages to fetch.
|
|
"""
|
|
safe_mb = safe_applescript_string(mailbox_name)
|
|
if account_name:
|
|
safe_acct = safe_applescript_string(account_name)
|
|
mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"'
|
|
else:
|
|
mb_ref = f'inbox'
|
|
if mailbox_name != "INBOX":
|
|
mb_ref = f'mailbox "{safe_mb}" of first account'
|
|
|
|
script = f'''
|
|
tell application "Mail"
|
|
set output to ""
|
|
set theMB to {mb_ref}
|
|
set msgList to messages 1 through {count} of theMB
|
|
repeat with m in msgList
|
|
set mSubject to subject of m
|
|
set mFrom to sender of m
|
|
set mDate to (date received of m) as string
|
|
set mRead to read status of m as string
|
|
set mId to message id of m
|
|
set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & "|||" & mId & linefeed
|
|
end repeat
|
|
return output
|
|
end tell'''
|
|
raw = run_applescript(script, timeout=30)
|
|
results = []
|
|
for line in raw.strip().split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("|||")
|
|
if len(parts) < 5:
|
|
continue
|
|
results.append({
|
|
"subject": parts[0].strip(),
|
|
"from": parts[1].strip(),
|
|
"date": parse_applescript_date(parts[2].strip()),
|
|
"read": parts[3].strip() == "true",
|
|
"message_id": parts[4].strip(),
|
|
})
|
|
return results
|
|
|
|
|
|
def get_message_content(subject: str, mailbox_name: str = "INBOX",
|
|
account_name: str | None = None) -> dict:
|
|
"""Get full content of a specific message by subject."""
|
|
safe_subject = safe_applescript_string(subject)
|
|
if account_name:
|
|
safe_acct = safe_applescript_string(account_name)
|
|
safe_mb = safe_applescript_string(mailbox_name)
|
|
mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"'
|
|
else:
|
|
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
|
|
|
|
script = f'''
|
|
tell application "Mail"
|
|
set theMB to {mb_ref}
|
|
set msgs to (messages of theMB whose subject contains "{safe_subject}")
|
|
if (count of msgs) > 0 then
|
|
set m to item 1 of msgs
|
|
set mSubject to subject of m
|
|
set mFrom to sender of m
|
|
set mTo to ""
|
|
try
|
|
set recipList to to recipients of m
|
|
set mTo to ""
|
|
repeat with r in recipList
|
|
set mTo to mTo & (address of r) & ", "
|
|
end repeat
|
|
end try
|
|
set mDate to (date received of m) as string
|
|
set mContent to content of m
|
|
set mRead to read status of m as string
|
|
return mSubject & "|||" & mFrom & "|||" & mTo & "|||" & mDate & "|||" & mRead & "|||" & mContent
|
|
else
|
|
return "NOT_FOUND"
|
|
end if
|
|
end tell'''
|
|
raw = run_applescript(script, timeout=30)
|
|
if raw == "NOT_FOUND":
|
|
raise RuntimeError(f"Message with subject containing '{subject}' not found")
|
|
parts = raw.split("|||", 5)
|
|
if len(parts) < 6:
|
|
raise RuntimeError("Failed to parse message")
|
|
return {
|
|
"subject": parts[0].strip(),
|
|
"from": parts[1].strip(),
|
|
"to": parts[2].strip().rstrip(", ") or None,
|
|
"date": parse_applescript_date(parts[3].strip()),
|
|
"read": parts[4].strip() == "true",
|
|
"content": parts[5].strip(),
|
|
}
|
|
|
|
|
|
def search_mail(query: str, mailbox_name: str = "INBOX", count: int = 20) -> list[dict]:
|
|
"""Search mail by subject or sender."""
|
|
safe_query = safe_applescript_string(query)
|
|
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
|
|
|
|
script = f'''
|
|
tell application "Mail"
|
|
set output to ""
|
|
set theMB to {mb_ref}
|
|
set msgs to (messages of theMB whose subject contains "{safe_query}" or sender contains "{safe_query}")
|
|
set maxCount to {count}
|
|
set i to 0
|
|
repeat with m in msgs
|
|
if i >= maxCount then exit repeat
|
|
set mSubject to subject of m
|
|
set mFrom to sender of m
|
|
set mDate to (date received of m) as string
|
|
set mRead to read status of m as string
|
|
set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & linefeed
|
|
set i to i + 1
|
|
end repeat
|
|
return output
|
|
end tell'''
|
|
raw = run_applescript(script, timeout=60)
|
|
results = []
|
|
for line in raw.strip().split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("|||")
|
|
if len(parts) < 4:
|
|
continue
|
|
results.append({
|
|
"subject": parts[0].strip(),
|
|
"from": parts[1].strip(),
|
|
"date": parse_applescript_date(parts[2].strip()),
|
|
"read": parts[3].strip() == "true",
|
|
})
|
|
return results
|
|
|
|
|
|
def send_mail(to: str, subject: str, body: str, cc: str | None = None,
|
|
from_account: str | None = None) -> dict:
|
|
"""Send an email."""
|
|
safe_to = safe_applescript_string(to)
|
|
safe_subject = safe_applescript_string(subject)
|
|
safe_body = safe_applescript_string(body)
|
|
|
|
cc_line = ""
|
|
if cc:
|
|
cc_addrs = [a.strip() for a in cc.split(",")]
|
|
cc_lines = "\n".join([f'make new cc recipient at end of cc recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in cc_addrs])
|
|
cc_line = cc_lines
|
|
|
|
account_line = ""
|
|
if from_account:
|
|
account_line = f', sender:"{safe_applescript_string(from_account)}"'
|
|
|
|
to_addrs = [a.strip() for a in to.split(",")]
|
|
to_lines = "\n".join([f'make new to recipient at end of to recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in to_addrs])
|
|
|
|
script = f'''
|
|
tell application "Mail"
|
|
set newMsg to make new outgoing message with properties {{subject:"{safe_subject}", content:"{safe_body}", visible:false{account_line}}}
|
|
tell newMsg
|
|
{to_lines}
|
|
{cc_line}
|
|
end tell
|
|
send newMsg
|
|
return "sent"
|
|
end tell'''
|
|
run_applescript(script, timeout=30)
|
|
return {"to": to, "subject": subject, "sent": True}
|
|
|
|
|
|
def mark_read(subject: str, mailbox_name: str = "INBOX", read: bool = True) -> dict:
|
|
"""Mark a message as read or unread."""
|
|
safe_subject = safe_applescript_string(subject)
|
|
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
|
|
read_val = "true" if read else "false"
|
|
|
|
script = f'''
|
|
tell application "Mail"
|
|
set theMB to {mb_ref}
|
|
set msgs to (messages of theMB whose subject contains "{safe_subject}")
|
|
if (count of msgs) > 0 then
|
|
set read status of item 1 of msgs to {read_val}
|
|
return "done"
|
|
else
|
|
return "not_found"
|
|
end if
|
|
end tell'''
|
|
result = run_applescript(script)
|
|
if result == "done":
|
|
return {"subject": subject, "read": read}
|
|
raise RuntimeError(f"Message '{subject}' not found")
|
|
|
|
|
|
def move_message(subject: str, from_mailbox: str, to_mailbox: str,
|
|
account_name: str | None = None) -> dict:
|
|
"""Move a message to a different mailbox."""
|
|
safe_subject = safe_applescript_string(subject)
|
|
safe_from = safe_applescript_string(from_mailbox)
|
|
safe_to = safe_applescript_string(to_mailbox)
|
|
|
|
if account_name:
|
|
safe_acct = safe_applescript_string(account_name)
|
|
from_ref = f'mailbox "{safe_from}" of account "{safe_acct}"'
|
|
to_ref = f'mailbox "{safe_to}" of account "{safe_acct}"'
|
|
else:
|
|
from_ref = "inbox" if from_mailbox == "INBOX" else f'mailbox "{safe_from}" of first account'
|
|
to_ref = f'mailbox "{safe_to}" of first account'
|
|
|
|
script = f'''
|
|
tell application "Mail"
|
|
set fromMB to {from_ref}
|
|
set toMB to {to_ref}
|
|
set msgs to (messages of fromMB whose subject contains "{safe_subject}")
|
|
if (count of msgs) > 0 then
|
|
move item 1 of msgs to toMB
|
|
return "moved"
|
|
else
|
|
return "not_found"
|
|
end if
|
|
end tell'''
|
|
result = run_applescript(script)
|
|
if result == "moved":
|
|
return {"subject": subject, "from": from_mailbox, "to": to_mailbox, "moved": True}
|
|
raise RuntimeError(f"Message '{subject}' not found in {from_mailbox}")
|