ff7da29638
smtp_send: conecta+envia+cierra en un paso via smtplib (TLS/STARTTLS/plain). email_build_html: construye EmailMessagePy frozen dataclass con cuerpo HTML. Solo stdlib Python: smtplib, email.mime. Tests con mock SMTP server threading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
5.3 KiB
Python
170 lines
5.3 KiB
Python
"""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
|