mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
fase 4
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user