""" SmtpSender – invio PEC via SMTP (SSL/STARTTLS) con aiosmtplib. Costruisce il messaggio MIME, si connette al server SMTP della casella, invia e restituisce il Message-ID e i byte raw EML per l'archiviazione. Porta 465 → SSL diretto (use_tls=True, start_tls=False) Porta 587 → STARTTLS (use_tls=False, start_tls=True) Porta 25 → plain (use_tls=False, start_tls=False) – deprecato, non usato """ import base64 import io import logging import uuid from email import encoders from email.headerregistry import Address from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formatdate, make_msgid import aiosmtplib from cryptography.hazmat.primitives.ciphers.aead import AESGCM from app.config import get_settings from app.models import Mailbox logger = logging.getLogger(__name__) # ─── Helpers ────────────────────────────────────────────────────────────────── def _decrypt(enc_value: str) -> str: """ Decifra un campo credenziale cifrato con AES-256-GCM (ADR-002). Usa get_settings() in modo lazy (non module-level) per permettere ai test di iniettare la chiave tramite env var o mock. """ raw = base64.b64decode(enc_value) nonce, ciphertext_tag = raw[:12], raw[12:] aesgcm = AESGCM(get_settings().encryption_key_bytes) return aesgcm.decrypt(nonce, ciphertext_tag, None).decode("utf-8") def decrypt_smtp_credentials(mailbox: Mailbox) -> dict: """Restituisce le credenziali SMTP in chiaro della casella.""" return { "host": _decrypt(mailbox.smtp_host_enc), "port": int(_decrypt(mailbox.smtp_port_enc)), "user": _decrypt(mailbox.smtp_user_enc), "password": _decrypt(mailbox.smtp_pass_enc), "use_tls": mailbox.smtp_use_tls, } # ─── SmtpSender ─────────────────────────────────────────────────────────────── class SmtpSender: """ Gestisce la connessione SMTP e l'invio di un singolo messaggio PEC. Esempio di utilizzo:: sender = SmtpSender(mailbox) msg_id, raw_eml = await sender.send( to_addresses=["dest@pec.it"], cc_addresses=[], subject="Test", body_text="Corpo del messaggio", ) """ def __init__(self, mailbox: Mailbox) -> None: self.mailbox = mailbox self._creds = decrypt_smtp_credentials(mailbox) # ── Costruzione MIME ────────────────────────────────────────────────────── def build_mime_message( self, to_addresses: list[str], cc_addresses: list[str], subject: str, body_text: str, body_html: str | None = None, attachments: list[dict] | None = None, ) -> tuple[MIMEMultipart, str]: """ Costruisce il messaggio MIME per la PEC. Args: to_addresses: destinatari principali cc_addresses: destinatari in copia (può essere vuoto) subject: oggetto del messaggio body_text: corpo in testo semplice body_html: corpo HTML opzionale attachments: lista di dict {filename, content: bytes, content_type} Returns: (msg MIME, message_id_header) """ attachments = attachments or [] # Struttura MIME if attachments: msg = MIMEMultipart("mixed") body_container = MIMEMultipart("alternative") elif body_html: msg = MIMEMultipart("alternative") body_container = msg else: msg = MIMEMultipart("mixed") body_container = msg # Headers obbligatori message_id = make_msgid(domain="pecflow.local") msg["From"] = self.mailbox.email_address msg["To"] = ", ".join(to_addresses) if cc_addresses: msg["Cc"] = ", ".join(cc_addresses) msg["Subject"] = subject msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = message_id msg["MIME-Version"] = "1.0" # Corpo if body_text: body_container.attach(MIMEText(body_text, "plain", "utf-8")) if body_html: body_container.attach(MIMEText(body_html, "html", "utf-8")) elif not body_text: # Almeno un body vuoto per evitare messaggi malformati body_container.attach(MIMEText("", "plain", "utf-8")) # Se la struttura è mixed, aggiungi il body_container come parte if attachments and body_container is not msg: msg.attach(body_container) # Allegati for att in attachments: filename: str = att["filename"] content: bytes = att["content"] content_type: str = att.get("content_type", "application/octet-stream") try: main_type, sub_type = content_type.split("/", 1) except ValueError: main_type, sub_type = "application", "octet-stream" part = MIMEBase(main_type, sub_type) part.set_payload(content) encoders.encode_base64(part) part.add_header( "Content-Disposition", "attachment", filename=filename, ) msg.attach(part) return msg, message_id # ── Invio SMTP ──────────────────────────────────────────────────────────── async def send( self, to_addresses: list[str], cc_addresses: list[str], subject: str, body_text: str, body_html: str | None = None, attachments: list[dict] | None = None, ) -> tuple[str, bytes]: """ Invia la PEC via SMTP. Supporta: - Porta 465 con SSL diretto (use_tls=True) - Porta 587 con STARTTLS (use_tls=False, porta 587) - Porta 25 plain (uso sconsigliato) Returns: (message_id_header, raw_eml_bytes) Raises: aiosmtplib.SMTPException: su errore SMTP non recuperabile aiosmtplib.SMTPConnectError: su timeout/connessione fallita """ msg, message_id = self.build_mime_message( to_addresses=to_addresses, cc_addresses=cc_addresses, subject=subject, body_text=body_text, body_html=body_html, attachments=attachments, ) raw_eml: bytes = msg.as_bytes() creds = self._creds all_recipients = list(to_addresses) + list(cc_addresses) # Determina la modalità di connessione in base alla porta e al flag port: int = creds["port"] use_tls: bool = creds["use_tls"] start_tls: bool = False if port == 587: # STARTTLS tipico use_tls = False start_tls = True elif port == 465: # SSL diretto use_tls = True start_tls = False # porta 25 → plain (entrambi False) logger.debug( f"SMTP connect: {creds['host']}:{port} " f"(use_tls={use_tls}, start_tls={start_tls})" ) smtp = aiosmtplib.SMTP( hostname=creds["host"], port=port, use_tls=use_tls, start_tls=start_tls, timeout=30, ) try: await smtp.connect() await smtp.login(creds["user"], creds["password"]) errors, response = await smtp.sendmail( sender=self.mailbox.email_address, recipients=all_recipients, message=raw_eml, ) if errors: failed = ", ".join(f"{addr}: {err}" for addr, err in errors.items()) raise aiosmtplib.SMTPRecipientsRefused( recipients={a: (code, msg_b) for a, (code, msg_b) in errors.items()} ) await smtp.quit() except Exception: try: smtp.close() except Exception: pass raise logger.info( f"PEC inviata: {message_id} da {self.mailbox.email_address} " f"→ {all_recipients} ({len(raw_eml)} bytes)" ) return message_id, raw_eml