agentic.email/backend/app/services/mail_sender.py
2026-10-06 07:53:23 +05:30

266 lines
8.7 KiB
Python

import email.policy
import email.utils
import logging
from email.parser import BytesParser
from email.message import EmailMessage, Message
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
import dkim
from app.config import get_settings
from app.models import MailDomain
from app.services.mime_body_extractor import ExtractedMimePart
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
logger = logging.getLogger(__name__)
SYSTEM_SENDER_LOCALS = {
"invite",
"invites",
"notification",
"notifications",
"no-reply",
"noreply",
"support",
}
def _sender_identity(sender: str, sender_name: str | None = None) -> tuple[str, str]:
"""Return an RFC-compliant From header and the bare SMTP envelope address."""
if "\r" in sender or "\n" in sender or (sender_name and ("\r" in sender_name or "\n" in sender_name)):
raise ValueError("Sender identity cannot contain line breaks.")
existing_name, address = email.utils.parseaddr(sender)
address = address.strip().lower()
if not address or "@" not in address:
raise ValueError("A valid sender email address is required.")
display_name = (sender_name or existing_name).strip()
if not display_name:
local_part = address.split("@", 1)[0]
if local_part in SYSTEM_SENDER_LOCALS:
display_name = "aGentic Email"
else:
display_name = local_part.replace(".", " ").replace("_", " ").replace("-", " ").title()
return email.utils.formataddr((display_name, address), charset="utf-8"), address
def log_outbound_mime_headers(message: Message) -> None:
logger.info(
"smtp_outbound_mime_headers message_id=%s in_reply_to=%s references=%s "
"from=%s to=%s subject=%s",
message.get("Message-ID"),
message.get("In-Reply-To"),
message.get("References"),
message.get("From"),
message.get("To"),
message.get("Subject"),
)
def log_signed_mime_headers(message_bytes: bytes) -> None:
"""Log threading headers from the final wire-format MIME (post-DKIM)."""
parsed = BytesParser(policy=SMTP_POLICY).parsebytes(message_bytes)
logger.info(
"smtp_signed_mime_headers message_id=%s in_reply_to=%s references=%s "
"from=%s to=%s subject=%s",
parsed.get("Message-ID"),
parsed.get("In-Reply-To"),
parsed.get("References"),
parsed.get("From"),
parsed.get("To"),
parsed.get("Subject"),
)
def _mime_attachment(part: ExtractedMimePart) -> MIMEBase:
maintype, _, subtype = part.content_type.partition("/")
if not subtype:
maintype, subtype = "application", "octet-stream"
mime = MIMEBase(maintype, subtype, policy=SMTP_POLICY)
mime.set_payload(part.payload)
email.utils.encode_base64(mime)
mime.add_header("Content-Disposition", part.disposition, filename=part.filename)
if part.content_id:
mime.add_header("Content-ID", f"<{part.content_id}>")
return mime
def build_message(
*,
sender: str,
sender_name: str | None = None,
recipients: list[str],
cc: list[str],
subject: str,
body_text: str,
body_html: str | None,
reply_to: str | None = None,
in_reply_to: str | None = None,
references: list[str] | None = None,
message_id: str | None = None,
inline_parts: list[ExtractedMimePart] | None = None,
file_attachments: list[ExtractedMimePart] | None = None,
) -> Message:
inline_parts = inline_parts or []
file_attachments = file_attachments or []
sender_header, sender_address = _sender_identity(sender, sender_name)
if inline_parts or file_attachments:
root = MIMEMultipart("mixed", policy=SMTP_POLICY)
root["From"] = sender_header
root["To"] = ", ".join(recipients)
if cc:
root["Cc"] = ", ".join(cc)
if reply_to:
root["Reply-To"] = reply_to
root["Subject"] = subject
root["Date"] = email.utils.formatdate(localtime=True)
generated_id = message_id or email.utils.make_msgid(domain=sender_address.split("@")[-1])
root["Message-ID"] = generated_id
if in_reply_to:
root["In-Reply-To"] = in_reply_to
refs = references or [in_reply_to]
root["References"] = " ".join(refs)
elif references:
root["References"] = " ".join(references)
alternative = MIMEMultipart("alternative", policy=SMTP_POLICY)
alternative.attach(MIMEText(body_text, "plain", "utf-8", policy=SMTP_POLICY))
if body_html:
if inline_parts:
related = MIMEMultipart("related", policy=SMTP_POLICY)
related.attach(MIMEText(body_html, "html", "utf-8", policy=SMTP_POLICY))
for part in inline_parts:
related.attach(_mime_attachment(part))
alternative.attach(related)
else:
alternative.attach(MIMEText(body_html, "html", "utf-8", policy=SMTP_POLICY))
root.attach(alternative)
for attachment in file_attachments:
root.attach(_mime_attachment(attachment))
return root
message = EmailMessage(policy=SMTP_POLICY)
message["From"] = sender_header
message["To"] = ", ".join(recipients)
if cc:
message["Cc"] = ", ".join(cc)
if reply_to:
message["Reply-To"] = reply_to
message["Subject"] = subject
message["Date"] = email.utils.formatdate(localtime=True)
generated_id = message_id or email.utils.make_msgid(domain=sender_address.split("@")[-1])
message["Message-ID"] = generated_id
if in_reply_to:
message["In-Reply-To"] = in_reply_to
refs = references or [in_reply_to]
message["References"] = " ".join(refs)
elif references:
message["References"] = " ".join(references)
message.set_content(body_text)
if body_html:
message.add_alternative(body_html, subtype="html")
return message
def sign_message(message: Message, domain: MailDomain) -> bytes:
raw_message = message.as_bytes(policy=SMTP_POLICY)
signed_headers = [
b"from",
b"to",
b"subject",
b"date",
b"message-id",
b"mime-version",
b"content-type",
]
if message.get("Cc"):
signed_headers.insert(3, b"cc")
if message.get("In-Reply-To"):
signed_headers.append(b"in-reply-to")
if message.get("References"):
signed_headers.append(b"references")
signature = dkim.sign(
raw_message,
domain.dkim_selector.encode(),
domain.domain_name.encode(),
domain.dkim_private_key.encode(),
include_headers=signed_headers,
canonicalize=(b"relaxed", b"relaxed"),
)
if not signature.endswith(b"\r\n"):
signature += b"\r\n"
return signature + raw_message
async def send_via_smtp(
message_bytes: bytes,
*,
sender: str,
recipients: list[str],
) -> None:
settings = get_settings()
await aiosmtplib.send(
message_bytes,
sender=sender,
recipients=recipients,
hostname=settings.smtp_host,
port=settings.smtp_port,
local_hostname=settings.smtp_client_hostname,
username=settings.smtp_username or None,
password=settings.smtp_password or None,
start_tls=settings.smtp_use_tls,
timeout=settings.smtp_timeout,
)
async def deliver_email(
*,
sender: str,
sender_name: str | None = None,
recipients: list[str],
cc: list[str],
bcc: list[str],
subject: str,
body_text: str,
body_html: str | None,
domain: MailDomain,
reply_to: str | None = None,
in_reply_to: str | None = None,
references: list[str] | None = None,
message_id: str | None = None,
inline_parts: list[ExtractedMimePart] | None = None,
file_attachments: list[ExtractedMimePart] | None = None,
envelope_sender: str | None = None,
) -> str:
_, sender_address = _sender_identity(sender, sender_name)
message = build_message(
sender=sender,
sender_name=sender_name,
recipients=recipients,
cc=cc,
subject=subject,
body_text=body_text,
body_html=body_html,
reply_to=reply_to,
in_reply_to=in_reply_to,
references=references,
message_id=message_id,
inline_parts=inline_parts,
file_attachments=file_attachments,
)
log_outbound_mime_headers(message)
signed = sign_message(message, domain)
log_signed_mime_headers(signed)
await send_via_smtp(
signed,
sender=envelope_sender or sender_address,
recipients=[*recipients, *cc, *bcc],
)
return str(message["Message-ID"])