230 lines
7.2 KiB
Python
230 lines
7.2 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__)
|
|
|
|
|
|
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"])
|