This commit is contained in:
2026-03-18 18:16:44 +01:00
parent c89c08c397
commit b3c8b77f12
20 changed files with 1934 additions and 36 deletions
+256
View File
@@ -0,0 +1,256 @@
"""
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