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__) 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, 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 [] if inline_parts or file_attachments: root = MIMEMultipart("mixed", policy=SMTP_POLICY) root["From"] = sender 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.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 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.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, 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, ) -> str: message = build_message( sender=sender, 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=sender, recipients=[*recipients, *cc, *bcc], ) return str(message["Message-ID"])