From c64fbbb07264e68da665c5418f541f3e82bd5516 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Mon, 11 Aug 2025 09:44:47 -0500 Subject: [PATCH] aerc sendmail wip --- sendmail | 152 +++++++++++++++ shell/email_processor.awk | 10 +- src/cli/sync.py | 66 ++++++- src/services/microsoft_graph/auth.py | 7 +- src/services/microsoft_graph/mail.py | 279 ++++++++++++++++++++++++++- test_aerc_integration.sh | 60 ++++++ 6 files changed, 561 insertions(+), 13 deletions(-) create mode 100755 sendmail create mode 100755 test_aerc_integration.sh diff --git a/sendmail b/sendmail new file mode 100755 index 0000000..1327880 --- /dev/null +++ b/sendmail @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +Sendmail-compatible wrapper for Microsoft Graph email sending. +Queues emails in maildir format for processing by the sync daemon. +""" + +import sys +import os +import time +import logging +from email.parser import Parser +from email.utils import parseaddr + +# Add the project root to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from src.utils.mail_utils.helpers import ensure_directory_exists + + +def extract_org_from_email(email_address: str) -> str: + """ + Extract organization name from email address domain. + + Args: + email_address: Email address like "user@corteva.com" + + Returns: + Organization name (e.g., "corteva") + """ + if "@" not in email_address: + return "default" + + domain = email_address.split("@")[1].lower() + + # Map known domains to org names + domain_to_org = { + "corteva.com": "corteva", + # Add more domain mappings as needed + } + + return domain_to_org.get(domain, domain.split(".")[0]) + + +def create_outbox_structure(base_path: str, org: str): + """ + Create maildir structure for outbox. + + Args: + base_path: Base maildir path (e.g., ~/Mail) + org: Organization name + """ + org_path = os.path.join(base_path, org, "outbox") + ensure_directory_exists(os.path.join(org_path, "new")) + ensure_directory_exists(os.path.join(org_path, "cur")) + ensure_directory_exists(os.path.join(org_path, "tmp")) + ensure_directory_exists(os.path.join(org_path, "failed")) + + +def queue_email(email_content: str, org: str) -> bool: + """ + Queue email in maildir outbox for sending. + + Args: + email_content: Raw email content + org: Organization name + + Returns: + True if queued successfully, False otherwise + """ + try: + # Get base maildir path + base_path = os.path.expanduser(os.getenv("MAILDIR_PATH", "~/Mail")) + + # Create outbox structure + create_outbox_structure(base_path, org) + + # Generate unique filename + timestamp = str(int(time.time() * 1000000)) + hostname = os.uname().nodename + filename = f"{timestamp}.{os.getpid()}.{hostname}" + + # Write to tmp first, then move to new (atomic operation) + tmp_path = os.path.join(base_path, org, "outbox", "tmp", filename) + new_path = os.path.join(base_path, org, "outbox", "new", filename) + + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(email_content) + + os.rename(tmp_path, new_path) + + return True + + except Exception as e: + logging.error(f"Failed to queue email: {e}") + return False + + +def main(): + """ + Main sendmail wrapper function. + Reads email from stdin and queues it for sending. + """ + # Set up basic logging + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler(os.path.expanduser("~/Mail/sendmail.log")), + ] + ) + + try: + # Read email from stdin + email_content = sys.stdin.read() + + if not email_content.strip(): + logging.error("No email content received") + sys.exit(1) + + # Parse email to extract From header + parser = Parser() + msg = parser.parsestr(email_content) + + from_header = msg.get("From", "") + if not from_header: + logging.error("No From header found in email") + sys.exit(1) + + # Extract email address from From header + _, from_email = parseaddr(from_header) + if not from_email: + logging.error(f"Could not parse email address from From header: {from_header}") + sys.exit(1) + + # Determine organization from email domain + org = extract_org_from_email(from_email) + + # Queue the email + if queue_email(email_content, org): + logging.info(f"Email queued successfully for org: {org}, from: {from_email}") + sys.exit(0) + else: + logging.error("Failed to queue email") + sys.exit(1) + + except Exception as e: + logging.error(f"Sendmail wrapper error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/shell/email_processor.awk b/shell/email_processor.awk index 94496bf..6f12d7e 100755 --- a/shell/email_processor.awk +++ b/shell/email_processor.awk @@ -26,13 +26,13 @@ gsub(/mailto:[^[:space:]]*/, "") # Clean up email headers - make them bold - if (/^From:/) { gsub(/^From:[[:space:]]*/, "**From:** ") } - if (/^To:/) { gsub(/^To:[[:space:]]*/, "**To:** ") } - if (/^Subject:/) { gsub(/^Subject:[[:space:]]*/, "**Subject:** ") } - if (/^Date:/) { gsub(/^Date:[[:space:]]*/, "**Date:** ") } + # if (/^From:/) { gsub(/^From:[[:space:]]*/, "**From:** ") } + # if (/^To:/) { gsub(/^To:[[:space:]]*/, "**To:** ") } + # if (/^Subject:/) { gsub(/^Subject:[[:space:]]*/, "**Subject:** ") } + # if (/^Date:/) { gsub(/^Date:[[:space:]]*/, "**Date:** ") } # Skip empty lines if (/^[[:space:]]*$/) next print -} \ No newline at end of file +} diff --git a/src/cli/sync.py b/src/cli/sync.py index 78b9fb1..714d981 100644 --- a/src/cli/sync.py +++ b/src/cli/sync.py @@ -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)})", diff --git a/src/services/microsoft_graph/auth.py b/src/services/microsoft_graph/auth.py index 98bee93..86866a1 100644 --- a/src/services/microsoft_graph/auth.py +++ b/src/services/microsoft_graph/auth.py @@ -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: diff --git a/src/services/microsoft_graph/mail.py b/src/services/microsoft_graph/mail.py index bc14ff2..a00a9d4 100644 --- a/src/services/microsoft_graph/mail.py +++ b/src/services/microsoft_graph/mail.py @@ -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 diff --git a/test_aerc_integration.sh b/test_aerc_integration.sh new file mode 100755 index 0000000..40213ee --- /dev/null +++ b/test_aerc_integration.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# Test script to demonstrate aerc integration with the sendmail wrapper +# This shows how aerc would interact with our email sending system + +echo "=== Testing Email Sending Daemon ===" +echo + +# Get the full path to our sendmail wrapper +SENDMAIL_PATH="$(pwd)/sendmail" +echo "Sendmail wrapper: $SENDMAIL_PATH" +echo + +# Show current queue status +echo "Current outbox queue:" +find ~/Mail/*/outbox/new -type f 2>/dev/null | wc -l | xargs echo "Pending emails:" +echo + +# Create a test email that aerc might send +echo "Creating test email as aerc would..." +cat << 'EOF' | $SENDMAIL_PATH +From: user@corteva.com +To: colleague@example.com +Cc: team@example.com +Subject: Project Update from aerc + +Hi team, + +This email was composed in aerc using helix editor and queued +for sending through our Microsoft Graph adapter. + +The email will be sent when the sync daemon processes the outbox. + +Best regards, +User +EOF + +echo "Email queued successfully!" +echo + +# Show updated queue status +echo "Updated outbox queue:" +find ~/Mail/*/outbox/new -type f 2>/dev/null | wc -l | xargs echo "Pending emails:" +echo + +echo "To process the queue, run:" +echo " python3 -m src.cli sync --daemon --notify" +echo +echo "Or for a one-time sync:" +echo " python3 -m src.cli sync --dry-run" +echo + +echo "=== aerc Configuration ===" +echo "Add this to your aerc config:" +echo +echo "[outgoing]" +echo "sendmail = $SENDMAIL_PATH" +echo +echo "Then compose emails in aerc as usual - they'll be queued offline" +echo "and sent when you run the sync daemon." \ No newline at end of file