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 @@
|
||||
"""Package SMTP – invio PEC via aiosmtplib."""
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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