"""Envio de email via SMTP con smtplib — sin dependencias externas.""" import smtplib from dataclasses import dataclass, field from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email import encoders @dataclass(frozen=True) class EmailAttachmentPy: """Archivo adjunto a un email.""" filename: str content_type: str # MIME type, ej: "application/pdf" data: bytes @dataclass(frozen=True) class SMTPConfigPy: """Parametros de conexion SMTP. tls_mode puede ser: "tls" — TLS directo (port 465) "starttls" — upgrade STARTTLS (port 587) "" — sin cifrado (port 25) """ host: str port: int username: str = "" password: str = "" tls_mode: str = "starttls" # "tls", "starttls" o "" def smtp_send( cfg: SMTPConfigPy, from_addr: str, to: list[str], subject: str, body_html: str = "", body_text: str = "", cc: list[str] | None = None, bcc: list[str] | None = None, attachments: list[EmailAttachmentPy] | None = None, headers: dict[str, str] | None = None, ) -> None: """Conecta al servidor SMTP, envia el email y cierra la conexion. Construye el mensaje MIME segun el contenido: - Solo texto: text/plain - Solo HTML: text/html - Ambos: multipart/alternative - Con adjuntos: multipart/mixed Args: cfg: Configuracion SMTP (host, port, usuario, password, tls_mode). from_addr: Direccion del remitente. to: Lista de destinatarios principales. subject: Asunto del correo. body_html: Cuerpo HTML (opcional). body_text: Cuerpo texto plano (opcional). cc: Lista de destinatarios en copia (opcional). bcc: Lista de destinatarios en copia oculta (opcional). attachments: Lista de adjuntos EmailAttachmentPy (opcional). headers: Headers MIME adicionales como diccionario (opcional). Raises: RuntimeError: Si la conexion SMTP falla, la autenticacion es incorrecta o el envio no se puede completar. """ cc = cc or [] bcc = bcc or [] attachments = attachments or [] headers = headers or {} msg = _build_mime(from_addr, to, cc, subject, body_html, body_text, attachments, headers) all_recipients = list(to) + list(cc) + list(bcc) try: if cfg.tls_mode == "tls": smtp = smtplib.SMTP_SSL(cfg.host, cfg.port) else: smtp = smtplib.SMTP(cfg.host, cfg.port) with smtp: if cfg.tls_mode == "starttls": smtp.starttls() if cfg.username: smtp.login(cfg.username, cfg.password) smtp.sendmail(from_addr, all_recipients, msg.as_string()) except smtplib.SMTPException as e: raise RuntimeError(f"smtp_send: SMTP error: {e}") from e except OSError as e: raise RuntimeError(f"smtp_send: connection error to {cfg.host}:{cfg.port}: {e}") from e def _build_mime( from_addr: str, to: list[str], cc: list[str], subject: str, body_html: str, body_text: str, attachments: list[EmailAttachmentPy], headers: dict[str, str], ) -> MIMEMultipart | MIMEText: """Construye la estructura MIME del mensaje.""" has_text = bool(body_text) has_html = bool(body_html) has_atts = bool(attachments) if has_atts: outer = MIMEMultipart("mixed") _set_headers(outer, from_addr, to, cc, subject, headers) body_part = _build_body_part(body_html, body_text, has_html, has_text) outer.attach(body_part) for att in attachments: outer.attach(_build_attachment(att)) return outer if has_html and has_text: alt = MIMEMultipart("alternative") _set_headers(alt, from_addr, to, cc, subject, headers) alt.attach(MIMEText(body_text, "plain", "utf-8")) alt.attach(MIMEText(body_html, "html", "utf-8")) return alt if has_html: msg = MIMEText(body_html, "html", "utf-8") _set_headers(msg, from_addr, to, cc, subject, headers) return msg msg = MIMEText(body_text or "", "plain", "utf-8") _set_headers(msg, from_addr, to, cc, subject, headers) return msg def _build_body_part( body_html: str, body_text: str, has_html: bool, has_text: bool ) -> MIMEBase: if has_html and has_text: alt = MIMEMultipart("alternative") alt.attach(MIMEText(body_text, "plain", "utf-8")) alt.attach(MIMEText(body_html, "html", "utf-8")) return alt if has_html: return MIMEText(body_html, "html", "utf-8") return MIMEText(body_text, "plain", "utf-8") def _set_headers(msg: MIMEBase, from_addr: str, to: list[str], cc: list[str], subject: str, extra: dict[str, str]) -> None: msg["From"] = from_addr msg["To"] = ", ".join(to) if cc: msg["Cc"] = ", ".join(cc) msg["Subject"] = subject for k, v in extra.items(): msg[k] = v def _build_attachment(att: EmailAttachmentPy) -> MIMEBase: maintype, subtype = att.content_type.split("/", 1) if "/" in att.content_type else ("application", "octet-stream") part = MIMEBase(maintype, subtype) part.set_payload(att.data) encoders.encode_base64(part) part.add_header("Content-Disposition", "attachment", filename=att.filename) return part