mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 20:55:41 +02:00
257 lines
8.5 KiB
Python
257 lines
8.5 KiB
Python
"""
|
||
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(
|
||
self.mailbox.email_address,
|
||
all_recipients,
|
||
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
|