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"])