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,267 @@
|
||||
"""
|
||||
Job arq: send_pec – invio PEC con retry esponenziale.
|
||||
|
||||
Flusso completo:
|
||||
1. API backend crea Message(state=queued) + SendJob(status=pending)
|
||||
2. Backend enqueue send_pec via arq
|
||||
3. send_pec legge il job dal DB, tenta l'invio SMTP
|
||||
4. Successo → Message(state=sent), SendJob(status=sent), upload EML su MinIO,
|
||||
enqueue watch_receipt con defer 24h
|
||||
5. Fallimento transitorio → status=retrying, re-enqueue con backoff
|
||||
6. Fallimento definitivo (max_attempts raggiunto) → status=failed,
|
||||
Message(state=failed), evento WS
|
||||
|
||||
Retry backoff (max 5 tentativi totali):
|
||||
Tentativo 1 fallisce → attendi 1 min → tentativo 2
|
||||
Tentativo 2 fallisce → attendi 5 min → tentativo 3
|
||||
Tentativo 3 fallisce → attendi 15 min → tentativo 4
|
||||
Tentativo 4 fallisce → attendi 1 ora → tentativo 5
|
||||
Tentativo 5 fallisce → FAILED (no retry)
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import uuid as uuid_module
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Mailbox, Message, SendJob
|
||||
from app.smtp.sender import SmtpSender
|
||||
from app.storage.minio_client import upload_outbound_eml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── Configurazione retry ─────────────────────────────────────────────────────
|
||||
|
||||
MAX_ATTEMPTS = 5
|
||||
|
||||
# Delay in secondi dopo il fallimento del tentativo N (0-based)
|
||||
RETRY_DELAYS = [
|
||||
60, # dopo tentativo 1 fallisce → 1 min
|
||||
300, # dopo tentativo 2 fallisce → 5 min
|
||||
900, # dopo tentativo 3 fallisce → 15 min
|
||||
3600, # dopo tentativo 4 fallisce → 1 ora
|
||||
# tentativo 5 = ultimo → nessun retry
|
||||
]
|
||||
|
||||
|
||||
# ─── Job principale ───────────────────────────────────────────────────────────
|
||||
|
||||
async def send_pec(ctx: dict[str, Any], send_job_id: str) -> dict:
|
||||
"""
|
||||
Job arq: invia una PEC.
|
||||
|
||||
Args:
|
||||
ctx: contesto arq (contiene redis client arq per re-enqueue)
|
||||
send_job_id: UUID del SendJob da processare
|
||||
|
||||
Returns:
|
||||
dict con esito: status, message_id, attempt
|
||||
"""
|
||||
redis_client = ctx.get("redis")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# ── Carica dati ──────────────────────────────────────────────────────
|
||||
job = await db.get(SendJob, uuid_module.UUID(send_job_id))
|
||||
if not job:
|
||||
logger.error(f"[send_pec] SendJob {send_job_id} non trovato")
|
||||
return {"status": "error", "message": "SendJob non trovato"}
|
||||
|
||||
if job.status in ("sent", "failed"):
|
||||
logger.warning(
|
||||
f"[send_pec] SendJob {send_job_id} già in stato {job.status!r}, skip"
|
||||
)
|
||||
return {"status": "skipped", "current_status": job.status}
|
||||
|
||||
msg = await db.get(Message, job.message_id)
|
||||
if not msg:
|
||||
logger.error(f"[send_pec] Messaggio {job.message_id} non trovato")
|
||||
job.status = "failed"
|
||||
job.last_error = "Messaggio associato non trovato"
|
||||
await db.commit()
|
||||
return {"status": "error", "message": "Messaggio non trovato"}
|
||||
|
||||
mailbox = await db.get(Mailbox, job.mailbox_id)
|
||||
if not mailbox:
|
||||
logger.error(f"[send_pec] Casella {job.mailbox_id} non trovata")
|
||||
job.status = "failed"
|
||||
job.last_error = "Casella mittente non trovata"
|
||||
msg.state = "failed"
|
||||
await db.commit()
|
||||
return {"status": "error", "message": "Casella non trovata"}
|
||||
|
||||
# ── Aggiorna contatori ────────────────────────────────────────────────
|
||||
job.status = "sending"
|
||||
job.attempt_count += 1
|
||||
current_attempt = job.attempt_count
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"[send_pec] Tentativo {current_attempt}/{MAX_ATTEMPTS} "
|
||||
f"per job {send_job_id} → {msg.to_addresses}"
|
||||
)
|
||||
|
||||
# ── Tenta invio SMTP ──────────────────────────────────────────────────
|
||||
try:
|
||||
sender = SmtpSender(mailbox)
|
||||
message_id_header, raw_eml = await sender.send(
|
||||
to_addresses=list(msg.to_addresses or []),
|
||||
cc_addresses=list(msg.cc_addresses or []),
|
||||
subject=msg.subject or "",
|
||||
body_text=msg.body_text or "",
|
||||
body_html=msg.body_html,
|
||||
attachments=None, # allegati in fase successiva (Fase 5)
|
||||
)
|
||||
|
||||
# ── Successo: aggiorna DB ─────────────────────────────────────────
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
msg.message_id_header = message_id_header
|
||||
msg.state = "sent"
|
||||
msg.sent_at = now
|
||||
|
||||
job.status = "sent"
|
||||
job.sent_at = now
|
||||
job.message_id = msg.id
|
||||
|
||||
# Upload raw EML su MinIO
|
||||
try:
|
||||
eml_path = await upload_outbound_eml(
|
||||
tenant_id=str(msg.tenant_id),
|
||||
mailbox_id=str(msg.mailbox_id),
|
||||
message_id=str(msg.id),
|
||||
eml_bytes=raw_eml,
|
||||
)
|
||||
msg.raw_eml_path = eml_path
|
||||
logger.debug(f"[send_pec] EML salvato: {eml_path}")
|
||||
except Exception as minio_err:
|
||||
logger.warning(f"[send_pec] Upload MinIO fallito (non critico): {minio_err}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# ── Pubblica evento WS ────────────────────────────────────────────
|
||||
if redis_client:
|
||||
await _publish_ws_event(redis_client, msg.tenant_id, {
|
||||
"type": "message:sent",
|
||||
"message_id": str(msg.id),
|
||||
"mailbox_id": str(msg.mailbox_id),
|
||||
"subject": msg.subject,
|
||||
"message_id_header": message_id_header,
|
||||
})
|
||||
|
||||
# ── Enqueue watch_receipt dopo 24h ────────────────────────────────
|
||||
try:
|
||||
await redis_client.enqueue_job(
|
||||
"watch_receipt",
|
||||
str(msg.id),
|
||||
_defer_by=timedelta(hours=24),
|
||||
)
|
||||
logger.info(
|
||||
f"[send_pec] watch_receipt schedulato per {msg.id} "
|
||||
f"tra 24h"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[send_pec] Errore enqueue watch_receipt: {e}")
|
||||
|
||||
logger.info(
|
||||
f"[send_pec] ✅ PEC inviata: job={send_job_id} "
|
||||
f"message_id_header={message_id_header}"
|
||||
)
|
||||
return {
|
||||
"status": "sent",
|
||||
"send_job_id": send_job_id,
|
||||
"message_id": str(msg.id),
|
||||
"message_id_header": message_id_header,
|
||||
"attempt": current_attempt,
|
||||
}
|
||||
|
||||
except Exception as smtp_error:
|
||||
error_msg = str(smtp_error)
|
||||
logger.warning(
|
||||
f"[send_pec] Tentativo {current_attempt} fallito: {error_msg}"
|
||||
)
|
||||
|
||||
# ── Gestione retry / failure ──────────────────────────────────────
|
||||
if current_attempt >= MAX_ATTEMPTS:
|
||||
# Esauriti tutti i tentativi → FAILED
|
||||
job.status = "failed"
|
||||
job.last_error = error_msg
|
||||
msg.state = "failed"
|
||||
await db.commit()
|
||||
|
||||
# Pubblica evento WS: invio fallito
|
||||
if redis_client:
|
||||
await _publish_ws_event(redis_client, msg.tenant_id, {
|
||||
"type": "message:send_failed",
|
||||
"message_id": str(msg.id),
|
||||
"mailbox_id": str(msg.mailbox_id),
|
||||
"subject": msg.subject,
|
||||
"error": error_msg,
|
||||
"attempts": current_attempt,
|
||||
})
|
||||
|
||||
logger.error(
|
||||
f"[send_pec] ❌ Invio FALLITO definitivamente: "
|
||||
f"job={send_job_id}, errore: {error_msg}"
|
||||
)
|
||||
return {
|
||||
"status": "failed",
|
||||
"send_job_id": send_job_id,
|
||||
"error": error_msg,
|
||||
"attempts": current_attempt,
|
||||
}
|
||||
|
||||
else:
|
||||
# Retry con backoff
|
||||
delay_seconds = RETRY_DELAYS[current_attempt - 1]
|
||||
next_retry = datetime.now(tz=timezone.utc) + timedelta(seconds=delay_seconds)
|
||||
|
||||
job.status = "retrying"
|
||||
job.last_error = error_msg
|
||||
job.next_retry_at = next_retry
|
||||
msg.state = "queued" # torna in coda
|
||||
await db.commit()
|
||||
|
||||
# Re-enqueue con defer
|
||||
try:
|
||||
await redis_client.enqueue_job(
|
||||
"send_pec",
|
||||
send_job_id,
|
||||
_defer_by=timedelta(seconds=delay_seconds),
|
||||
)
|
||||
logger.info(
|
||||
f"[send_pec] Retry {current_attempt} schedulato "
|
||||
f"in {delay_seconds}s per job {send_job_id}"
|
||||
)
|
||||
except Exception as enqueue_err:
|
||||
logger.error(
|
||||
f"[send_pec] Impossibile re-enqueue job {send_job_id}: "
|
||||
f"{enqueue_err}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "retrying",
|
||||
"send_job_id": send_job_id,
|
||||
"attempt": current_attempt,
|
||||
"next_retry_at": next_retry.isoformat(),
|
||||
"delay_seconds": delay_seconds,
|
||||
"error": error_msg,
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _publish_ws_event(
|
||||
redis_client: Any,
|
||||
tenant_id: uuid_module.UUID,
|
||||
event: dict,
|
||||
) -> None:
|
||||
"""Pubblica un evento WebSocket per il tenant tramite Redis pub/sub."""
|
||||
try:
|
||||
channel = f"ws:tenant:{tenant_id}"
|
||||
await redis_client.publish(channel, json.dumps(event, default=str))
|
||||
except Exception as e:
|
||||
logger.warning(f"[send_pec] Errore pubblicazione WS: {e}")
|
||||
+5
-2
@@ -24,7 +24,9 @@ from arq.connections import RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
from app.imap.pool import MailboxPool
|
||||
from app.jobs.send_pec import send_pec
|
||||
from app.jobs.sync_mailbox import sync_mailbox
|
||||
from app.smtp.receipt_watcher import watch_receipt
|
||||
from app.storage.minio_client import ensure_bucket_exists
|
||||
|
||||
settings = get_settings()
|
||||
@@ -127,7 +129,7 @@ class WorkerSettings:
|
||||
"""Configurazione del worker arq."""
|
||||
|
||||
# Funzioni/job registrati
|
||||
functions = [sync_mailbox, health_check]
|
||||
functions = [sync_mailbox, send_pec, watch_receipt, health_check]
|
||||
|
||||
# Callbacks lifecycle
|
||||
on_startup = on_startup
|
||||
@@ -140,7 +142,8 @@ class WorkerSettings:
|
||||
max_jobs = 20
|
||||
|
||||
# Timeout per ogni job (secondi)
|
||||
job_timeout = 300
|
||||
# send_pec può richiedere più tempo su SMTP lenti
|
||||
job_timeout = 120
|
||||
|
||||
# Retry automatico in caso di errore
|
||||
max_tries = 3
|
||||
|
||||
@@ -133,6 +133,44 @@ class Message(Base):
|
||||
)
|
||||
|
||||
|
||||
SendJobStatus = Enum(
|
||||
"pending", "sending", "sent", "failed", "retrying",
|
||||
name="send_job_status", create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class SendJob(Base):
|
||||
"""
|
||||
Job di invio PEC – traccia ogni tentativo di invio SMTP.
|
||||
Corrisponde alla tabella `send_jobs` nel DB.
|
||||
"""
|
||||
|
||||
__tablename__ = "send_jobs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
mailbox_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("messages.id"),
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(SendJobStatus, nullable=False, default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
queued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
"""
|
||||
Allegato di un messaggio PEC.
|
||||
|
||||
@@ -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
|
||||
@@ -134,6 +134,52 @@ def _sanitize_filename(filename: str) -> str:
|
||||
return safe or "attachment"
|
||||
|
||||
|
||||
async def upload_outbound_eml(
|
||||
tenant_id: str,
|
||||
mailbox_id: str,
|
||||
message_id: str,
|
||||
eml_bytes: bytes,
|
||||
) -> str:
|
||||
"""
|
||||
Carica il raw EML di un messaggio outbound su MinIO.
|
||||
|
||||
Percorso: tenants/{tenant_id}/mailboxes/{mailbox_id}/outbound/{message_id}.eml
|
||||
|
||||
Args:
|
||||
tenant_id: UUID del tenant
|
||||
mailbox_id: UUID della casella mittente
|
||||
message_id: UUID del messaggio
|
||||
eml_bytes: byte del raw EML
|
||||
|
||||
Returns:
|
||||
Percorso oggetto su MinIO (senza bucket name)
|
||||
"""
|
||||
client = get_minio_client()
|
||||
bucket = settings.minio_bucket
|
||||
object_path = (
|
||||
f"tenants/{tenant_id}/mailboxes/{mailbox_id}/outbound/{message_id}.eml"
|
||||
)
|
||||
|
||||
try:
|
||||
import io as _io
|
||||
data_stream = _io.BytesIO(eml_bytes)
|
||||
await client.put_object(
|
||||
bucket_name=bucket,
|
||||
object_name=object_path,
|
||||
data=data_stream,
|
||||
length=len(eml_bytes),
|
||||
content_type="message/rfc822",
|
||||
)
|
||||
logger.debug(
|
||||
f"EML outbound caricato: s3://{bucket}/{object_path} "
|
||||
f"({len(eml_bytes)} bytes)"
|
||||
)
|
||||
return object_path
|
||||
except Exception as e:
|
||||
logger.error(f"Errore upload EML outbound {object_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_bucket_exists() -> None:
|
||||
"""Verifica che il bucket MinIO esista, altrimenti lo crea."""
|
||||
client = get_minio_client()
|
||||
|
||||
@@ -27,6 +27,9 @@ dependencies = [
|
||||
# IMAP async
|
||||
"aioimaplib>=2.0.0",
|
||||
|
||||
# SMTP async (invio PEC – Fase 4)
|
||||
"aiosmtplib>=3.0.0",
|
||||
|
||||
# Storage MinIO/S3
|
||||
"miniopy-async>=1.21.0",
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Test unitari per SmtpSender.
|
||||
|
||||
Verifica la costruzione del messaggio MIME senza connessioni SMTP reali.
|
||||
Il test del send() effettivo verso server reali è un test di integrazione
|
||||
(eseguito separatamente con flag --real-smtp).
|
||||
"""
|
||||
|
||||
import email as email_lib
|
||||
import email.policy
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Chiave test fissa – deve coincidere con ENCRYPTION_KEY ──────────────────
|
||||
_TEST_KEY_HEX = "b" * 64
|
||||
|
||||
# Imposta la variabile d'ambiente e invalida la cache prima di qualsiasi import
|
||||
import os as _os
|
||||
_os.environ["ENCRYPTION_KEY"] = _TEST_KEY_HEX
|
||||
_os.environ.setdefault("SECRET_KEY", "test-secret-worker")
|
||||
|
||||
# Invalida la cache di get_settings se già caricata
|
||||
try:
|
||||
from app.config import get_settings as _gs
|
||||
_gs.cache_clear()
|
||||
except Exception:
|
||||
pass
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ─── Fixtures helper ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_fake_mailbox():
|
||||
"""Crea un oggetto mailbox-like con attributi minimi per SmtpSender."""
|
||||
import base64
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
key = bytes.fromhex(_TEST_KEY_HEX)
|
||||
|
||||
def _enc(value: str) -> str:
|
||||
nonce = os.urandom(12)
|
||||
ct = AESGCM(key).encrypt(nonce, value.encode(), None)
|
||||
return base64.b64encode(nonce + ct).decode()
|
||||
|
||||
mailbox = MagicMock()
|
||||
mailbox.email_address = "test@pec.example.it"
|
||||
mailbox.smtp_host_enc = _enc("smtp.example.it")
|
||||
mailbox.smtp_port_enc = _enc("465")
|
||||
mailbox.smtp_user_enc = _enc("test@pec.example.it")
|
||||
mailbox.smtp_pass_enc = _enc("secret")
|
||||
mailbox.smtp_use_tls = True
|
||||
|
||||
return mailbox
|
||||
|
||||
|
||||
# ─── Test costruzione MIME ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildMimeMessage:
|
||||
"""Verifica la costruzione del messaggio MIME con varie combinazioni."""
|
||||
|
||||
def _get_sender(self):
|
||||
"""Restituisce SmtpSender con mailbox mock."""
|
||||
# La chiave è già impostata a livello di modulo (_TEST_KEY_HEX)
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear() # forza rilettura env var
|
||||
|
||||
from app.smtp.sender import SmtpSender
|
||||
return SmtpSender(_make_fake_mailbox())
|
||||
|
||||
def test_build_plain_text_only(self):
|
||||
"""Verifica struttura MIME con solo testo semplice."""
|
||||
sender = self._get_sender()
|
||||
msg, msg_id = sender.build_mime_message(
|
||||
to_addresses=["dest@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Test oggetto",
|
||||
body_text="Testo del corpo",
|
||||
)
|
||||
|
||||
assert isinstance(msg, MIMEMultipart)
|
||||
assert msg["From"] == "test@pec.example.it"
|
||||
assert msg["To"] == "dest@pec.it"
|
||||
assert msg["Subject"] == "Test oggetto"
|
||||
assert msg_id.startswith("<")
|
||||
assert msg_id.endswith(">")
|
||||
assert "Cc" not in msg
|
||||
|
||||
def test_build_with_cc(self):
|
||||
"""Verifica che il campo Cc venga incluso correttamente."""
|
||||
sender = self._get_sender()
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest1@pec.it"],
|
||||
cc_addresses=["cc@pec.it", "cc2@pec.it"],
|
||||
subject="Test Cc",
|
||||
body_text="corpo",
|
||||
)
|
||||
|
||||
assert "Cc" in msg
|
||||
assert "cc@pec.it" in msg["Cc"]
|
||||
assert "cc2@pec.it" in msg["Cc"]
|
||||
|
||||
def test_build_multiple_to(self):
|
||||
"""Verifica destinatari multipli nel campo To."""
|
||||
sender = self._get_sender()
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest1@pec.it", "dest2@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Multi dest",
|
||||
body_text="corpo",
|
||||
)
|
||||
|
||||
assert "dest1@pec.it" in msg["To"]
|
||||
assert "dest2@pec.it" in msg["To"]
|
||||
|
||||
def test_build_with_html(self):
|
||||
"""Verifica che corpo HTML venga aggiunto come parte MIME."""
|
||||
sender = self._get_sender()
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Test HTML",
|
||||
body_text="Testo semplice",
|
||||
body_html="<p>Testo <b>HTML</b></p>",
|
||||
)
|
||||
|
||||
# Trova le parti del messaggio
|
||||
raw = msg.as_string()
|
||||
assert "text/plain" in raw
|
||||
assert "text/html" in raw
|
||||
|
||||
def test_build_with_attachment(self):
|
||||
"""Verifica che un allegato venga incluso nel messaggio."""
|
||||
sender = self._get_sender()
|
||||
attachments = [
|
||||
{
|
||||
"filename": "documento.pdf",
|
||||
"content": b"%PDF-1.4 fake content",
|
||||
"content_type": "application/pdf",
|
||||
}
|
||||
]
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Test allegato",
|
||||
body_text="Vedi allegato",
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
raw = msg.as_string()
|
||||
assert "documento.pdf" in raw
|
||||
|
||||
def test_build_multiple_attachments(self):
|
||||
"""Verifica più allegati in un unico messaggio."""
|
||||
sender = self._get_sender()
|
||||
attachments = [
|
||||
{"filename": "file1.txt", "content": b"contenuto 1", "content_type": "text/plain"},
|
||||
{"filename": "file2.txt", "content": b"contenuto 2", "content_type": "text/plain"},
|
||||
]
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Multi allegati",
|
||||
body_text="Due allegati",
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
raw = msg.as_string()
|
||||
assert "file1.txt" in raw
|
||||
assert "file2.txt" in raw
|
||||
|
||||
def test_message_id_unique(self):
|
||||
"""Verifica che ogni messaggio abbia un Message-ID unico."""
|
||||
sender = self._get_sender()
|
||||
_, id1 = sender.build_mime_message(
|
||||
to_addresses=["a@pec.it"], cc_addresses=[], subject="A", body_text="a"
|
||||
)
|
||||
_, id2 = sender.build_mime_message(
|
||||
to_addresses=["b@pec.it"], cc_addresses=[], subject="B", body_text="b"
|
||||
)
|
||||
assert id1 != id2
|
||||
|
||||
def test_required_headers_present(self):
|
||||
"""Verifica che tutti gli header obbligatori siano presenti."""
|
||||
sender = self._get_sender()
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Test headers",
|
||||
body_text="corpo",
|
||||
)
|
||||
|
||||
required_headers = ["From", "To", "Subject", "Date", "Message-ID", "MIME-Version"]
|
||||
for header in required_headers:
|
||||
assert header in msg, f"Header mancante: {header}"
|
||||
|
||||
def test_eml_bytes_serializable(self):
|
||||
"""Verifica che il messaggio sia serializzabile in bytes."""
|
||||
sender = self._get_sender()
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Serializzazione",
|
||||
body_text="corpo",
|
||||
)
|
||||
|
||||
raw = msg.as_bytes()
|
||||
assert len(raw) > 0
|
||||
assert isinstance(raw, bytes)
|
||||
|
||||
def test_empty_body_creates_valid_message(self):
|
||||
"""Verifica che un messaggio con corpo vuoto sia comunque valido."""
|
||||
sender = self._get_sender()
|
||||
msg, _ = sender.build_mime_message(
|
||||
to_addresses=["dest@pec.it"],
|
||||
cc_addresses=[],
|
||||
subject="Corpo vuoto",
|
||||
body_text="",
|
||||
)
|
||||
|
||||
raw = msg.as_bytes()
|
||||
assert len(raw) > 0
|
||||
|
||||
|
||||
# ─── Test decifrazione credenziali ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDecryptSmtpCredentials:
|
||||
"""Verifica la decifrazione delle credenziali SMTP."""
|
||||
|
||||
def test_decrypt_returns_correct_values(self):
|
||||
"""Le credenziali decifrate devono corrispondere ai valori originali."""
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.smtp.sender import decrypt_smtp_credentials
|
||||
|
||||
mailbox = _make_fake_mailbox()
|
||||
creds = decrypt_smtp_credentials(mailbox)
|
||||
|
||||
assert creds["host"] == "smtp.example.it"
|
||||
assert creds["port"] == 465
|
||||
assert creds["user"] == "test@pec.example.it"
|
||||
assert creds["password"] == "secret"
|
||||
assert creds["use_tls"] is True
|
||||
|
||||
def test_wrong_key_raises_error(self):
|
||||
"""Una chiave errata deve sollevare un'eccezione."""
|
||||
import os
|
||||
from app.config import get_settings
|
||||
|
||||
# Imposta chiave sbagliata
|
||||
os.environ["ENCRYPTION_KEY"] = "a" * 64
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.smtp.sender import decrypt_smtp_credentials
|
||||
mailbox = _make_fake_mailbox() # cifrato con chiave "b"*64
|
||||
|
||||
with pytest.raises(Exception):
|
||||
decrypt_smtp_credentials(mailbox)
|
||||
|
||||
# Ripristina chiave corretta per test successivi
|
||||
os.environ["ENCRYPTION_KEY"] = _TEST_KEY_HEX
|
||||
get_settings.cache_clear()
|
||||
Reference in New Issue
Block a user