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
+1
View File
@@ -0,0 +1 @@
"""Package SMTP invio PEC via aiosmtplib."""
+94
View File
@@ -0,0 +1,94 @@
"""
Job arq: watch_receipt attende la ricevuta di accettazione per una PEC inviata.
Viene enqueued da send_pec dopo un invio riuscito con un defer di 24 ore.
Se dopo 24h nessuna ricevuta (accettazione o avvenuta_consegna) è arrivata
tramite IMAP sync, imposta lo stato del messaggio a 'anomaly' e pubblica
un evento WebSocket all'admin del tenant.
Flow:
send_pec → invio OK → enqueue watch_receipt (defer 24h)
IMAP sync → ricevuta arriva → aggiorna Message.state a 'accepted'/'delivered'
watch_receipt (dopo 24h) → verifica se state == 'accepted'/'delivered'
→ no → state = 'anomaly' + WS event
"""
import json
import logging
import uuid as uuid_module
from typing import Any
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Message
logger = logging.getLogger(__name__)
# Stati che indicano ricezione ricevuta (impostati da IMAP sync via pec_parser)
_ACCEPTED_STATES = {"accepted", "delivered"}
async def watch_receipt(ctx: dict[str, Any], message_id: str) -> dict:
"""
Job arq: verifica se il messaggio outbound ha ricevuto accettazione.
Args:
ctx: contesto arq (redis, ecc.)
message_id: UUID del messaggio outbound da monitorare
Returns:
dict con esito del controllo
"""
redis_client = ctx.get("redis")
async with AsyncSessionLocal() as db:
msg = await db.get(Message, uuid_module.UUID(message_id))
if not msg:
logger.warning(f"[watch_receipt] Messaggio {message_id} non trovato")
return {"status": "error", "message": "Messaggio non trovato"}
if msg.direction != "outbound":
return {"status": "skipped", "message": "Non è un messaggio outbound"}
if msg.state in _ACCEPTED_STATES:
# Ricevuta già arrivata tramite IMAP sync: OK
logger.info(
f"[watch_receipt] Messaggio {message_id} ha ricevuto "
f"accettazione (state={msg.state!r})"
)
return {"status": "ok", "state": msg.state}
# Nessuna ricevuta in 24h → anomalia
logger.warning(
f"[watch_receipt] Nessuna accettazione in 24h per {message_id} "
f"(state={msg.state!r}, mailbox={msg.mailbox_id})"
)
prev_state = msg.state
msg.state = "anomaly"
await db.commit()
# Pubblica evento WebSocket al tenant
if redis_client:
event = {
"type": "message:anomaly",
"message_id": message_id,
"mailbox_id": str(msg.mailbox_id),
"subject": msg.subject,
"reason": "Nessuna ricevuta di accettazione entro 24 ore",
"previous_state": prev_state,
}
channel = f"ws:tenant:{msg.tenant_id}"
try:
await redis_client.publish(channel, json.dumps(event, default=str))
logger.debug(f"[watch_receipt] Evento anomalia pubblicato su {channel}")
except Exception as e:
logger.error(f"[watch_receipt] Errore pubblicazione Redis: {e}")
return {
"status": "anomaly",
"message_id": message_id,
"reason": "Nessuna ricevuta di accettazione entro 24 ore",
}
+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