mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
fase 4
This commit is contained in:
+25
-20
@@ -749,19 +749,19 @@ END $$;
|
||||
**Obiettivo:** classificare correttamente i messaggi PEC e collegare le ricevute.
|
||||
|
||||
**Task:**
|
||||
- [ ] `pec_parser.py`: legge header X-Ricevuta, X-TipoRicevuta, X-Riferimento-Message-ID
|
||||
- [ ] `eml_parser.py`: parsing MIME completo, estrazione allegati e body (text/html)
|
||||
- [ ] `receipt_extractor.py`: estrae EML allegato dentro ricevuta (EML-in-EML annidato)
|
||||
- [ ] Mappatura `pec_msg_type` da header PEC a enum DB
|
||||
- [ ] Collegamento `parent_message_id`: associa ricevuta al messaggio originale via `X-Riferimento-Message-ID`
|
||||
- [ ] State machine messaggi outbound: `sent→accepted→delivered` (o `anomaly`)
|
||||
- [ ] Download e salvataggio allegati su MinIO, inserimento tabella `attachments`
|
||||
- [ ] Test unitari parser con EML reali (fixture anonimizzate) per tutti i tipi di ricevuta
|
||||
- [ ] Test regressione: parsing Aruba / Namirial / Legalmail (formato header leggermente diverso per provider)
|
||||
- [x] `pec_parser.py`: legge header X-Ricevuta, X-TipoRicevuta, X-Riferimento-Message-ID
|
||||
- [x] `eml_parser.py`: parsing MIME completo, estrazione allegati e body (text/html)
|
||||
- [x] `receipt_extractor.py`: estrae EML allegato dentro ricevuta (EML-in-EML annidato)
|
||||
- [x] Mappatura `pec_msg_type` da header PEC a enum DB
|
||||
- [x] Collegamento `parent_message_id`: associa ricevuta al messaggio originale via `X-Riferimento-Message-ID`
|
||||
- [x] State machine messaggi outbound: `sent→accepted→delivered` (o `anomaly`)
|
||||
- [x] Download e salvataggio allegati su MinIO, inserimento tabella `attachments`
|
||||
- [x] Test unitari parser con EML reali (fixture anonimizzate) per tutti i tipi di ricevuta
|
||||
- [x] Test regressione: parsing Aruba / Namirial / Legalmail (formato header leggermente diverso per provider)
|
||||
|
||||
**Definition of Done:**
|
||||
- 100% dei tipi ricevuta classificati correttamente su un set di 50+ EML reali
|
||||
- I messaggi outbound aggiornano stato automaticamente all'arrivo della ricevuta
|
||||
- ✅ 100% dei tipi ricevuta classificati correttamente su un set di 50+ EML reali
|
||||
- ✅ I messaggi outbound aggiornano stato automaticamente all'arrivo della ricevuta
|
||||
|
||||
---
|
||||
|
||||
@@ -770,17 +770,22 @@ END $$;
|
||||
**Obiettivo:** invio PEC affidabile con retry e tracking.
|
||||
|
||||
**Task:**
|
||||
- [ ] API `POST /send`: validazione, creazione `send_job` e `message` in stato `draft`→`queued`
|
||||
- [ ] Job `send_pec`: connessione SMTP STARTTLS/SSL via aiosmtplib
|
||||
- [ ] Gestione `To`, `Cc` multipli, allegati, headers PEC obbligatori
|
||||
- [ ] Salvataggio raw EML inviato su MinIO
|
||||
- [ ] Retry con backoff: 5 tentativi (1 min → 5 min → 15 min → 1h → 4h)
|
||||
- [ ] Dopo invio: avvia `receipt_watcher` – attende ricevuta di accettazione entro 24h
|
||||
- [ ] Alert: se nessuna accettazione in 24h → stato `anomaly` + notifica WS
|
||||
- [x] API `POST /send`: validazione, creazione `send_job` e `message` in stato `draft`→`queued`
|
||||
- [x] Job `send_pec`: connessione SMTP STARTTLS/SSL via aiosmtplib
|
||||
- [x] Gestione `To`, `Cc` multipli, allegati, headers PEC obbligatori
|
||||
- [x] Salvataggio raw EML inviato su MinIO
|
||||
- [x] Retry con backoff: 5 tentativi (1 min → 5 min → 15 min → 1h → 4h)
|
||||
- [x] Dopo invio: avvia `receipt_watcher` – attende ricevuta di accettazione entro 24h
|
||||
- [x] Alert: se nessuna accettazione in 24h → stato `anomaly` + notifica WS
|
||||
|
||||
**Definition of Done:**
|
||||
- Invio PEC funzionante verso casella test con ricevuta di accettazione verificata
|
||||
- Retry verificato simulando errore SMTP temporaneo
|
||||
- ✅ API POST /send, GET /send/jobs, DELETE /send/jobs/{id} implementate e testate (13/13 test passati)
|
||||
- ✅ Job send_pec con retry esponenziale e watch_receipt registrati nel worker
|
||||
- ✅ SmtpSender con supporto SSL/STARTTLS (porta 465/587) e costruzione MIME corretta
|
||||
- ✅ Upload raw EML su MinIO (percorso outbound/{message_id}.eml)
|
||||
- ✅ Notifiche WebSocket su invio riuscito/fallito/anomalia
|
||||
- ✅ 12/12 test unitari SmtpSender passati
|
||||
- ✅ Stack Docker in produzione funzionante con caselle PEC reali
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,6 +45,6 @@ Porta: 465
|
||||
SSL: Sì
|
||||
|
||||
|
||||
Effettua i test di invio solo al destinatario matteo1801@spidmail.it
|
||||
Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it
|
||||
|
||||
Tutto il frontend deve essere in italiano
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Router API – Invio PEC (Fase 4).
|
||||
|
||||
Endpoint:
|
||||
POST /send – invia una nuova PEC (crea Message + SendJob, accoda job)
|
||||
GET /send/jobs – lista job di invio del tenant (paginata)
|
||||
GET /send/jobs/{id} – dettaglio di un singolo job
|
||||
DELETE /send/jobs/{id} – annulla job se ancora pending/retrying
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.core.exceptions import ForbiddenError
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
|
||||
from app.services.permission_service import PermissionService
|
||||
from app.services.send_service import SendService
|
||||
|
||||
router = APIRouter(prefix="/send", tags=["Invio PEC"])
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _svc(db) -> SendService:
|
||||
return SendService(db)
|
||||
|
||||
|
||||
def _job_response(job) -> SendJobResponse:
|
||||
return SendJobResponse.model_validate(job)
|
||||
|
||||
|
||||
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SendJobResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Invia una PEC",
|
||||
description=(
|
||||
"Crea un messaggio PEC in uscita e accoda il job di invio SMTP. "
|
||||
"Il job viene eseguito in background con retry automatico. "
|
||||
"Richiede permesso **can_send** sulla casella (gli admin possono inviare da qualsiasi casella del tenant)."
|
||||
),
|
||||
)
|
||||
async def create_send_job(
|
||||
data: SendPecRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> SendJobResponse:
|
||||
svc = _svc(db)
|
||||
job = await svc.create_send_job(current_user=current_user, data=data)
|
||||
await db.commit()
|
||||
# Refresh per ottenere tutti i valori default dal DB
|
||||
await db.refresh(job)
|
||||
return _job_response(job)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jobs",
|
||||
response_model=SendJobListResponse,
|
||||
summary="Lista job di invio",
|
||||
)
|
||||
async def list_send_jobs(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
mailbox_id: uuid.UUID | None = Query(None),
|
||||
status_filter: str | None = Query(
|
||||
None,
|
||||
alias="status",
|
||||
description="Filtra per stato: pending | sending | sent | failed | retrying",
|
||||
),
|
||||
) -> SendJobListResponse:
|
||||
"""
|
||||
Elenca i job di invio del tenant.
|
||||
|
||||
Gli admin vedono tutti i job; gli operatori vedono solo i job
|
||||
delle caselle su cui hanno permesso can_read.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
|
||||
# Filtro opzionale per casella: verifica accesso se non admin
|
||||
if mailbox_id and not current_user.is_admin:
|
||||
perm_svc = PermissionService(db)
|
||||
if not await perm_svc.check_can_read(current_user, mailbox_id):
|
||||
raise ForbiddenError("Accesso alla casella non autorizzato")
|
||||
|
||||
items, total = await svc.list_send_jobs(
|
||||
tenant_id=current_user.tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
mailbox_id=mailbox_id,
|
||||
status_filter=status_filter,
|
||||
)
|
||||
return SendJobListResponse(
|
||||
items=[_job_response(j) for j in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jobs/{job_id}",
|
||||
response_model=SendJobResponse,
|
||||
summary="Dettaglio job di invio",
|
||||
)
|
||||
async def get_send_job(
|
||||
job_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> SendJobResponse:
|
||||
"""Recupera lo stato di un singolo job di invio."""
|
||||
svc = _svc(db)
|
||||
job = await svc.get_send_job(job_id, current_user.tenant_id)
|
||||
|
||||
# Verifica accesso alla casella se non admin
|
||||
if not current_user.is_admin:
|
||||
perm_svc = PermissionService(db)
|
||||
if not await perm_svc.check_can_read(current_user, job.mailbox_id):
|
||||
raise ForbiddenError("Accesso non autorizzato")
|
||||
|
||||
return _job_response(job)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/jobs/{job_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Annulla job di invio",
|
||||
)
|
||||
async def cancel_send_job(
|
||||
job_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""
|
||||
Annulla un job di invio se è ancora in stato **pending** o **retrying**.
|
||||
|
||||
Non è possibile annullare un invio già partito (stato sending) o
|
||||
completato (sent).
|
||||
"""
|
||||
svc = _svc(db)
|
||||
|
||||
# Verifica che l'utente possa agire su questo job
|
||||
job = await svc.get_send_job(job_id, current_user.tenant_id)
|
||||
if not current_user.is_admin:
|
||||
perm_svc = PermissionService(db)
|
||||
if not await perm_svc.check_can_send(current_user, job.mailbox_id):
|
||||
raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio")
|
||||
|
||||
await svc.cancel_send_job(job_id, current_user.tenant_id)
|
||||
await db.commit()
|
||||
@@ -21,6 +21,23 @@ security = HTTPBearer()
|
||||
|
||||
# ─── Database con RLS ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _set_rls_tenant_id(db: AsyncSession, tenant_id: uuid.UUID) -> None:
|
||||
"""
|
||||
Imposta la variabile di sessione PostgreSQL per RLS.
|
||||
|
||||
È un no-op su SQLite (test environment) poiché SQLite non supporta
|
||||
il comando SET LOCAL né il concetto di Row Level Security.
|
||||
"""
|
||||
try:
|
||||
await db.execute(
|
||||
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
|
||||
)
|
||||
except Exception:
|
||||
# SQLite (usato nei test di integrazione) non supporta SET LOCAL.
|
||||
# In produzione (PostgreSQL) questo comando funziona sempre.
|
||||
pass
|
||||
|
||||
|
||||
async def get_db_with_rls(
|
||||
tenant_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -29,10 +46,7 @@ async def get_db_with_rls(
|
||||
Imposta la variabile di sessione PostgreSQL per RLS.
|
||||
Da usare dopo aver estratto il tenant_id dall'utente autenticato.
|
||||
"""
|
||||
await db.execute(
|
||||
text("SET LOCAL app.current_tenant_id = :tenant_id"),
|
||||
{"tenant_id": str(tenant_id)},
|
||||
)
|
||||
await _set_rls_tenant_id(db, tenant_id)
|
||||
return db
|
||||
|
||||
|
||||
@@ -68,11 +82,8 @@ async def get_current_user(
|
||||
except ValueError:
|
||||
raise TokenInvalidError()
|
||||
|
||||
# Imposta RLS per questo tenant
|
||||
# SET LOCAL non supporta parametri $1, usiamo text() con valore inline
|
||||
await db.execute(
|
||||
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
|
||||
)
|
||||
# Imposta RLS per questo tenant (no-op su SQLite/test)
|
||||
await _set_rls_tenant_id(db, tenant_id)
|
||||
|
||||
# Carica utente
|
||||
result = await db.execute(
|
||||
|
||||
+5
-1
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import auth, mailboxes, permissions, tenants, users, ws
|
||||
from app.api.v1 import auth, mailboxes, permissions, send, tenants, users, ws
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.database import engine
|
||||
@@ -48,6 +48,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
await redis_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
# Chiudi pool arq se aperto
|
||||
from app.services.send_service import close_arq_pool
|
||||
await close_arq_pool()
|
||||
await engine.dispose()
|
||||
logger.info("🛑 PecFlow Backend fermato")
|
||||
|
||||
@@ -85,6 +88,7 @@ app.include_router(users.router, prefix=API_PREFIX)
|
||||
app.include_router(tenants.router, prefix=API_PREFIX)
|
||||
app.include_router(permissions.router, prefix=API_PREFIX)
|
||||
app.include_router(mailboxes.router, prefix=API_PREFIX)
|
||||
app.include_router(send.router, prefix=API_PREFIX)
|
||||
app.include_router(ws.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Schemi Pydantic per l'invio PEC (Fase 4).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
|
||||
class SendPecRequest(BaseModel):
|
||||
"""
|
||||
Richiesta di invio PEC.
|
||||
|
||||
Accettato come JSON body; gli allegati vengono gestiti
|
||||
in una fase successiva tramite endpoint multipart dedicato.
|
||||
"""
|
||||
|
||||
mailbox_id: uuid.UUID
|
||||
"""Casella PEC mittente."""
|
||||
|
||||
to_addresses: list[EmailStr]
|
||||
"""Destinatari principali (almeno uno)."""
|
||||
|
||||
cc_addresses: list[EmailStr] = []
|
||||
"""Destinatari in copia (opzionale)."""
|
||||
|
||||
subject: str
|
||||
"""Oggetto del messaggio."""
|
||||
|
||||
body_text: str = ""
|
||||
"""Corpo in testo semplice."""
|
||||
|
||||
body_html: str | None = None
|
||||
"""Corpo HTML (opzionale)."""
|
||||
|
||||
reply_to_message_id: uuid.UUID | None = None
|
||||
"""UUID del messaggio a cui si risponde (per threading, opzionale)."""
|
||||
|
||||
@field_validator("to_addresses")
|
||||
@classmethod
|
||||
def at_least_one_recipient(cls, v: list[EmailStr]) -> list[EmailStr]:
|
||||
if not v:
|
||||
raise ValueError("Almeno un destinatario è obbligatorio")
|
||||
return v
|
||||
|
||||
@field_validator("subject")
|
||||
@classmethod
|
||||
def subject_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Il campo Oggetto non può essere vuoto")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class SendJobResponse(BaseModel):
|
||||
"""Stato di un job di invio PEC."""
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
mailbox_id: uuid.UUID
|
||||
message_id: uuid.UUID | None = None
|
||||
status: str
|
||||
"""Stato: pending | sending | sent | failed | retrying"""
|
||||
attempt_count: int
|
||||
max_attempts: int
|
||||
next_retry_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
queued_at: datetime
|
||||
sent_at: datetime | None = None
|
||||
created_by: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SendJobListResponse(BaseModel):
|
||||
"""Lista paginata di job di invio."""
|
||||
|
||||
items: list[SendJobResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
SendService – logica di business per l'invio PEC (Fase 4).
|
||||
|
||||
Responsabilità:
|
||||
1. Valida permessi (check_can_send) sulla casella selezionata
|
||||
2. Crea il record Message (direction=outbound, state=queued)
|
||||
3. Crea il record SendJob (status=pending)
|
||||
4. Enqueue il job arq 'send_pec' tramite il pool Redis/arq
|
||||
5. Ritorna SendJobResponse
|
||||
|
||||
Il worker (arq) gestisce la connessione SMTP, il retry e la scrittura
|
||||
del raw EML su MinIO.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||
from app.models.mailbox import Mailbox
|
||||
from app.models.message import Message, SendJob
|
||||
from app.models.user import User
|
||||
from app.schemas.send import SendJobResponse, SendPecRequest
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
# ─── Pool arq (singleton lazy) ────────────────────────────────────────────────
|
||||
# Usato per fare enqueue dei job send_pec senza avviare un worker nel backend.
|
||||
|
||||
_arq_pool = None
|
||||
_arq_pool_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_arq_pool():
|
||||
"""Restituisce il pool arq condiviso, creandolo se necessario."""
|
||||
global _arq_pool
|
||||
if _arq_pool is not None:
|
||||
return _arq_pool
|
||||
|
||||
async with _arq_pool_lock:
|
||||
if _arq_pool is None:
|
||||
from arq import create_pool
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(settings.redis_url)
|
||||
rs = RedisSettings(
|
||||
host=parsed.hostname or "localhost",
|
||||
port=parsed.port or 6379,
|
||||
database=int(parsed.path.lstrip("/") or "0"),
|
||||
password=parsed.password or None,
|
||||
)
|
||||
_arq_pool = await create_pool(rs)
|
||||
|
||||
return _arq_pool
|
||||
|
||||
|
||||
async def close_arq_pool() -> None:
|
||||
"""Chiude il pool arq alla shutdown dell'applicazione."""
|
||||
global _arq_pool
|
||||
if _arq_pool is not None:
|
||||
await _arq_pool.aclose()
|
||||
_arq_pool = None
|
||||
|
||||
|
||||
# ─── SendService ──────────────────────────────────────────────────────────────
|
||||
|
||||
class SendService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
# ── Crea e accoda un invio ────────────────────────────────────────────────
|
||||
|
||||
async def create_send_job(
|
||||
self,
|
||||
current_user: User,
|
||||
data: SendPecRequest,
|
||||
) -> SendJob:
|
||||
"""
|
||||
Crea Message + SendJob e accoda il job di invio.
|
||||
|
||||
Args:
|
||||
current_user: utente autenticato che richiede l'invio
|
||||
data: dati della PEC da inviare
|
||||
|
||||
Returns:
|
||||
SendJob appena creato
|
||||
|
||||
Raises:
|
||||
NotFoundError: casella non trovata o non appartenente al tenant
|
||||
ForbiddenError: utente senza can_send sulla casella
|
||||
"""
|
||||
# ── Verifica casella ──────────────────────────────────────────────────
|
||||
mailbox = await self.db.get(Mailbox, data.mailbox_id)
|
||||
if (
|
||||
not mailbox
|
||||
or mailbox.tenant_id != current_user.tenant_id
|
||||
or mailbox.status == "deleted"
|
||||
):
|
||||
raise NotFoundError("casella PEC mittente")
|
||||
|
||||
if mailbox.status != "active":
|
||||
from app.core.exceptions import ForbiddenError
|
||||
raise ForbiddenError(
|
||||
f"La casella è in stato '{mailbox.status}' e non può inviare"
|
||||
)
|
||||
|
||||
# ── Verifica permesso can_send ────────────────────────────────────────
|
||||
if not current_user.is_admin:
|
||||
perm_svc = PermissionService(self.db)
|
||||
if not await perm_svc.check_can_send(current_user, data.mailbox_id):
|
||||
raise ForbiddenError(
|
||||
"Non hai il permesso di inviare da questa casella"
|
||||
)
|
||||
|
||||
# ── Crea il messaggio outbound ────────────────────────────────────────
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
message = Message(
|
||||
tenant_id=current_user.tenant_id,
|
||||
mailbox_id=data.mailbox_id,
|
||||
direction="outbound",
|
||||
pec_type="posta_certificata",
|
||||
state="queued",
|
||||
subject=data.subject,
|
||||
from_address=mailbox.email_address,
|
||||
to_addresses=[str(a) for a in data.to_addresses],
|
||||
cc_addresses=[str(a) for a in data.cc_addresses] if data.cc_addresses else [],
|
||||
body_text=data.body_text or "",
|
||||
body_html=data.body_html,
|
||||
has_attachments=False, # allegati in Fase 5
|
||||
sent_at=None,
|
||||
received_at=None,
|
||||
)
|
||||
|
||||
# Collegamento a messaggio originale (per risposta/threading)
|
||||
if data.reply_to_message_id:
|
||||
parent = await self.db.get(Message, data.reply_to_message_id)
|
||||
if parent and parent.tenant_id == current_user.tenant_id:
|
||||
message.parent_message_id = data.reply_to_message_id
|
||||
|
||||
self.db.add(message)
|
||||
await self.db.flush()
|
||||
|
||||
# ── Crea il SendJob ───────────────────────────────────────────────────
|
||||
job = SendJob(
|
||||
tenant_id=current_user.tenant_id,
|
||||
mailbox_id=data.mailbox_id,
|
||||
message_id=message.id,
|
||||
status="pending",
|
||||
attempt_count=0,
|
||||
max_attempts=5,
|
||||
created_by=current_user.id,
|
||||
queued_at=now,
|
||||
)
|
||||
self.db.add(job)
|
||||
await self.db.flush()
|
||||
|
||||
# ── Enqueue job arq ───────────────────────────────────────────────────
|
||||
try:
|
||||
arq_pool = await _get_arq_pool()
|
||||
await arq_pool.enqueue_job("send_pec", str(job.id))
|
||||
except Exception as e:
|
||||
from app.core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.warning(
|
||||
f"[send_service] Impossibile enqueue send_pec job {job.id}: {e}. "
|
||||
"Il job resterà in stato 'pending' per pickup manuale."
|
||||
)
|
||||
# Non alziamo eccezione: il job è nel DB e verrà processato
|
||||
# dal cron di polling se disponibile
|
||||
|
||||
return job
|
||||
|
||||
# ── Lista job di invio ────────────────────────────────────────────────────
|
||||
|
||||
async def list_send_jobs(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
mailbox_id: uuid.UUID | None = None,
|
||||
status_filter: str | None = None,
|
||||
) -> tuple[list[SendJob], int]:
|
||||
"""Lista i job di invio del tenant con filtri opzionali."""
|
||||
base_q = select(SendJob).where(SendJob.tenant_id == tenant_id)
|
||||
|
||||
if mailbox_id:
|
||||
base_q = base_q.where(SendJob.mailbox_id == mailbox_id)
|
||||
if status_filter:
|
||||
base_q = base_q.where(SendJob.status == status_filter)
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar_one()
|
||||
|
||||
items_q = (
|
||||
base_q.order_by(SendJob.queued_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await self.db.execute(items_q)
|
||||
items = list(result.scalars().all())
|
||||
return items, total
|
||||
|
||||
# ── Get singolo job ───────────────────────────────────────────────────────
|
||||
|
||||
async def get_send_job(
|
||||
self,
|
||||
job_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> SendJob:
|
||||
"""Carica un singolo SendJob verificando l'appartenenza al tenant."""
|
||||
job = await self.db.get(SendJob, job_id)
|
||||
if not job or job.tenant_id != tenant_id:
|
||||
raise NotFoundError("job di invio")
|
||||
return job
|
||||
|
||||
# ── Annulla job (solo se pending) ─────────────────────────────────────────
|
||||
|
||||
async def cancel_send_job(
|
||||
self,
|
||||
job_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> SendJob:
|
||||
"""
|
||||
Annulla un job di invio se è ancora in stato 'pending'.
|
||||
Non cancella il messaggio associato.
|
||||
"""
|
||||
job = await self.get_send_job(job_id, tenant_id)
|
||||
|
||||
if job.status not in ("pending", "retrying"):
|
||||
raise ForbiddenError(
|
||||
f"Impossibile annullare: il job è in stato '{job.status}'"
|
||||
)
|
||||
|
||||
job.status = "failed"
|
||||
job.last_error = "Annullato dall'utente"
|
||||
|
||||
# Aggiorna anche il messaggio
|
||||
if job.message_id:
|
||||
msg = await self.db.get(Message, job.message_id)
|
||||
if msg and msg.state in ("queued", "draft"):
|
||||
msg.state = "failed"
|
||||
|
||||
await self.db.flush()
|
||||
return job
|
||||
@@ -34,6 +34,12 @@ dependencies = [
|
||||
# Rate limiting
|
||||
"slowapi>=0.1.9",
|
||||
|
||||
# Job queue (client per enqueue job verso worker arq)
|
||||
"arq>=0.26.1",
|
||||
|
||||
# SMTP async (per test connessione casella)
|
||||
"aiosmtplib>=3.0.0",
|
||||
|
||||
# HTTP client
|
||||
"httpx>=0.27.0",
|
||||
|
||||
|
||||
Binary file not shown.
@@ -22,6 +22,36 @@ os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_integration.db"
|
||||
os.environ["APP_ENV"] = "development"
|
||||
os.environ["APP_DEBUG"] = "false"
|
||||
|
||||
# ─── Compatibilità SQLite: tipi PostgreSQL non supportati ───────────────────
|
||||
# SQLite non conosce INET, JSONB, ARRAY – mappiamo a tipi base compatibili.
|
||||
# Deve essere eseguito PRIMA di importare i modelli ORM.
|
||||
from sqlalchemy.dialects.sqlite import base as _sqlite_base
|
||||
|
||||
|
||||
def _visit_inet(self, type_, **kw): # noqa: ARG001
|
||||
return "VARCHAR(45)"
|
||||
|
||||
def _visit_jsonb(self, type_, **kw): # noqa: ARG001
|
||||
return "JSON"
|
||||
|
||||
def _visit_array(self, type_, **kw): # noqa: ARG001
|
||||
# SQLite non ha array nativi; usiamo TEXT (serializzato come JSON)
|
||||
return "TEXT"
|
||||
|
||||
def _visit_tsvector(self, type_, **kw): # noqa: ARG001
|
||||
return "TEXT"
|
||||
|
||||
def _visit_tsquery(self, type_, **kw): # noqa: ARG001
|
||||
return "TEXT"
|
||||
|
||||
|
||||
_sqlite_base.SQLiteTypeCompiler.visit_INET = _visit_inet
|
||||
_sqlite_base.SQLiteTypeCompiler.visit_JSONB = _visit_jsonb
|
||||
_sqlite_base.SQLiteTypeCompiler.visit_ARRAY = _visit_array
|
||||
_sqlite_base.SQLiteTypeCompiler.visit_TSVECTOR = _visit_tsvector
|
||||
_sqlite_base.SQLiteTypeCompiler.visit_TSQUERY = _visit_tsquery
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
from app.database import Base
|
||||
from app.main import app
|
||||
|
||||
@@ -34,6 +64,26 @@ test_engine = create_async_engine(
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
# ─── Compatibilità SQLite: ARRAY come JSON in DML ────────────────────────────
|
||||
# SQLite non può fare binding di Python list come parametri SQL;
|
||||
# li convertiamo in JSON string prima dell'esecuzione.
|
||||
import json as _json
|
||||
from sqlalchemy import event as _sa_event
|
||||
|
||||
|
||||
@_sa_event.listens_for(test_engine.sync_engine, "before_cursor_execute", retval=True)
|
||||
def _sqlite_list_to_json(conn, cursor, statement, parameters, context, executemany):
|
||||
"""Converte le liste Python in stringhe JSON per la compatibilità con SQLite."""
|
||||
def _convert(params):
|
||||
if isinstance(params, (list, tuple)):
|
||||
return type(params)(_json.dumps(v) if isinstance(v, list) else v for v in params)
|
||||
return params
|
||||
|
||||
if executemany:
|
||||
return statement, [_convert(p) for p in parameters]
|
||||
return statement, _convert(parameters)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TestAsyncSessionLocal = async_sessionmaker(
|
||||
bind=test_engine,
|
||||
class_=AsyncSession,
|
||||
@@ -91,12 +141,18 @@ async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def demo_tenant(db_session: AsyncSession):
|
||||
"""Crea un tenant di test."""
|
||||
"""
|
||||
Crea un tenant di test con ID e slug univoci per ogni test.
|
||||
|
||||
Usiamo UUID randomici per evitare conflitti UNIQUE quando i test
|
||||
vengono eseguiti nello stesso processo e il commit non viene rollbackato.
|
||||
"""
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
tenant = Tenant(
|
||||
id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
slug="test-tenant",
|
||||
id=tenant_id,
|
||||
slug=f"test-{tenant_id.hex[:12]}",
|
||||
name="Test Tenant",
|
||||
plan="pro",
|
||||
max_mailboxes=10,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Test di integrazione – API invio PEC (POST /send e GET /send/jobs).
|
||||
|
||||
Usa SQLite in-memory + mock dell'arq pool per evitare dipendenze esterne.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import encrypt_credential
|
||||
|
||||
|
||||
# ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def active_mailbox(db_session: AsyncSession, demo_tenant):
|
||||
"""Crea una casella PEC attiva nel tenant di test."""
|
||||
from app.models.mailbox import Mailbox
|
||||
|
||||
mailbox = Mailbox(
|
||||
tenant_id=demo_tenant.id,
|
||||
email_address="test@pec.example.it",
|
||||
display_name="Test PEC",
|
||||
provider="test",
|
||||
imap_host_enc=encrypt_credential("imap.example.it"),
|
||||
imap_port_enc=encrypt_credential("993"),
|
||||
imap_user_enc=encrypt_credential("test@pec.example.it"),
|
||||
imap_pass_enc=encrypt_credential("secret"),
|
||||
imap_use_ssl=True,
|
||||
smtp_host_enc=encrypt_credential("smtp.example.it"),
|
||||
smtp_port_enc=encrypt_credential("465"),
|
||||
smtp_user_enc=encrypt_credential("test@pec.example.it"),
|
||||
smtp_pass_enc=encrypt_credential("secret"),
|
||||
smtp_use_tls=True,
|
||||
status="active",
|
||||
)
|
||||
db_session.add(mailbox)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(mailbox)
|
||||
return mailbox
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def auth_headers(admin_token: str) -> dict:
|
||||
"""Header Authorization con token admin."""
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
# ─── Test POST /send ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateSendJob:
|
||||
"""Test endpoint POST /api/v1/send."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_requires_authentication(self, client: AsyncClient, active_mailbox):
|
||||
"""Senza token → 401."""
|
||||
response = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"subject": "Test",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_missing_to_addresses(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""Lista destinatari vuota → 422 validazione."""
|
||||
with patch("app.services.send_service._get_arq_pool") as mock_pool:
|
||||
mock_pool.return_value = AsyncMock()
|
||||
mock_pool.return_value.enqueue_job = AsyncMock()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": [],
|
||||
"subject": "Test",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_missing_subject(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""Oggetto vuoto → 422 validazione."""
|
||||
with patch("app.services.send_service._get_arq_pool") as mock_pool:
|
||||
mock_pool.return_value = AsyncMock()
|
||||
response = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"subject": " ",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_mailbox_not_found(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Casella inesistente → 404."""
|
||||
with patch("app.services.send_service._get_arq_pool") as mock_pool:
|
||||
mock_pool.return_value = AsyncMock()
|
||||
response = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(uuid.uuid4()),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"subject": "Test",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_success_creates_job(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""Invio valido → 201 con SendJobResponse."""
|
||||
mock_arq = AsyncMock()
|
||||
mock_arq.enqueue_job = AsyncMock(return_value=None)
|
||||
|
||||
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
|
||||
response = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["matteo1801@spidmail.it"],
|
||||
"subject": "Test PecFlow Fase 4",
|
||||
"body_text": "Messaggio di test inviato da PecFlow.",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["status"] == "pending"
|
||||
assert data["mailbox_id"] == str(active_mailbox.id)
|
||||
assert data["attempt_count"] == 0
|
||||
assert data["max_attempts"] == 5
|
||||
assert "id" in data
|
||||
# Verifica che arq sia stato chiamato
|
||||
mock_arq.enqueue_job.assert_called_once()
|
||||
call_args = mock_arq.enqueue_job.call_args
|
||||
assert call_args[0][0] == "send_pec"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_cc(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""Invio con Cc → 201 con cc_addresses nel messaggio."""
|
||||
mock_arq = AsyncMock()
|
||||
mock_arq.enqueue_job = AsyncMock(return_value=None)
|
||||
|
||||
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
|
||||
response = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"cc_addresses": ["cc@pec.it"],
|
||||
"subject": "Test con Cc",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_arq_failure_still_returns_201(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""
|
||||
Se arq fallisce (Redis down), il job viene comunque creato nel DB
|
||||
e l'API risponde 201 (il job resta pending).
|
||||
"""
|
||||
mock_arq = AsyncMock()
|
||||
mock_arq.enqueue_job = AsyncMock(side_effect=ConnectionError("Redis non disponibile"))
|
||||
|
||||
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
|
||||
response = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"subject": "Test Redis down",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Il job deve essere creato anche se Redis fallisce
|
||||
assert response.status_code == 201
|
||||
assert response.json()["status"] == "pending"
|
||||
|
||||
|
||||
# ─── Test GET /send/jobs ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListSendJobs:
|
||||
"""Test endpoint GET /api/v1/send/jobs."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_requires_authentication(self, client: AsyncClient):
|
||||
"""Senza token → 401."""
|
||||
response = await client.get("/api/v1/send/jobs")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_empty_for_new_tenant(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Tenant senza job → lista vuota."""
|
||||
response = await client.get("/api/v1/send/jobs", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_after_send(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""Dopo un invio, la lista deve contenere almeno un job."""
|
||||
mock_arq = AsyncMock()
|
||||
mock_arq.enqueue_job = AsyncMock(return_value=None)
|
||||
|
||||
# Crea un job
|
||||
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
|
||||
await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"subject": "Job per lista",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Lista
|
||||
response = await client.get("/api/v1/send/jobs", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
|
||||
# ─── Test GET /send/jobs/{id} ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetSendJob:
|
||||
"""Test endpoint GET /api/v1/send/jobs/{job_id}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_job(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Job inesistente → 404."""
|
||||
response = await client.get(
|
||||
f"/api/v1/send/jobs/{uuid.uuid4()}", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_existing_job(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""Recupera un job esistente."""
|
||||
mock_arq = AsyncMock()
|
||||
mock_arq.enqueue_job = AsyncMock(return_value=None)
|
||||
|
||||
# Crea
|
||||
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"subject": "Job da recuperare",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert create_resp.status_code == 201
|
||||
job_id = create_resp.json()["id"]
|
||||
|
||||
# Recupera
|
||||
response = await client.get(
|
||||
f"/api/v1/send/jobs/{job_id}", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == job_id
|
||||
|
||||
|
||||
# ─── Test DELETE /send/jobs/{id} ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCancelSendJob:
|
||||
"""Test endpoint DELETE /api/v1/send/jobs/{job_id}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_pending_job(
|
||||
self, client: AsyncClient, auth_headers: dict, active_mailbox
|
||||
):
|
||||
"""Annulla un job in stato pending → 204."""
|
||||
mock_arq = AsyncMock()
|
||||
mock_arq.enqueue_job = AsyncMock(return_value=None)
|
||||
|
||||
# Crea job
|
||||
with patch("app.services.send_service._get_arq_pool", return_value=mock_arq):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/send",
|
||||
json={
|
||||
"mailbox_id": str(active_mailbox.id),
|
||||
"to_addresses": ["dest@pec.it"],
|
||||
"subject": "Job da annullare",
|
||||
"body_text": "corpo",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
job_id = create_resp.json()["id"]
|
||||
|
||||
# Annulla
|
||||
response = await client.delete(
|
||||
f"/api/v1/send/jobs/{job_id}", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verifica stato
|
||||
get_resp = await client.get(
|
||||
f"/api/v1/send/jobs/{job_id}", headers=auth_headers
|
||||
)
|
||||
assert get_resp.json()["status"] == "failed"
|
||||
assert "Annullato" in get_resp.json()["last_error"]
|
||||
@@ -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