feat: Add compose/reply/forward email actions via Apple Mail
- Add compose.py with async actions that export messages via himalaya - Add apple_mail.py utilities using mailto: URLs and open command - No AppleScript automation for compose/reply/forward (only calendar replies) - Update app.py to call async reply/forward actions - Add SMTP OAuth2 support (disabled by default) in mail.py and auth.py - Add config options for SMTP send and auto-send via AppleScript
This commit is contained in:
255
src/mail/utils/apple_mail.py
Normal file
255
src/mail/utils/apple_mail.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Apple Mail integration utilities.
|
||||
|
||||
Provides functions for opening emails in Apple Mail and optionally
|
||||
auto-sending them via AppleScript.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def open_eml_in_apple_mail(
|
||||
email_content: str,
|
||||
auto_send: bool = False,
|
||||
subject: str = "",
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open an email in Apple Mail, optionally auto-sending it.
|
||||
|
||||
Args:
|
||||
email_content: The raw email content (RFC 5322 format)
|
||||
auto_send: If True, automatically send the email after opening
|
||||
subject: Email subject for logging purposes
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
# Create a temp .eml file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".eml", delete=False, encoding="utf-8"
|
||||
) as tmp:
|
||||
tmp.write(email_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
logger.info(f"Created temp .eml file: {tmp_path}")
|
||||
|
||||
# Open with Apple Mail
|
||||
result = subprocess.run(
|
||||
["open", "-a", "Mail", tmp_path], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to open Mail: {result.stderr}")
|
||||
return False, f"Failed to open Mail: {result.stderr}"
|
||||
|
||||
if auto_send:
|
||||
# Wait for Mail to open the message
|
||||
time.sleep(1.5)
|
||||
|
||||
# Use AppleScript to send the frontmost message
|
||||
success, message = _applescript_send_frontmost_message()
|
||||
if success:
|
||||
logger.info(f"Auto-sent email: {subject}")
|
||||
# Clean up temp file after sending
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return True, "Email sent successfully"
|
||||
else:
|
||||
logger.warning(
|
||||
f"Auto-send failed, email opened for manual sending: {message}"
|
||||
)
|
||||
return True, f"Email opened (auto-send failed: {message})"
|
||||
else:
|
||||
logger.info(f"Opened email in Mail for manual sending: {subject}")
|
||||
return True, "Email opened in Mail - please send manually"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening email in Apple Mail: {e}", exc_info=True)
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
|
||||
def _applescript_send_frontmost_message() -> Tuple[bool, str]:
|
||||
"""
|
||||
Use AppleScript to send the frontmost message in Apple Mail.
|
||||
|
||||
When an .eml file is opened, Mail shows it as a "view" not a compose window.
|
||||
We need to use Message > Send Again to convert it to a compose window,
|
||||
then send it.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
# AppleScript to:
|
||||
# 1. Activate Mail
|
||||
# 2. Use "Send Again" menu item to convert viewed message to compose
|
||||
# 3. Send the message with Cmd+Shift+D
|
||||
applescript = """
|
||||
tell application "Mail"
|
||||
activate
|
||||
delay 0.3
|
||||
end tell
|
||||
|
||||
tell application "System Events"
|
||||
tell process "Mail"
|
||||
-- First, trigger "Send Again" from Message menu to convert to compose window
|
||||
-- Menu: Message > Send Again (Cmd+Shift+D also works for this in some contexts)
|
||||
try
|
||||
click menu item "Send Again" of menu "Message" of menu bar 1
|
||||
delay 0.5
|
||||
on error
|
||||
-- If Send Again fails, window might already be a compose window
|
||||
end try
|
||||
|
||||
-- Now send the message with Cmd+Shift+D
|
||||
keystroke "d" using {command down, shift down}
|
||||
delay 0.3
|
||||
|
||||
return "sent"
|
||||
end tell
|
||||
end tell
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", applescript], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.strip()
|
||||
if output == "sent":
|
||||
return True, "Message sent"
|
||||
else:
|
||||
return False, output
|
||||
else:
|
||||
return False, result.stderr.strip()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "AppleScript timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def compose_new_email(
|
||||
to: str = "",
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
auto_send: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open a new compose window in Apple Mail using mailto: URL.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
body: Email body text
|
||||
auto_send: Ignored - no AppleScript automation for compose
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
import urllib.parse
|
||||
|
||||
try:
|
||||
# Build mailto: URL
|
||||
params = {}
|
||||
if subject:
|
||||
params["subject"] = subject
|
||||
if body:
|
||||
params["body"] = body
|
||||
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
mailto_url = f"mailto:{to}"
|
||||
if query_string:
|
||||
mailto_url += f"?{query_string}"
|
||||
|
||||
# Open mailto: URL - this will open the default mail client
|
||||
result = subprocess.run(["open", mailto_url], capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to open mailto: {result.stderr}")
|
||||
return False, f"Failed to open compose: {result.stderr}"
|
||||
|
||||
return True, "Compose window opened"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error composing email: {e}", exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def reply_to_email(
|
||||
original_message_path: str,
|
||||
reply_all: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open an email in Apple Mail for the user to manually reply.
|
||||
|
||||
This just opens the .eml file in Mail. The user can then use
|
||||
Mail's Reply button (Cmd+R) or Reply All (Cmd+Shift+R) themselves.
|
||||
|
||||
Args:
|
||||
original_message_path: Path to the original .eml file
|
||||
reply_all: Ignored - user will manually choose reply type
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
# Just open the message in Mail - no AppleScript automation
|
||||
result = subprocess.run(
|
||||
["open", "-a", "Mail", original_message_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False, f"Failed to open message: {result.stderr}"
|
||||
|
||||
reply_type = "Reply All" if reply_all else "Reply"
|
||||
return (
|
||||
True,
|
||||
f"Message opened - use {reply_type} (Cmd+{'Shift+' if reply_all else ''}R)",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening message for reply: {e}", exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def forward_email(original_message_path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open an email in Apple Mail for the user to manually forward.
|
||||
|
||||
This just opens the .eml file in Mail. The user can then use
|
||||
Mail's Forward button (Cmd+Shift+F) themselves.
|
||||
|
||||
Args:
|
||||
original_message_path: Path to the original .eml file
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
# Just open the message in Mail - no AppleScript automation
|
||||
result = subprocess.run(
|
||||
["open", "-a", "Mail", original_message_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False, f"Failed to open message: {result.stderr}"
|
||||
|
||||
return True, "Message opened - use Forward (Cmd+Shift+F)"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening message for forward: {e}", exc_info=True)
|
||||
return False, str(e)
|
||||
Reference in New Issue
Block a user