aerc sendmail wip

This commit is contained in:
Tim Bendt
2025-08-11 09:44:47 -05:00
parent 5eddddc8ec
commit c64fbbb072
6 changed files with 561 additions and 13 deletions

View File

@@ -18,6 +18,7 @@ from src.services.microsoft_graph.mail import (
archive_mail_async,
delete_mail_async,
synchronize_maildir_async,
process_outbox_async,
)
from src.services.microsoft_graph.auth import get_access_token
@@ -38,6 +39,11 @@ def create_maildir_structure(base_path):
ensure_directory_exists(os.path.join(base_path, "tmp"))
ensure_directory_exists(os.path.join(base_path, ".Archives"))
ensure_directory_exists(os.path.join(base_path, ".Trash", "cur"))
# Create outbox structure for sending emails
ensure_directory_exists(os.path.join(base_path, "outbox", "new"))
ensure_directory_exists(os.path.join(base_path, "outbox", "cur"))
ensure_directory_exists(os.path.join(base_path, "outbox", "tmp"))
ensure_directory_exists(os.path.join(base_path, "outbox", "failed"))
async def fetch_calendar_async(
@@ -228,7 +234,8 @@ async def _sync_outlook_data(
vdir = os.path.expanduser(vdir)
# Save emails to Maildir
maildir_path = os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail")) + f"/{org}"
base_maildir_path = os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail"))
maildir_path = base_maildir_path + f"/{org}"
attachments_dir = os.path.join(maildir_path, "attachments")
ensure_directory_exists(attachments_dir)
create_maildir_structure(maildir_path)
@@ -256,6 +263,9 @@ async def _sync_outlook_data(
task_read = progress.add_task("[blue]Marking as read...", total=0)
task_archive = progress.add_task("[yellow]Archiving mail...", total=0)
task_delete = progress.add_task("[red]Deleting mail...", total=0)
task_outbox = progress.add_task(
"[bright_green]Sending outbound mail...", total=0
)
# Stage 1: Synchronize local changes (read, archive, delete, calendar) to the server
progress.console.print(
@@ -273,13 +283,16 @@ async def _sync_outlook_data(
headers, org_vdir_path, progress, task_local_calendar, dry_run
)
# Handle mail changes in parallel
# Handle mail changes and outbound email in parallel
await asyncio.gather(
synchronize_maildir_async(
maildir_path, headers, progress, task_read, dry_run
),
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
),
)
progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]")
@@ -628,14 +641,50 @@ async def daemon_mode(
vdir, org
)
# Check for outbound emails in outbox
base_maildir_path = os.getenv(
"MAILDIR_PATH", os.path.expanduser("~/Mail")
)
outbox_new_dir = os.path.join(base_maildir_path, org, "outbox", "new")
outbox_changes = False
pending_email_count = 0
if os.path.exists(outbox_new_dir):
pending_emails = [
f for f in os.listdir(outbox_new_dir) if not f.startswith(".")
]
pending_email_count = len(pending_emails)
outbox_changes = pending_email_count > 0
# Determine what changed and show appropriate status
if mail_changes and calendar_changes:
if mail_changes and calendar_changes and outbox_changes:
console.print(
create_status_display(
f"Changes detected! Mail: Remote {remote_message_count}, Local {local_message_count} | Calendar: {calendar_change_desc} | Outbox: {pending_email_count} pending. Starting sync...",
"yellow",
)
)
elif mail_changes and calendar_changes:
console.print(
create_status_display(
f"Changes detected! Mail: Remote {remote_message_count}, Local {local_message_count} | Calendar: {calendar_change_desc}. Starting sync...",
"yellow",
)
)
elif mail_changes and outbox_changes:
console.print(
create_status_display(
f"Changes detected! Mail: Remote {remote_message_count}, Local {local_message_count} | Outbox: {pending_email_count} pending. Starting sync...",
"yellow",
)
)
elif calendar_changes and outbox_changes:
console.print(
create_status_display(
f"Changes detected! Calendar: {calendar_change_desc} | Outbox: {pending_email_count} pending. Starting sync...",
"yellow",
)
)
elif mail_changes:
console.print(
create_status_display(
@@ -650,9 +699,16 @@ async def daemon_mode(
"yellow",
)
)
elif outbox_changes:
console.print(
create_status_display(
f"Outbound emails detected! {pending_email_count} emails pending. Starting sync...",
"yellow",
)
)
# Sync if any changes detected
if mail_changes or calendar_changes:
if mail_changes or calendar_changes or outbox_changes:
await _sync_outlook_data(
dry_run,
vdir,
@@ -674,6 +730,8 @@ async def daemon_mode(
if two_way_calendar:
status_parts.append(f"Calendar: {calendar_change_desc}")
status_parts.append(f"Outbox: {pending_email_count} pending")
console.print(
create_status_display(
f"No changes detected ({', '.join(status_parts)})",

View File

@@ -81,8 +81,13 @@ def get_access_token(scopes):
token_response = app.acquire_token_by_device_flow(flow)
if token_response is None:
raise Exception("Token response is None - authentication failed")
if "access_token" not in token_response:
raise Exception("Failed to acquire token")
error_description = token_response.get("error_description", "Unknown error")
error_code = token_response.get("error", "unknown_error")
raise Exception(f"Failed to acquire token - {error_code}: {error_description}")
# Save token cache
with open(cache_file, "w") as f:

View File

@@ -6,8 +6,9 @@ import os
import re
import glob
import asyncio
from typing import Set
import aiohttp
from email.parser import Parser
from email.utils import getaddresses
from typing import List, Dict, Any
from .client import (
fetch_with_aiohttp,
@@ -43,7 +44,6 @@ async def fetch_mail_async(
None
"""
from src.utils.mail_utils.maildir import save_mime_to_maildir_async
from src.utils.mail_utils.helpers import truncate_id
mail_url = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?$top=100&$orderby=receivedDateTime asc&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead"
messages = []
@@ -559,3 +559,276 @@ async def synchronize_maildir_async(
)
else:
progress.console.print("[DRY-RUN] Would save sync timestamp.")
def parse_email_for_graph_api(email_content: str) -> Dict[str, Any]:
"""
Parse email content and convert to Microsoft Graph API message format.
Args:
email_content: Raw email content (RFC 5322 format)
Returns:
Dictionary formatted for Microsoft Graph API send message
"""
parser = Parser()
msg = parser.parsestr(email_content)
# Parse recipients
def parse_recipients(header_value: str) -> List[Dict[str, Any]]:
if not header_value:
return []
addresses = getaddresses([header_value])
return [
{"emailAddress": {"address": addr, "name": name if name else addr}}
for name, addr in addresses
if addr
]
to_recipients = parse_recipients(msg.get("To", ""))
cc_recipients = parse_recipients(msg.get("Cc", ""))
bcc_recipients = parse_recipients(msg.get("Bcc", ""))
# Get body content
body_content = ""
body_type = "text"
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body_content = part.get_payload(decode=True).decode(
"utf-8", errors="ignore"
)
body_type = "text"
break
elif part.get_content_type() == "text/html":
body_content = part.get_payload(decode=True).decode(
"utf-8", errors="ignore"
)
body_type = "html"
else:
body_content = msg.get_payload(decode=True).decode("utf-8", errors="ignore")
if msg.get_content_type() == "text/html":
body_type = "html"
# Build Graph API message
message = {
"subject": msg.get("Subject", ""),
"body": {"contentType": body_type, "content": body_content},
"toRecipients": to_recipients,
"ccRecipients": cc_recipients,
"bccRecipients": bcc_recipients,
}
# Add reply-to if present
reply_to = msg.get("Reply-To", "")
if reply_to:
message["replyTo"] = parse_recipients(reply_to)
return message
async def send_email_async(
email_content: str, headers: Dict[str, str], dry_run: bool = False
) -> bool:
"""
Send email using Microsoft Graph API.
Args:
email_content: Raw email content (RFC 5322 format)
headers: Authentication headers for Microsoft Graph API
dry_run: If True, don't actually send the email
Returns:
True if email was sent successfully, False otherwise
"""
try:
# Parse email content for Graph API
message_data = parse_email_for_graph_api(email_content)
if dry_run:
print(f"[DRY-RUN] Would send email: {message_data['subject']}")
print(
f"[DRY-RUN] To: {[r['emailAddress']['address'] for r in message_data['toRecipients']]}"
)
return True
# Send email via Graph API
send_url = "https://graph.microsoft.com/v1.0/me/sendMail"
# Log attempt
import logging
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"
),
],
)
logging.info(
f"Attempting to send email: {message_data['subject']} to {[r['emailAddress']['address'] for r in message_data['toRecipients']]}"
)
response = await post_with_aiohttp(send_url, headers, {"message": message_data})
# Microsoft Graph sendMail returns 202 Accepted on success
if response == 202:
logging.info(f"Successfully sent email: {message_data['subject']}")
return True
else:
logging.error(
f"Unexpected response code {response} when sending email: {message_data['subject']}"
)
return False
except Exception as e:
import logging
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"
),
],
)
logging.error(f"Exception sending email: {e}", exc_info=True)
print(f"Error sending email: {e}")
return False
async def process_outbox_async(
maildir_path: str,
org: str,
headers: Dict[str, str],
progress,
task_id,
dry_run: bool = False,
) -> tuple[int, int]:
"""
Process outbound emails in the outbox queue.
Args:
maildir_path: Base maildir path
org: Organization name
headers: Authentication headers for Microsoft Graph API
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
Returns:
Tuple of (successful_sends, failed_sends)
"""
outbox_path = os.path.join(maildir_path, org, "outbox")
new_dir = os.path.join(outbox_path, "new")
cur_dir = os.path.join(outbox_path, "cur")
failed_dir = os.path.join(outbox_path, "failed")
# Ensure directories exist
from src.utils.mail_utils.helpers import ensure_directory_exists
ensure_directory_exists(failed_dir)
# Get pending emails
pending_emails = []
if os.path.exists(new_dir):
pending_emails = [f for f in os.listdir(new_dir) if not f.startswith(".")]
if not pending_emails:
progress.update(task_id, total=0, completed=0)
return 0, 0
progress.update(task_id, total=len(pending_emails))
progress.console.print(
f"Processing {len(pending_emails)} outbound emails for {org}"
)
successful_sends = 0
failed_sends = 0
for email_file in pending_emails:
email_path = os.path.join(new_dir, email_file)
try:
# Read email content
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):
# Move to cur directory on success
if not dry_run:
cur_path = os.path.join(cur_dir, email_file)
os.rename(email_path, cur_path)
progress.console.print(f"✓ Sent email: {email_file}")
else:
progress.console.print(f"[DRY-RUN] Would send email: {email_file}")
successful_sends += 1
else:
# Move to failed directory on failure
if not dry_run:
failed_path = os.path.join(failed_dir, email_file)
os.rename(email_path, failed_path)
progress.console.print(f"✗ Failed to send email: {email_file}")
# Log the failure
import logging
logging.error(f"Failed to send email: {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}",
subtitle=f"Check {failed_dir}",
sound="default",
)
failed_sends += 1
except Exception as e:
progress.console.print(f"✗ Error processing {email_file}: {e}")
if not dry_run:
# Move to failed directory
failed_path = os.path.join(failed_dir, email_file)
try:
os.rename(email_path, failed_path)
except (OSError, FileNotFoundError):
pass # File might already be moved or deleted
failed_sends += 1
progress.advance(task_id, 1)
if not dry_run and successful_sends > 0:
progress.console.print(f"✓ Successfully sent {successful_sends} emails")
# Send success notification
from src.utils.notifications import send_notification
if successful_sends == 1:
send_notification(
title="Email Sent",
message="1 email sent successfully",
subtitle=f"from {org}",
sound="default",
)
else:
send_notification(
title="Emails Sent",
message=f"{successful_sends} emails sent successfully",
subtitle=f"from {org}",
sound="default",
)
if failed_sends > 0:
progress.console.print(f"✗ Failed to send {failed_sends} emails")
return successful_sends, failed_sends