From b52a06f2cf4e3b356d86d801aa99383fb6bae7f4 Mon Sep 17 00:00:00 2001 From: Bendt Date: Fri, 2 Jan 2026 12:11:44 -0500 Subject: [PATCH] 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 --- src/cli/sync.py | 50 +++++- src/cli/sync_dashboard.py | 8 +- src/mail/actions/calendar_invite.py | 77 +++++++- src/mail/actions/compose.py | 170 ++++++++++++++++++ src/mail/app.py | 21 +++ src/mail/config.py | 9 + src/mail/utils/__init__.py | 9 +- src/mail/utils/apple_mail.py | 255 +++++++++++++++++++++++++++ src/services/microsoft_graph/auth.py | 75 ++++++++ src/services/microsoft_graph/mail.py | 250 +++++++++++++++++++++++++- uv.lock | 15 ++ 11 files changed, 926 insertions(+), 13 deletions(-) create mode 100644 src/mail/actions/compose.py create mode 100644 src/mail/utils/apple_mail.py diff --git a/src/cli/sync.py b/src/cli/sync.py index 5222787..cadc9e9 100644 --- a/src/cli/sync.py +++ b/src/cli/sync.py @@ -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, diff --git a/src/cli/sync_dashboard.py b/src/cli/sync_dashboard.py index 94d0a4c..a4e960d 100644 --- a/src/cli/sync_dashboard.py +++ b/src/cli/sync_dashboard.py @@ -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: diff --git a/src/mail/actions/calendar_invite.py b/src/mail/actions/calendar_invite.py index b7f093e..497a3e6 100644 --- a/src/mail/actions/calendar_invite.py +++ b/src/mail/actions/calendar_invite.py @@ -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" diff --git a/src/mail/actions/compose.py b/src/mail/actions/compose.py new file mode 100644 index 0000000..ab2f3d3 --- /dev/null +++ b/src/mail/actions/compose.py @@ -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") diff --git a/src/mail/app.py b/src/mail/app.py index fe0f8a2..9b60c4c 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -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) diff --git a/src/mail/config.py b/src/mail/config.py index 8ebd6a0..52a0133 100644 --- a/src/mail/config.py +++ b/src/mail/config.py @@ -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.""" diff --git a/src/mail/utils/__init__.py b/src/mail/utils/__init__.py index 844da06..891bc9c 100644 --- a/src/mail/utils/__init__.py +++ b/src/mail/utils/__init__.py @@ -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, +) diff --git a/src/mail/utils/apple_mail.py b/src/mail/utils/apple_mail.py new file mode 100644 index 0000000..3708dd1 --- /dev/null +++ b/src/mail/utils/apple_mail.py @@ -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) diff --git a/src/services/microsoft_graph/auth.py b/src/services/microsoft_graph/auth.py index af82009..d3f69ff 100644 --- a/src/services/microsoft_graph/auth.py +++ b/src/services/microsoft_graph/auth.py @@ -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 diff --git a/src/services/microsoft_graph/mail.py b/src/services/microsoft_graph/mail.py index 6e9953f..1e78aee 100644 --- a/src/services/microsoft_graph/mail.py +++ b/src/services/microsoft_graph/mail.py @@ -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 " 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}", diff --git a/uv.lock b/uv.lock index 83262fc..a90f559 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },