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

152
sendmail Executable file
View File

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

View File

@@ -26,13 +26,13 @@
gsub(/mailto:[^[:space:]]*/, "") gsub(/mailto:[^[:space:]]*/, "")
# Clean up email headers - make them bold # Clean up email headers - make them bold
if (/^From:/) { gsub(/^From:[[:space:]]*/, "**From:** ") } # if (/^From:/) { gsub(/^From:[[:space:]]*/, "**From:** ") }
if (/^To:/) { gsub(/^To:[[:space:]]*/, "**To:** ") } # if (/^To:/) { gsub(/^To:[[:space:]]*/, "**To:** ") }
if (/^Subject:/) { gsub(/^Subject:[[:space:]]*/, "**Subject:** ") } # if (/^Subject:/) { gsub(/^Subject:[[:space:]]*/, "**Subject:** ") }
if (/^Date:/) { gsub(/^Date:[[:space:]]*/, "**Date:** ") } # if (/^Date:/) { gsub(/^Date:[[:space:]]*/, "**Date:** ") }
# Skip empty lines # Skip empty lines
if (/^[[:space:]]*$/) next if (/^[[:space:]]*$/) next
print print
} }

View File

@@ -18,6 +18,7 @@ from src.services.microsoft_graph.mail import (
archive_mail_async, archive_mail_async,
delete_mail_async, delete_mail_async,
synchronize_maildir_async, synchronize_maildir_async,
process_outbox_async,
) )
from src.services.microsoft_graph.auth import get_access_token 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, "tmp"))
ensure_directory_exists(os.path.join(base_path, ".Archives")) ensure_directory_exists(os.path.join(base_path, ".Archives"))
ensure_directory_exists(os.path.join(base_path, ".Trash", "cur")) 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( async def fetch_calendar_async(
@@ -228,7 +234,8 @@ async def _sync_outlook_data(
vdir = os.path.expanduser(vdir) vdir = os.path.expanduser(vdir)
# Save emails to Maildir # 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") attachments_dir = os.path.join(maildir_path, "attachments")
ensure_directory_exists(attachments_dir) ensure_directory_exists(attachments_dir)
create_maildir_structure(maildir_path) 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_read = progress.add_task("[blue]Marking as read...", total=0)
task_archive = progress.add_task("[yellow]Archiving mail...", total=0) task_archive = progress.add_task("[yellow]Archiving mail...", total=0)
task_delete = progress.add_task("[red]Deleting 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 # Stage 1: Synchronize local changes (read, archive, delete, calendar) to the server
progress.console.print( progress.console.print(
@@ -273,13 +283,16 @@ async def _sync_outlook_data(
headers, org_vdir_path, progress, task_local_calendar, dry_run 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( await asyncio.gather(
synchronize_maildir_async( synchronize_maildir_async(
maildir_path, headers, progress, task_read, dry_run maildir_path, headers, progress, task_read, dry_run
), ),
archive_mail_async(maildir_path, headers, progress, task_archive, dry_run), archive_mail_async(maildir_path, headers, progress, task_archive, dry_run),
delete_mail_async(maildir_path, headers, progress, task_delete, 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]") progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]")
@@ -628,14 +641,50 @@ async def daemon_mode(
vdir, org 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 # 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( console.print(
create_status_display( create_status_display(
f"Changes detected! Mail: Remote {remote_message_count}, Local {local_message_count} | Calendar: {calendar_change_desc}. Starting sync...", f"Changes detected! Mail: Remote {remote_message_count}, Local {local_message_count} | Calendar: {calendar_change_desc}. Starting sync...",
"yellow", "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: elif mail_changes:
console.print( console.print(
create_status_display( create_status_display(
@@ -650,9 +699,16 @@ async def daemon_mode(
"yellow", "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 # Sync if any changes detected
if mail_changes or calendar_changes: if mail_changes or calendar_changes or outbox_changes:
await _sync_outlook_data( await _sync_outlook_data(
dry_run, dry_run,
vdir, vdir,
@@ -674,6 +730,8 @@ async def daemon_mode(
if two_way_calendar: if two_way_calendar:
status_parts.append(f"Calendar: {calendar_change_desc}") status_parts.append(f"Calendar: {calendar_change_desc}")
status_parts.append(f"Outbox: {pending_email_count} pending")
console.print( console.print(
create_status_display( create_status_display(
f"No changes detected ({', '.join(status_parts)})", 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) 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: 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 # Save token cache
with open(cache_file, "w") as f: with open(cache_file, "w") as f:

View File

@@ -6,8 +6,9 @@ import os
import re import re
import glob import glob
import asyncio import asyncio
from typing import Set from email.parser import Parser
import aiohttp from email.utils import getaddresses
from typing import List, Dict, Any
from .client import ( from .client import (
fetch_with_aiohttp, fetch_with_aiohttp,
@@ -43,7 +44,6 @@ async def fetch_mail_async(
None None
""" """
from src.utils.mail_utils.maildir import save_mime_to_maildir_async 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" 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 = [] messages = []
@@ -559,3 +559,276 @@ async def synchronize_maildir_async(
) )
else: else:
progress.console.print("[DRY-RUN] Would save sync timestamp.") 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

60
test_aerc_integration.sh Executable file
View File

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