aerc sendmail wip
This commit is contained in:
@@ -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)})",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user