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:
Bendt
2026-01-02 12:11:44 -05:00
parent efe417b41a
commit b52a06f2cf
11 changed files with 926 additions and 13 deletions

View File

@@ -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,

View File

@@ -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:

View File

@@ -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
View 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")

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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,
)

View 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)

View File

@@ -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

View File

@@ -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
View File

@@ -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" },