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:
@@ -475,7 +475,13 @@ async def _sync_outlook_data(
|
||||
archive_mail_async(maildir_path, headers, progress, task_archive, dry_run),
|
||||
delete_mail_async(maildir_path, headers, progress, task_delete, dry_run),
|
||||
process_outbox_async(
|
||||
base_maildir_path, org, headers, progress, task_outbox, dry_run
|
||||
base_maildir_path,
|
||||
org,
|
||||
headers,
|
||||
progress,
|
||||
task_outbox,
|
||||
dry_run,
|
||||
access_token=access_token,
|
||||
),
|
||||
)
|
||||
progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]")
|
||||
@@ -734,6 +740,27 @@ def sync(
|
||||
click.echo(f"Authentication failed: {e}")
|
||||
return
|
||||
|
||||
# Pre-authenticate SMTP token only if SMTP sending is enabled in config
|
||||
from src.mail.config import get_config
|
||||
|
||||
config = get_config()
|
||||
if config.mail.enable_smtp_send:
|
||||
from src.services.microsoft_graph.auth import get_smtp_access_token
|
||||
|
||||
smtp_token = get_smtp_access_token(silent_only=True)
|
||||
if not smtp_token:
|
||||
click.echo(
|
||||
"SMTP authentication required for sending calendar replies..."
|
||||
)
|
||||
try:
|
||||
smtp_token = get_smtp_access_token(silent_only=False)
|
||||
if smtp_token:
|
||||
click.echo("SMTP authentication successful!")
|
||||
except Exception as e:
|
||||
click.echo(
|
||||
f"SMTP authentication failed (calendar replies may not work): {e}"
|
||||
)
|
||||
|
||||
sync_config = {
|
||||
"org": org,
|
||||
"vdir": vdir,
|
||||
@@ -976,6 +1003,27 @@ def interactive(org, vdir, notify, dry_run, demo):
|
||||
click.echo(f"Authentication failed: {e}")
|
||||
return
|
||||
|
||||
# Pre-authenticate SMTP token only if SMTP sending is enabled in config
|
||||
from src.mail.config import get_config
|
||||
|
||||
config = get_config()
|
||||
if config.mail.enable_smtp_send:
|
||||
from src.services.microsoft_graph.auth import get_smtp_access_token
|
||||
|
||||
smtp_token = get_smtp_access_token(silent_only=True)
|
||||
if not smtp_token:
|
||||
click.echo(
|
||||
"SMTP authentication required for sending calendar replies..."
|
||||
)
|
||||
try:
|
||||
smtp_token = get_smtp_access_token(silent_only=False)
|
||||
if smtp_token:
|
||||
click.echo("SMTP authentication successful!")
|
||||
except Exception as e:
|
||||
click.echo(
|
||||
f"SMTP authentication failed (calendar replies may not work): {e}"
|
||||
)
|
||||
|
||||
sync_config = {
|
||||
"org": org,
|
||||
"vdir": vdir,
|
||||
|
||||
@@ -1140,7 +1140,13 @@ async def run_dashboard_sync(
|
||||
try:
|
||||
outbox_progress = DashboardProgressAdapter(tracker, "outbox")
|
||||
result = await process_outbox_async(
|
||||
base_maildir_path, org, headers, outbox_progress, None, dry_run
|
||||
base_maildir_path,
|
||||
org,
|
||||
headers,
|
||||
outbox_progress,
|
||||
None,
|
||||
dry_run,
|
||||
access_token=access_token,
|
||||
)
|
||||
sent_count, failed_count = result if result else (0, 0)
|
||||
if sent_count > 0:
|
||||
|
||||
@@ -332,6 +332,62 @@ def queue_calendar_reply(
|
||||
return False, f"Failed to queue response: {str(e)}"
|
||||
|
||||
|
||||
def send_calendar_reply_via_apple_mail(
|
||||
event: ParsedCalendarEvent,
|
||||
response: str,
|
||||
from_email: str,
|
||||
to_email: str,
|
||||
from_name: Optional[str] = None,
|
||||
auto_send: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Send a calendar reply immediately via Apple Mail.
|
||||
|
||||
Args:
|
||||
event: The parsed calendar event from the original invite
|
||||
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||
from_email: Sender's email address
|
||||
to_email: Recipient's email address (the organizer)
|
||||
from_name: Sender's display name (optional)
|
||||
auto_send: If True, automatically send via AppleScript
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
from src.mail.utils.apple_mail import open_eml_in_apple_mail
|
||||
|
||||
try:
|
||||
# Build the email
|
||||
email_content = build_calendar_reply_email(
|
||||
event, response, from_email, to_email, from_name
|
||||
)
|
||||
|
||||
response_text = {
|
||||
"accept": "accepted",
|
||||
"tentativelyAccept": "tentatively accepted",
|
||||
"decline": "declined",
|
||||
}.get(response, "accepted")
|
||||
|
||||
subject = f"{response_text.capitalize()}: {event.summary or '(no subject)'}"
|
||||
|
||||
# Open in Apple Mail (and optionally auto-send)
|
||||
success, message = open_eml_in_apple_mail(
|
||||
email_content, auto_send=auto_send, subject=subject
|
||||
)
|
||||
|
||||
if success:
|
||||
rsvp_logger.info(
|
||||
f"Calendar reply via Apple Mail: {response_text} for '{event.summary}' to {to_email}"
|
||||
)
|
||||
|
||||
return success, message
|
||||
|
||||
except Exception as e:
|
||||
rsvp_logger.error(
|
||||
f"Failed to send calendar reply via Apple Mail: {e}", exc_info=True
|
||||
)
|
||||
return False, f"Failed to send response: {str(e)}"
|
||||
|
||||
|
||||
def action_accept_invite(app):
|
||||
"""Accept the current calendar invite."""
|
||||
_respond_to_current_invite(app, "accept")
|
||||
@@ -348,8 +404,12 @@ def action_tentative_invite(app):
|
||||
|
||||
|
||||
def _respond_to_current_invite(app, response: str):
|
||||
"""Helper to respond to the current message's calendar invite using ICS/SMTP."""
|
||||
"""Helper to respond to the current message's calendar invite.
|
||||
|
||||
Sends the response immediately via Apple Mail instead of queuing for sync.
|
||||
"""
|
||||
from src.mail.widgets.ContentContainer import ContentContainer
|
||||
from src.mail.config import get_config
|
||||
|
||||
rsvp_logger.info(f"Starting invite response: {response}")
|
||||
|
||||
@@ -404,9 +464,18 @@ def _respond_to_current_invite(app, response: str):
|
||||
)
|
||||
return
|
||||
|
||||
# Queue the calendar reply (organizer_email is guaranteed non-None here)
|
||||
success, message = queue_calendar_reply(
|
||||
calendar_event, response, user_email, organizer_email, user_name
|
||||
# Get config for auto-send preference
|
||||
config = get_config()
|
||||
auto_send = config.mail.auto_send_via_applescript
|
||||
|
||||
# Send immediately via Apple Mail
|
||||
success, message = send_calendar_reply_via_apple_mail(
|
||||
calendar_event,
|
||||
response,
|
||||
user_email,
|
||||
organizer_email,
|
||||
user_name,
|
||||
auto_send=auto_send,
|
||||
)
|
||||
|
||||
severity = "information" if success else "error"
|
||||
|
||||
170
src/mail/actions/compose.py
Normal file
170
src/mail/actions/compose.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Compose, reply, and forward email actions for mail app.
|
||||
|
||||
Uses Apple Mail for composing and sending emails.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from src.mail.utils.apple_mail import (
|
||||
compose_new_email,
|
||||
reply_to_email,
|
||||
forward_email,
|
||||
)
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level temp directory for exported messages (persists across calls)
|
||||
_temp_dir: Optional[tempfile.TemporaryDirectory] = None
|
||||
|
||||
|
||||
def _get_temp_dir() -> str:
|
||||
"""Get or create a persistent temp directory for exported messages."""
|
||||
global _temp_dir
|
||||
if _temp_dir is None:
|
||||
_temp_dir = tempfile.TemporaryDirectory(prefix="luk_mail_")
|
||||
return _temp_dir.name
|
||||
|
||||
|
||||
async def _export_current_message(app) -> Optional[str]:
|
||||
"""Export the currently selected message to a temp .eml file.
|
||||
|
||||
Args:
|
||||
app: The mail app instance
|
||||
|
||||
Returns:
|
||||
Path to the exported .eml file, or None if export failed
|
||||
"""
|
||||
current_message_id = app.current_message_id
|
||||
if not current_message_id:
|
||||
return None
|
||||
|
||||
# Use himalaya to export the raw message
|
||||
raw_content, success = await himalaya_client.get_raw_message(current_message_id)
|
||||
if not success or not raw_content:
|
||||
logger.error(f"Failed to export message {current_message_id}")
|
||||
return None
|
||||
|
||||
# Save to a temp file
|
||||
temp_dir = _get_temp_dir()
|
||||
eml_path = os.path.join(temp_dir, f"message_{current_message_id}.eml")
|
||||
|
||||
try:
|
||||
with open(eml_path, "w", encoding="utf-8") as f:
|
||||
f.write(raw_content)
|
||||
return eml_path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write temp .eml file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_email() -> Optional[str]:
|
||||
"""Get the current user's email address from MSAL cache."""
|
||||
import msal
|
||||
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
|
||||
if not client_id or not tenant_id:
|
||||
return None
|
||||
|
||||
cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin")
|
||||
if not os.path.exists(cache_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
cache = msal.SerializableTokenCache()
|
||||
cache.deserialize(open(cache_file, "r").read())
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
app = msal.PublicClientApplication(
|
||||
client_id, authority=authority, token_cache=cache
|
||||
)
|
||||
accounts = app.get_accounts()
|
||||
|
||||
if accounts:
|
||||
return accounts[0].get("username")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def action_compose(app):
|
||||
"""Open a new compose window in Apple Mail."""
|
||||
user_email = _get_user_email()
|
||||
|
||||
success, message = compose_new_email(
|
||||
to="",
|
||||
subject="",
|
||||
body="",
|
||||
)
|
||||
|
||||
if success:
|
||||
app.notify("Compose window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open compose: {message}", severity="error")
|
||||
|
||||
|
||||
async def action_reply(app):
|
||||
"""Reply to the current message in Apple Mail."""
|
||||
if not app.current_message_id:
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
app.notify("Exporting message...", severity="information")
|
||||
message_path = await _export_current_message(app)
|
||||
|
||||
if not message_path:
|
||||
app.notify("Failed to export message", severity="error")
|
||||
return
|
||||
|
||||
success, message = reply_to_email(message_path, reply_all=False)
|
||||
|
||||
if success:
|
||||
app.notify("Reply window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open reply: {message}", severity="error")
|
||||
|
||||
|
||||
async def action_reply_all(app):
|
||||
"""Reply to all on the current message in Apple Mail."""
|
||||
if not app.current_message_id:
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
app.notify("Exporting message...", severity="information")
|
||||
message_path = await _export_current_message(app)
|
||||
|
||||
if not message_path:
|
||||
app.notify("Failed to export message", severity="error")
|
||||
return
|
||||
|
||||
success, message = reply_to_email(message_path, reply_all=True)
|
||||
|
||||
if success:
|
||||
app.notify("Reply-all window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open reply-all: {message}", severity="error")
|
||||
|
||||
|
||||
async def action_forward(app):
|
||||
"""Forward the current message in Apple Mail."""
|
||||
if not app.current_message_id:
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
app.notify("Exporting message...", severity="information")
|
||||
message_path = await _export_current_message(app)
|
||||
|
||||
if not message_path:
|
||||
app.notify("Failed to export message", severity="error")
|
||||
return
|
||||
|
||||
success, message = forward_email(message_path)
|
||||
|
||||
if success:
|
||||
app.notify("Forward window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open forward: {message}", severity="error")
|
||||
@@ -14,6 +14,12 @@ from .actions.calendar_invite import (
|
||||
action_decline_invite,
|
||||
action_tentative_invite,
|
||||
)
|
||||
from .actions.compose import (
|
||||
action_compose,
|
||||
action_reply,
|
||||
action_reply_all,
|
||||
action_forward,
|
||||
)
|
||||
from src.services.taskwarrior import client as taskwarrior_client
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
from src.utils.shared_config import get_theme_name
|
||||
@@ -144,6 +150,9 @@ class EmailViewerApp(App):
|
||||
Binding("A", "accept_invite", "Accept invite"),
|
||||
Binding("D", "decline_invite", "Decline invite"),
|
||||
Binding("T", "tentative_invite", "Tentative"),
|
||||
Binding("c", "compose", "Compose new email"),
|
||||
Binding("R", "reply", "Reply to email"),
|
||||
Binding("F", "forward", "Forward email"),
|
||||
Binding("?", "show_help", "Show Help"),
|
||||
]
|
||||
)
|
||||
@@ -893,6 +902,18 @@ class EmailViewerApp(App):
|
||||
"""Tentatively accept the calendar invite from the current email."""
|
||||
action_tentative_invite(self)
|
||||
|
||||
def action_compose(self) -> None:
|
||||
"""Open a new compose window in Apple Mail."""
|
||||
action_compose(self)
|
||||
|
||||
async def action_reply(self) -> None:
|
||||
"""Reply to the current email in Apple Mail."""
|
||||
await action_reply(self)
|
||||
|
||||
async def action_forward(self) -> None:
|
||||
"""Forward the current email in Apple Mail."""
|
||||
await action_forward(self)
|
||||
|
||||
def action_open_links(self) -> None:
|
||||
"""Open the link panel showing links from the current message."""
|
||||
content_container = self.query_one(ContentContainer)
|
||||
|
||||
@@ -104,6 +104,15 @@ class MailOperationsConfig(BaseModel):
|
||||
# Folder to move messages to when archiving
|
||||
archive_folder: str = "Archive"
|
||||
|
||||
# Enable SMTP OAuth2 sending (requires IT to enable SMTP AUTH for your mailbox)
|
||||
# When disabled, calendar replies will open in your default mail client instead
|
||||
enable_smtp_send: bool = False
|
||||
|
||||
# Auto-send emails opened in Apple Mail via AppleScript
|
||||
# When True, calendar replies will be sent automatically after opening in Mail
|
||||
# When False, the email will be opened for manual review before sending
|
||||
auto_send_via_applescript: bool = False
|
||||
|
||||
|
||||
class ThemeConfig(BaseModel):
|
||||
"""Theme/appearance settings."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Calendar utilities module."""
|
||||
"""Mail utilities module."""
|
||||
|
||||
from .calendar_parser import (
|
||||
parse_calendar_part,
|
||||
@@ -7,3 +7,10 @@ from .calendar_parser import (
|
||||
is_event_request,
|
||||
ParsedCalendarEvent,
|
||||
)
|
||||
|
||||
from .apple_mail import (
|
||||
open_eml_in_apple_mail,
|
||||
compose_new_email,
|
||||
reply_to_email,
|
||||
forward_email,
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -186,3 +186,78 @@ def get_access_token(scopes):
|
||||
}
|
||||
|
||||
return access_token, headers
|
||||
|
||||
|
||||
def get_smtp_access_token(silent_only: bool = False):
|
||||
"""
|
||||
Get an access token specifically for SMTP sending via Outlook.
|
||||
|
||||
SMTP OAuth2 requires a token with the outlook.office.com resource,
|
||||
which is different from the graph.microsoft.com resource used for
|
||||
other operations.
|
||||
|
||||
Args:
|
||||
silent_only: If True, only attempt silent auth (no interactive prompts).
|
||||
Use this when calling from within a TUI to avoid blocking.
|
||||
|
||||
Returns:
|
||||
str: Access token for SMTP, or None if authentication fails.
|
||||
"""
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
|
||||
if not client_id or not tenant_id:
|
||||
return None
|
||||
|
||||
# Token cache - use consistent location
|
||||
cache = msal.SerializableTokenCache()
|
||||
cache_file = _get_cache_file()
|
||||
|
||||
if os.path.exists(cache_file):
|
||||
cache.deserialize(open(cache_file, "r").read())
|
||||
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
app = msal.PublicClientApplication(
|
||||
client_id, authority=authority, token_cache=cache
|
||||
)
|
||||
accounts = app.get_accounts()
|
||||
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
# Request token for Outlook SMTP scope
|
||||
smtp_scopes = ["https://outlook.office.com/SMTP.Send"]
|
||||
token_response = app.acquire_token_silent(smtp_scopes, account=accounts[0])
|
||||
|
||||
if token_response and "access_token" in token_response:
|
||||
# Save updated cache
|
||||
with open(cache_file, "w") as f:
|
||||
f.write(cache.serialize())
|
||||
return token_response["access_token"]
|
||||
|
||||
# If silent auth failed and we're not in silent_only mode, try interactive flow
|
||||
if not silent_only:
|
||||
try:
|
||||
flow = app.initiate_device_flow(scopes=smtp_scopes)
|
||||
if "user_code" not in flow:
|
||||
return None
|
||||
|
||||
print(
|
||||
Panel(
|
||||
flow["message"],
|
||||
border_style="magenta",
|
||||
padding=2,
|
||||
title="SMTP Authentication Required",
|
||||
)
|
||||
)
|
||||
|
||||
token_response = app.acquire_token_by_device_flow(flow)
|
||||
|
||||
if token_response and "access_token" in token_response:
|
||||
with open(cache_file, "w") as f:
|
||||
f.write(cache.serialize())
|
||||
return token_response["access_token"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@@ -1037,6 +1037,189 @@ async def send_email_async(
|
||||
return False
|
||||
|
||||
|
||||
def send_email_smtp(
|
||||
email_content: str, access_token: str, from_email: str, dry_run: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Send email using SMTP with OAuth2 XOAUTH2 authentication.
|
||||
|
||||
This uses Microsoft 365's SMTP AUTH with OAuth2, which requires the
|
||||
SMTP.Send scope (often available when Mail.ReadWrite is granted).
|
||||
|
||||
Args:
|
||||
email_content: Raw email content (RFC 5322 format)
|
||||
access_token: OAuth2 access token
|
||||
from_email: Sender's email address
|
||||
dry_run: If True, don't actually send the email
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
import smtplib
|
||||
import logging
|
||||
from email.parser import Parser
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.expanduser("~/Mail/sendmail.log"), mode="a"),
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse email to get recipients
|
||||
parser = Parser()
|
||||
msg = parser.parsestr(email_content)
|
||||
|
||||
to_addrs = []
|
||||
for header in ["To", "Cc", "Bcc"]:
|
||||
if msg.get(header):
|
||||
addrs = getaddresses([msg.get(header)])
|
||||
to_addrs.extend([addr for name, addr in addrs if addr])
|
||||
|
||||
subject = msg.get("Subject", "(no subject)")
|
||||
|
||||
if dry_run:
|
||||
print(f"[DRY-RUN] Would send email via SMTP: {subject}")
|
||||
print(f"[DRY-RUN] To: {to_addrs}")
|
||||
return True
|
||||
|
||||
logging.info(f"Attempting SMTP send: {subject} to {to_addrs}")
|
||||
|
||||
# Build XOAUTH2 auth string
|
||||
# Format: base64("user=" + user + "\x01auth=Bearer " + token + "\x01\x01")
|
||||
auth_string = f"user={from_email}\x01auth=Bearer {access_token}\x01\x01"
|
||||
|
||||
# Connect to Office 365 SMTP
|
||||
with smtplib.SMTP("smtp.office365.com", 587) as server:
|
||||
server.set_debuglevel(0)
|
||||
server.ehlo()
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
|
||||
# Authenticate using XOAUTH2
|
||||
server.auth("XOAUTH2", lambda: auth_string)
|
||||
|
||||
# Send the email
|
||||
server.sendmail(from_email, to_addrs, email_content)
|
||||
|
||||
logging.info(f"Successfully sent email via SMTP: {subject}")
|
||||
return True
|
||||
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
logging.error(f"SMTP authentication failed: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"SMTP send failed: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def send_email_smtp_async(
|
||||
email_content: str, access_token: str, from_email: str, dry_run: bool = False
|
||||
) -> bool:
|
||||
"""Async wrapper for send_email_smtp."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, send_email_smtp, email_content, access_token, from_email, dry_run
|
||||
)
|
||||
|
||||
|
||||
async def open_email_in_client_async(email_path: str, subject: str) -> bool:
|
||||
"""
|
||||
Open an email file in the default mail client for manual sending.
|
||||
|
||||
This is used as a fallback when automated sending (Graph API, SMTP) fails.
|
||||
The email is copied to a .eml temp file and opened with the system default
|
||||
mail application.
|
||||
|
||||
Args:
|
||||
email_path: Path to the email file in maildir format
|
||||
subject: Email subject for logging/notification purposes
|
||||
|
||||
Returns:
|
||||
True if the email was successfully opened, False otherwise
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import tempfile
|
||||
import logging
|
||||
from email.parser import Parser
|
||||
from email.utils import parseaddr
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.expanduser("~/Mail/sendmail.log"), mode="a"),
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
# Read and parse the email
|
||||
with open(email_path, "r", encoding="utf-8") as f:
|
||||
email_content = f.read()
|
||||
|
||||
parser = Parser()
|
||||
msg = parser.parsestr(email_content)
|
||||
|
||||
# Extract headers
|
||||
to_header = msg.get("To", "")
|
||||
_, to_email = parseaddr(to_header)
|
||||
from_header = msg.get("From", "")
|
||||
|
||||
# Create a temp .eml file that mail clients can open
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".eml", delete=False, encoding="utf-8"
|
||||
) as tmp:
|
||||
tmp.write(email_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
# Try to open with Outlook first (better .eml support), fall back to default
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Try Outlook
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(
|
||||
["open", "-a", "Microsoft Outlook", tmp_path], capture_output=True
|
||||
),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Fall back to default mail client
|
||||
result = await loop.run_in_executor(
|
||||
None, lambda: subprocess.run(["open", tmp_path], capture_output=True)
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logging.info(f"Opened email in mail client: {subject} (To: {to_email})")
|
||||
|
||||
# Send notification
|
||||
from src.utils.notifications import send_notification
|
||||
|
||||
send_notification(
|
||||
title="Calendar Reply Ready",
|
||||
message=f"To: {to_email}",
|
||||
subtitle=f"Please send: {subject}",
|
||||
sound="default",
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logging.error(f"Failed to open email: {result.stderr.decode()}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error opening email in client: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error opening email in client: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def process_outbox_async(
|
||||
maildir_path: str,
|
||||
org: str,
|
||||
@@ -1044,10 +1227,14 @@ async def process_outbox_async(
|
||||
progress,
|
||||
task_id,
|
||||
dry_run: bool = False,
|
||||
access_token: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Process outbound emails in the outbox queue.
|
||||
|
||||
Tries Graph API first, falls back to SMTP OAuth2 if Graph API fails
|
||||
(e.g., when Mail.Send scope is not available but SMTP.Send is).
|
||||
|
||||
Args:
|
||||
maildir_path: Base maildir path
|
||||
org: Organization name
|
||||
@@ -1055,6 +1242,7 @@ async def process_outbox_async(
|
||||
progress: Progress instance for updating progress bars
|
||||
task_id: ID of the task in the progress bar
|
||||
dry_run: If True, don't actually send emails
|
||||
access_token: OAuth2 access token for SMTP fallback
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_sends, failed_sends)
|
||||
@@ -1094,8 +1282,59 @@ async def process_outbox_async(
|
||||
with open(email_path, "r", encoding="utf-8") as f:
|
||||
email_content = f.read()
|
||||
|
||||
# Send email
|
||||
if await send_email_async(email_content, headers, dry_run):
|
||||
# Parse email to get from address for SMTP fallback
|
||||
parser = Parser()
|
||||
msg = parser.parsestr(email_content)
|
||||
from_header = msg.get("From", "")
|
||||
subject = msg.get("Subject", "Unknown")
|
||||
# Extract email from "Name <email@domain.com>" format
|
||||
from email.utils import parseaddr
|
||||
|
||||
_, from_email = parseaddr(from_header)
|
||||
|
||||
# Try Graph API first (will fail without Mail.Send scope)
|
||||
send_success = await send_email_async(email_content, headers, dry_run)
|
||||
|
||||
# If Graph API failed, check config for SMTP fallback
|
||||
if not send_success and from_email and not dry_run:
|
||||
import logging
|
||||
from src.mail.config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
if config.mail.enable_smtp_send:
|
||||
# SMTP sending is enabled in config
|
||||
from src.services.microsoft_graph.auth import get_smtp_access_token
|
||||
|
||||
logging.info(
|
||||
f"Graph API send failed, trying SMTP fallback for: {email_file}"
|
||||
)
|
||||
progress.console.print(f" Graph API failed, trying SMTP...")
|
||||
|
||||
# Get SMTP-specific token (different resource than Graph API)
|
||||
# Use silent_only=True to avoid blocking the TUI with auth prompts
|
||||
smtp_token = get_smtp_access_token(silent_only=True)
|
||||
if smtp_token:
|
||||
send_success = await send_email_smtp_async(
|
||||
email_content, smtp_token, from_email, dry_run
|
||||
)
|
||||
if send_success:
|
||||
logging.info(f"SMTP fallback succeeded for: {email_file}")
|
||||
else:
|
||||
logging.error("Failed to get SMTP access token")
|
||||
else:
|
||||
# SMTP disabled - open email in default mail client
|
||||
logging.info(
|
||||
f"Graph API send failed, opening in mail client: {email_file}"
|
||||
)
|
||||
progress.console.print(f" Opening in mail client...")
|
||||
|
||||
if await open_email_in_client_async(email_path, subject):
|
||||
# Mark as handled (move to cur) since user will send manually
|
||||
send_success = True
|
||||
logging.info(f"Opened email in mail client: {email_file}")
|
||||
|
||||
if send_success:
|
||||
# Move to cur directory on success
|
||||
if not dry_run:
|
||||
cur_path = os.path.join(cur_dir, email_file)
|
||||
@@ -1114,14 +1353,13 @@ async def process_outbox_async(
|
||||
# Log the failure
|
||||
import logging
|
||||
|
||||
logging.error(f"Failed to send email: {email_file}")
|
||||
logging.error(
|
||||
f"Failed to send email via Graph API and SMTP: {email_file}"
|
||||
)
|
||||
|
||||
# Send notification about failure
|
||||
from src.utils.notifications import send_notification
|
||||
|
||||
parser = Parser()
|
||||
msg = parser.parsestr(email_content)
|
||||
subject = msg.get("Subject", "Unknown")
|
||||
send_notification(
|
||||
title="Email Send Failed",
|
||||
message=f"Failed to send: {subject}",
|
||||
|
||||
15
uv.lock
generated
15
uv.lock
generated
@@ -643,6 +643,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "6.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422, upload-time = "2025-11-05T12:49:32.286Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403, upload-time = "2025-11-05T12:49:30.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id"
|
||||
version = "1.5.0"
|
||||
@@ -858,6 +871,7 @@ dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "click" },
|
||||
{ name = "html2text" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "mammoth" },
|
||||
{ name = "markitdown", extra = ["all"] },
|
||||
{ name = "msal" },
|
||||
@@ -896,6 +910,7 @@ requires-dist = [
|
||||
{ name = "certifi", specifier = ">=2025.4.26" },
|
||||
{ name = "click", specifier = ">=8.1.0" },
|
||||
{ name = "html2text", specifier = ">=2025.4.15" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0" },
|
||||
{ name = "mammoth", specifier = ">=1.9.0" },
|
||||
{ name = "markitdown", extras = ["all"], specifier = ">=0.1.1" },
|
||||
{ name = "msal", specifier = ">=1.32.3" },
|
||||
|
||||
Reference in New Issue
Block a user