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

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