mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Fase 2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
|
||||
+1
-1
@@ -739,7 +739,7 @@ END $$;
|
||||
|
||||
**Definition of Done:**
|
||||
- Connessione a casella Aruba PEC reale in ambiente di test (sandbox)
|
||||
- Nuovi messaggi compaiono in DB entro 30 secondi dall'arrivo
|
||||
- Verifica leggibilità mail
|
||||
- Riconnessione automatica verificata (kill connessione di rete e attesa recovery)
|
||||
|
||||
---
|
||||
|
||||
@@ -4,6 +4,8 @@ Effettua tutti i test in locale
|
||||
|
||||
Ho docker installato, compose v2 (docker cmpose senza trattino)
|
||||
|
||||
Non fare commit sul repository GitHub, ci penso io
|
||||
|
||||
Queste le caselle PEC e i loro parametri IMAP/SMTP che puoi usare per test, non effettuare invii per adesso
|
||||
|
||||
Casella: gmgspa@pec.it
|
||||
@@ -41,3 +43,8 @@ SSL: Sì
|
||||
Server SMTP: smtps.pec.mail-certificata.eu
|
||||
Porta: 465
|
||||
SSL: Sì
|
||||
|
||||
|
||||
Effettua i test di invio solo al destinatario matteo1801@spidmail.it
|
||||
|
||||
Tutto il frontend deve essere in italiano
|
||||
@@ -38,6 +38,9 @@ logs: ## Segui i log di tutti i servizi
|
||||
logs-backend: ## Segui i log del backend
|
||||
$(COMPOSE) logs -f backend
|
||||
|
||||
logs-worker: ## Segui i log del worker IMAP
|
||||
$(COMPOSE) logs -f worker
|
||||
|
||||
ps: ## Stato dei container
|
||||
$(COMPOSE) ps
|
||||
|
||||
@@ -80,6 +83,38 @@ test-integration: ## Solo integration test
|
||||
test-cov: ## Test con coverage report
|
||||
$(PYTEST) --cov=app --cov-report=term-missing --cov-report=html:/app/htmlcov -v
|
||||
|
||||
# ─── Worker ──────────────────────────────────────────────────────────────────
|
||||
|
||||
WORKER = $(COMPOSE) exec worker
|
||||
PYTEST_WORKER = $(WORKER) python -m pytest
|
||||
|
||||
test-worker: ## Esegui tutti i test del worker
|
||||
$(PYTEST_WORKER) -v --tb=short
|
||||
|
||||
test-worker-unit: ## Solo unit test del worker (no infra richiesta)
|
||||
$(PYTEST_WORKER) tests/unit -v
|
||||
|
||||
test-imap: ## Test integrazione IMAP con GreenMail (avvia GreenMail prima)
|
||||
$(PYTEST_WORKER) tests/integration -v
|
||||
|
||||
greenmail-up: ## Avvia GreenMail (server IMAP/SMTP mock per test)
|
||||
$(COMPOSE) --profile greenmail up -d greenmail
|
||||
@echo " ✅ GreenMail avviato:"
|
||||
@echo " 📬 IMAP: localhost:3143"
|
||||
@echo " 📨 SMTP: localhost:3025"
|
||||
@echo " 🌐 API: http://localhost:8080"
|
||||
|
||||
greenmail-down: ## Ferma GreenMail
|
||||
$(COMPOSE) --profile greenmail stop greenmail
|
||||
|
||||
shell-worker: ## Shell nel container worker
|
||||
$(WORKER) bash
|
||||
|
||||
worker-health: ## Verifica health del worker (tramite arq job)
|
||||
$(WORKER) python -c "import asyncio; from arq import create_pool; from arq.connections import RedisSettings; \
|
||||
async def main(): pool = await create_pool(RedisSettings()); r = await pool.enqueue_job('health_check'); print(await r.result(timeout=10)); \
|
||||
asyncio.run(main())"
|
||||
|
||||
# ─── Code quality ─────────────────────────────────────────────────────────────
|
||||
|
||||
lint: ## Esegui linting (ruff + mypy)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Router caselle PEC – CRUD + test connessione.
|
||||
|
||||
Permessi:
|
||||
- admin: CRUD completo su tutte le caselle del tenant
|
||||
- altri ruoli: solo lettura (caselle accessibili tramite PermissionService)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ForbiddenError
|
||||
from app.database import get_db
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.schemas.mailbox import (
|
||||
ConnectionTestRequest,
|
||||
ConnectionTestResult,
|
||||
MailboxCreateRequest,
|
||||
MailboxListResponse,
|
||||
MailboxResponse,
|
||||
MailboxUpdateRequest,
|
||||
)
|
||||
from app.services.mailbox_service import MailboxService
|
||||
|
||||
router = APIRouter(prefix="/mailboxes", tags=["Mailboxes"])
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _svc(db: AsyncSession) -> MailboxService:
|
||||
return MailboxService(db)
|
||||
|
||||
|
||||
def _build_response(mailbox, svc: MailboxService) -> MailboxResponse:
|
||||
return MailboxResponse(**MailboxService.to_response_dict(mailbox))
|
||||
|
||||
|
||||
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("", response_model=MailboxResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_mailbox(
|
||||
data: MailboxCreateRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> MailboxResponse:
|
||||
"""
|
||||
Crea una nuova casella PEC.
|
||||
Richiede ruolo **admin** o **super_admin**.
|
||||
Le credenziali vengono cifrate con AES-256-GCM prima della persistenza.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
mailbox = await svc.create_mailbox(
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=data,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
return _build_response(mailbox, svc)
|
||||
|
||||
|
||||
@router.get("", response_model=MailboxListResponse)
|
||||
async def list_mailboxes(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> MailboxListResponse:
|
||||
"""
|
||||
Elenca le caselle PEC.
|
||||
- Admin vede tutte le caselle del tenant.
|
||||
- Operatori vedono solo le caselle su cui hanno permesso can_read.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
|
||||
if current_user.is_admin:
|
||||
# Admin: tutte le caselle del tenant
|
||||
items, total = await svc.list_mailboxes(
|
||||
tenant_id=current_user.tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
else:
|
||||
# Operatori: caselle con permesso
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
visible_ids = await perm_svc.get_visible_mailboxes(current_user)
|
||||
|
||||
if not visible_ids:
|
||||
return MailboxListResponse(items=[], total=0, page=page, page_size=page_size)
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.mailbox import Mailbox
|
||||
from sqlalchemy import func
|
||||
|
||||
q = (
|
||||
select(Mailbox)
|
||||
.where(
|
||||
Mailbox.id.in_(visible_ids),
|
||||
Mailbox.status != "deleted",
|
||||
)
|
||||
.order_by(Mailbox.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
count_q = select(func.count()).select_from(
|
||||
select(Mailbox.id).where(
|
||||
Mailbox.id.in_(visible_ids),
|
||||
Mailbox.status != "deleted",
|
||||
).subquery()
|
||||
)
|
||||
result = await db.execute(q)
|
||||
items = list(result.scalars().all())
|
||||
total = (await db.execute(count_q)).scalar_one()
|
||||
|
||||
return MailboxListResponse(
|
||||
items=[_build_response(m, svc) for m in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{mailbox_id}", response_model=MailboxResponse)
|
||||
async def get_mailbox(
|
||||
mailbox_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> MailboxResponse:
|
||||
"""Carica una casella PEC per ID."""
|
||||
svc = _svc(db)
|
||||
|
||||
if not current_user.is_admin:
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
if not await perm_svc.check_can_read(current_user, mailbox_id):
|
||||
raise ForbiddenError("Accesso alla casella non autorizzato")
|
||||
|
||||
mailbox = await svc.get_mailbox(mailbox_id, current_user.tenant_id)
|
||||
return _build_response(mailbox, svc)
|
||||
|
||||
|
||||
@router.put("/{mailbox_id}", response_model=MailboxResponse)
|
||||
async def update_mailbox(
|
||||
mailbox_id: uuid.UUID,
|
||||
data: MailboxUpdateRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> MailboxResponse:
|
||||
"""
|
||||
Aggiorna una casella PEC.
|
||||
Richiede ruolo **admin** o **super_admin**.
|
||||
Se vengono fornite nuove credenziali, vengono ri-cifrate.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
mailbox = await svc.update_mailbox(
|
||||
mailbox_id=mailbox_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=data,
|
||||
)
|
||||
await db.commit()
|
||||
return _build_response(mailbox, svc)
|
||||
|
||||
|
||||
@router.delete("/{mailbox_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_mailbox(
|
||||
mailbox_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""
|
||||
Soft-delete di una casella PEC (status=deleted).
|
||||
Richiede ruolo **admin**.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
await svc.delete_mailbox(mailbox_id, current_user.tenant_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{mailbox_id}/test-connection",
|
||||
response_model=ConnectionTestResult,
|
||||
)
|
||||
async def test_mailbox_connection(
|
||||
mailbox_id: uuid.UUID,
|
||||
data: ConnectionTestRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> ConnectionTestResult:
|
||||
"""
|
||||
Testa la connessione IMAP o SMTP della casella.
|
||||
Non invia messaggi – solo verifica la connessione.
|
||||
Richiede ruolo **admin**.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
return await svc.test_connection(
|
||||
mailbox_id=mailbox_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=data,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
WebSocket endpoint – connessione real-time per aggiornamenti inbox.
|
||||
|
||||
URL: ws://<host>/api/v1/ws?token=<jwt_access_token>
|
||||
|
||||
Protocollo messaggi (dal server al client):
|
||||
{
|
||||
"type": "mailbox:new_message",
|
||||
"mailbox_id": "<uuid>",
|
||||
"message_id": "<uuid>",
|
||||
"subject": "...",
|
||||
"from_address": "...",
|
||||
"received_at": "2026-03-18T14:00:00Z"
|
||||
}
|
||||
|
||||
{
|
||||
"type": "mailbox:sync_error",
|
||||
"mailbox_id": "<uuid>",
|
||||
"error": "...",
|
||||
"status": "error"
|
||||
}
|
||||
|
||||
{ "type": "ping" } ← heartbeat ogni 30s per mantenere connessione viva
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
from jose import JWTError
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.core.logging import get_logger
|
||||
from app.websocket.manager import manager
|
||||
|
||||
router = APIRouter(tags=["WebSocket"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(..., description="JWT access token per autenticazione"),
|
||||
) -> None:
|
||||
"""
|
||||
WebSocket endpoint autenticato via JWT query param.
|
||||
Invia eventi real-time per il tenant dell'utente connesso.
|
||||
"""
|
||||
# Autenticazione: valida JWT
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
if payload.get("type") != "access":
|
||||
await websocket.close(code=4001, reason="Token non valido")
|
||||
return
|
||||
|
||||
tenant_id_str = payload.get("tid")
|
||||
user_id_str = payload.get("sub")
|
||||
if not tenant_id_str or not user_id_str:
|
||||
await websocket.close(code=4001, reason="Token malformato")
|
||||
return
|
||||
|
||||
tenant_id = uuid.UUID(tenant_id_str)
|
||||
user_id = uuid.UUID(user_id_str)
|
||||
|
||||
except (JWTError, ValueError):
|
||||
await websocket.close(code=4001, reason="Token non valido")
|
||||
return
|
||||
|
||||
# Connessione accettata
|
||||
await manager.connect(websocket, tenant_id)
|
||||
logger.info(
|
||||
"WS autenticato",
|
||||
extra={"user_id": str(user_id), "tenant_id": str(tenant_id)},
|
||||
)
|
||||
|
||||
# Invia ack di connessione
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
})
|
||||
except Exception:
|
||||
await manager.disconnect(websocket, tenant_id)
|
||||
return
|
||||
|
||||
# Heartbeat task
|
||||
async def send_pings() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(30)
|
||||
await websocket.send_json({"type": "ping"})
|
||||
except Exception:
|
||||
break
|
||||
|
||||
ping_task = asyncio.create_task(send_pings())
|
||||
|
||||
try:
|
||||
# Mantieni la connessione aperta, gestisci messaggi client (pong, ecc.)
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_text(), timeout=35.0)
|
||||
# Gestisci pong dal client (opzionale)
|
||||
if data == "pong":
|
||||
continue
|
||||
except asyncio.TimeoutError:
|
||||
# Nessun messaggio dal client in 35s: connessione morta
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
finally:
|
||||
ping_task.cancel()
|
||||
await manager.disconnect(websocket, tenant_id)
|
||||
+20
-2
@@ -13,10 +13,11 @@ from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import auth, permissions, tenants, users
|
||||
from app.api.v1 import auth, mailboxes, permissions, tenants, users, ws
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.database import engine
|
||||
from app.websocket.manager import redis_subscriber_loop
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
@@ -25,13 +26,28 @@ logger = get_logger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Gestione ciclo di vita dell'applicazione."""
|
||||
import asyncio
|
||||
|
||||
setup_logging()
|
||||
logger.info(
|
||||
"🚀 PecFlow Backend avviato",
|
||||
extra={"env": settings.app_env, "debug": settings.app_debug},
|
||||
)
|
||||
|
||||
# Avvia il subscriber Redis per il forward degli eventi WebSocket
|
||||
redis_task = asyncio.create_task(
|
||||
redis_subscriber_loop(settings.redis_url),
|
||||
name="redis-ws-subscriber",
|
||||
)
|
||||
|
||||
yield
|
||||
# Cleanup: chiudi connessioni DB
|
||||
|
||||
# Cleanup
|
||||
redis_task.cancel()
|
||||
try:
|
||||
await redis_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await engine.dispose()
|
||||
logger.info("🛑 PecFlow Backend fermato")
|
||||
|
||||
@@ -68,6 +84,8 @@ app.include_router(auth.router, prefix=API_PREFIX)
|
||||
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(ws.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Schemas Pydantic per le caselle PEC (mailboxes).
|
||||
Le credenziali vengono accettate in chiaro dalla API e cifrate nel service.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
# ─── Request schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class MailboxCreateRequest(BaseModel):
|
||||
"""Dati per creare una nuova casella PEC."""
|
||||
|
||||
email_address: EmailStr = Field(..., description="Indirizzo email PEC")
|
||||
display_name: str | None = Field(None, max_length=255, description="Nome visualizzato")
|
||||
provider: str | None = Field(None, max_length=100, description="Provider PEC (aruba, namirial...)")
|
||||
|
||||
# Credenziali IMAP (in chiaro, cifrate prima della persistenza)
|
||||
imap_host: str = Field(..., min_length=1, max_length=255, description="Host IMAP")
|
||||
imap_port: int = Field(993, ge=1, le=65535, description="Porta IMAP")
|
||||
imap_user: str = Field(..., min_length=1, max_length=255, description="Username IMAP")
|
||||
imap_pass: str = Field(..., min_length=1, description="Password IMAP")
|
||||
imap_use_ssl: bool = Field(True, description="Usa SSL/TLS per IMAP")
|
||||
|
||||
# Credenziali SMTP (in chiaro, cifrate prima della persistenza)
|
||||
smtp_host: str = Field(..., min_length=1, max_length=255, description="Host SMTP")
|
||||
smtp_port: int = Field(465, ge=1, le=65535, description="Porta SMTP")
|
||||
smtp_user: str = Field(..., min_length=1, max_length=255, description="Username SMTP")
|
||||
smtp_pass: str = Field(..., min_length=1, description="Password SMTP")
|
||||
smtp_use_tls: bool = Field(True, description="Usa TLS per SMTP")
|
||||
|
||||
@field_validator("imap_port", "smtp_port")
|
||||
@classmethod
|
||||
def validate_port(cls, v: int) -> int:
|
||||
if v not in range(1, 65536):
|
||||
raise ValueError("Porta non valida")
|
||||
return v
|
||||
|
||||
|
||||
class MailboxUpdateRequest(BaseModel):
|
||||
"""Aggiornamento parziale di una casella PEC."""
|
||||
|
||||
display_name: str | None = Field(None, max_length=255)
|
||||
provider: str | None = Field(None, max_length=100)
|
||||
status: Literal["active", "paused"] | None = None
|
||||
|
||||
# Aggiornamento credenziali IMAP (opzionale)
|
||||
imap_host: str | None = Field(None, min_length=1, max_length=255)
|
||||
imap_port: int | None = Field(None, ge=1, le=65535)
|
||||
imap_user: str | None = Field(None, min_length=1, max_length=255)
|
||||
imap_pass: str | None = None
|
||||
imap_use_ssl: bool | None = None
|
||||
|
||||
# Aggiornamento credenziali SMTP (opzionale)
|
||||
smtp_host: str | None = Field(None, min_length=1, max_length=255)
|
||||
smtp_port: int | None = Field(None, ge=1, le=65535)
|
||||
smtp_user: str | None = Field(None, min_length=1, max_length=255)
|
||||
smtp_pass: str | None = None
|
||||
smtp_use_tls: bool | None = None
|
||||
|
||||
|
||||
# ─── Response schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
class MailboxResponse(BaseModel):
|
||||
"""Risposta API casella PEC – NON include mai le credenziali in chiaro."""
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email_address: str
|
||||
display_name: str | None
|
||||
provider: str | None
|
||||
|
||||
# Info IMAP senza credenziali
|
||||
imap_host: str
|
||||
imap_port: int
|
||||
imap_use_ssl: bool
|
||||
|
||||
# Info SMTP senza credenziali
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_use_tls: bool
|
||||
|
||||
# Stato sync
|
||||
status: str
|
||||
last_sync_at: datetime | None
|
||||
last_sync_uid: int | None
|
||||
sync_error_msg: str | None
|
||||
sync_error_count: int
|
||||
|
||||
created_by: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MailboxListResponse(BaseModel):
|
||||
items: list[MailboxResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ConnectionTestRequest(BaseModel):
|
||||
"""Test connessione IMAP/SMTP per una casella esistente o per credenziali nuove."""
|
||||
|
||||
protocol: Literal["imap", "smtp"] = Field("imap", description="Protocollo da testare")
|
||||
|
||||
|
||||
class ConnectionTestResult(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
latency_ms: float | None = None
|
||||
capabilities: list[str] | None = None # Solo per IMAP
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Servizio caselle PEC – CRUD con cifratura AES-256-GCM delle credenziali (ADR-002).
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
|
||||
from app.core.security import decrypt_credential, encrypt_credential
|
||||
from app.models.mailbox import Mailbox
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.mailbox import (
|
||||
ConnectionTestRequest,
|
||||
ConnectionTestResult,
|
||||
MailboxCreateRequest,
|
||||
MailboxUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
class MailboxService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
# ─── CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_mailbox(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: MailboxCreateRequest,
|
||||
created_by: uuid.UUID,
|
||||
) -> Mailbox:
|
||||
"""Crea una nuova casella PEC cifrando le credenziali."""
|
||||
|
||||
# Verifica limite caselle del tenant
|
||||
tenant = await self.db.get(Tenant, tenant_id)
|
||||
if not tenant:
|
||||
raise NotFoundError("tenant")
|
||||
|
||||
count_result = await self.db.execute(
|
||||
select(func.count(Mailbox.id)).where(
|
||||
Mailbox.tenant_id == tenant_id,
|
||||
Mailbox.status != "deleted",
|
||||
)
|
||||
)
|
||||
current_count = count_result.scalar_one()
|
||||
if current_count >= tenant.max_mailboxes:
|
||||
raise ForbiddenError(
|
||||
f"Limite caselle raggiunto ({tenant.max_mailboxes}). "
|
||||
"Aggiorna il piano per aggiungerne altre."
|
||||
)
|
||||
|
||||
# Verifica unicità email nel tenant
|
||||
existing = await self.db.execute(
|
||||
select(Mailbox.id).where(
|
||||
Mailbox.tenant_id == tenant_id,
|
||||
Mailbox.email_address == str(data.email_address),
|
||||
Mailbox.status != "deleted",
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError("Casella con questo indirizzo già presente nel tenant")
|
||||
|
||||
mailbox = Mailbox(
|
||||
tenant_id=tenant_id,
|
||||
email_address=str(data.email_address),
|
||||
display_name=data.display_name,
|
||||
provider=data.provider,
|
||||
# Cifra tutte le credenziali IMAP
|
||||
imap_host_enc=encrypt_credential(data.imap_host),
|
||||
imap_port_enc=encrypt_credential(str(data.imap_port)),
|
||||
imap_user_enc=encrypt_credential(data.imap_user),
|
||||
imap_pass_enc=encrypt_credential(data.imap_pass),
|
||||
imap_use_ssl=data.imap_use_ssl,
|
||||
# Cifra tutte le credenziali SMTP
|
||||
smtp_host_enc=encrypt_credential(data.smtp_host),
|
||||
smtp_port_enc=encrypt_credential(str(data.smtp_port)),
|
||||
smtp_user_enc=encrypt_credential(data.smtp_user),
|
||||
smtp_pass_enc=encrypt_credential(data.smtp_pass),
|
||||
smtp_use_tls=data.smtp_use_tls,
|
||||
created_by=created_by,
|
||||
status="active",
|
||||
)
|
||||
self.db.add(mailbox)
|
||||
await self.db.flush()
|
||||
return mailbox
|
||||
|
||||
async def list_mailboxes(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
include_deleted: bool = False,
|
||||
) -> tuple[list[Mailbox], int]:
|
||||
"""Elenca le caselle di un tenant con paginazione."""
|
||||
base_q = select(Mailbox).where(Mailbox.tenant_id == tenant_id)
|
||||
if not include_deleted:
|
||||
base_q = base_q.where(Mailbox.status != "deleted")
|
||||
|
||||
# Count totale
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar_one()
|
||||
|
||||
# Pagina
|
||||
items_q = (
|
||||
base_q.order_by(Mailbox.created_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
|
||||
|
||||
async def get_mailbox(
|
||||
self,
|
||||
mailbox_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> Mailbox:
|
||||
"""Carica una singola casella verificando l'appartenenza al tenant."""
|
||||
mailbox = await self.db.get(Mailbox, mailbox_id)
|
||||
if not mailbox or mailbox.tenant_id != tenant_id or mailbox.status == "deleted":
|
||||
raise NotFoundError("casella")
|
||||
return mailbox
|
||||
|
||||
async def update_mailbox(
|
||||
self,
|
||||
mailbox_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
data: MailboxUpdateRequest,
|
||||
) -> Mailbox:
|
||||
"""Aggiornamento parziale di una casella. Ri-cifra le credenziali se modificate."""
|
||||
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
|
||||
|
||||
if data.display_name is not None:
|
||||
mailbox.display_name = data.display_name
|
||||
if data.provider is not None:
|
||||
mailbox.provider = data.provider
|
||||
if data.status is not None:
|
||||
mailbox.status = data.status
|
||||
|
||||
# IMAP
|
||||
if data.imap_host is not None:
|
||||
mailbox.imap_host_enc = encrypt_credential(data.imap_host)
|
||||
if data.imap_port is not None:
|
||||
mailbox.imap_port_enc = encrypt_credential(str(data.imap_port))
|
||||
if data.imap_user is not None:
|
||||
mailbox.imap_user_enc = encrypt_credential(data.imap_user)
|
||||
if data.imap_pass is not None:
|
||||
mailbox.imap_pass_enc = encrypt_credential(data.imap_pass)
|
||||
if data.imap_use_ssl is not None:
|
||||
mailbox.imap_use_ssl = data.imap_use_ssl
|
||||
|
||||
# SMTP
|
||||
if data.smtp_host is not None:
|
||||
mailbox.smtp_host_enc = encrypt_credential(data.smtp_host)
|
||||
if data.smtp_port is not None:
|
||||
mailbox.smtp_port_enc = encrypt_credential(str(data.smtp_port))
|
||||
if data.smtp_user is not None:
|
||||
mailbox.smtp_user_enc = encrypt_credential(data.smtp_user)
|
||||
if data.smtp_pass is not None:
|
||||
mailbox.smtp_pass_enc = encrypt_credential(data.smtp_pass)
|
||||
if data.smtp_use_tls is not None:
|
||||
mailbox.smtp_use_tls = data.smtp_use_tls
|
||||
|
||||
# Reset error state se il tenant ha aggiornato le credenziali
|
||||
if any(
|
||||
v is not None
|
||||
for v in [data.imap_host, data.imap_pass, data.imap_user, data.imap_port]
|
||||
):
|
||||
mailbox.sync_error_count = 0
|
||||
mailbox.sync_error_msg = None
|
||||
if mailbox.status == "error":
|
||||
mailbox.status = "active"
|
||||
|
||||
await self.db.flush()
|
||||
return mailbox
|
||||
|
||||
async def delete_mailbox(
|
||||
self,
|
||||
mailbox_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Soft-delete: imposta status=deleted."""
|
||||
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
|
||||
mailbox.status = "deleted"
|
||||
await self.db.flush()
|
||||
|
||||
# ─── Decrypt helpers (usati internamente e dal worker) ───────────────────
|
||||
|
||||
@staticmethod
|
||||
def decrypt_imap_credentials(mailbox: Mailbox) -> dict:
|
||||
"""Decifra le credenziali IMAP per uso interno."""
|
||||
return {
|
||||
"host": decrypt_credential(mailbox.imap_host_enc),
|
||||
"port": int(decrypt_credential(mailbox.imap_port_enc)),
|
||||
"user": decrypt_credential(mailbox.imap_user_enc),
|
||||
"password": decrypt_credential(mailbox.imap_pass_enc),
|
||||
"use_ssl": mailbox.imap_use_ssl,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def decrypt_smtp_credentials(mailbox: Mailbox) -> dict:
|
||||
"""Decifra le credenziali SMTP per uso interno."""
|
||||
return {
|
||||
"host": decrypt_credential(mailbox.smtp_host_enc),
|
||||
"port": int(decrypt_credential(mailbox.smtp_port_enc)),
|
||||
"user": decrypt_credential(mailbox.smtp_user_enc),
|
||||
"password": decrypt_credential(mailbox.smtp_pass_enc),
|
||||
"use_tls": mailbox.smtp_use_tls,
|
||||
}
|
||||
|
||||
# ─── Test connessione ─────────────────────────────────────────────────────
|
||||
|
||||
async def test_connection(
|
||||
self,
|
||||
mailbox_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
data: ConnectionTestRequest,
|
||||
) -> ConnectionTestResult:
|
||||
"""
|
||||
Testa la connessione IMAP o SMTP della casella.
|
||||
NON invia messaggi (conforme alle istruzioni: solo test connessione).
|
||||
"""
|
||||
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
|
||||
|
||||
if data.protocol == "imap":
|
||||
return await self._test_imap(mailbox)
|
||||
else:
|
||||
return await self._test_smtp(mailbox)
|
||||
|
||||
async def _test_imap(self, mailbox: Mailbox) -> ConnectionTestResult:
|
||||
"""Testa connessione IMAP – LOGIN + LIST + LOGOUT."""
|
||||
import asyncio
|
||||
try:
|
||||
import aioimaplib
|
||||
except ImportError:
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
message="aioimaplib non installato nel worker. Eseguire dal worker container.",
|
||||
)
|
||||
|
||||
creds = self.decrypt_imap_credentials(mailbox)
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
if creds["use_ssl"]:
|
||||
client = aioimaplib.IMAP4_SSL(
|
||||
host=creds["host"], port=creds["port"], timeout=15
|
||||
)
|
||||
else:
|
||||
client = aioimaplib.IMAP4(
|
||||
host=creds["host"], port=creds["port"], timeout=15
|
||||
)
|
||||
|
||||
await client.wait_hello_from_server()
|
||||
status, _ = await client.login(creds["user"], creds["password"])
|
||||
|
||||
if status != "OK":
|
||||
await client.logout()
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
message=f"Login fallito: {status}",
|
||||
latency_ms=round((time.monotonic() - start) * 1000, 1),
|
||||
)
|
||||
|
||||
caps = list(client.protocol.capabilities) if hasattr(client.protocol, "capabilities") else []
|
||||
await client.logout()
|
||||
|
||||
latency = round((time.monotonic() - start) * 1000, 1)
|
||||
return ConnectionTestResult(
|
||||
success=True,
|
||||
message="Connessione IMAP riuscita",
|
||||
latency_ms=latency,
|
||||
capabilities=caps,
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
message="Timeout connessione IMAP (15s)",
|
||||
latency_ms=round((time.monotonic() - start) * 1000, 1),
|
||||
)
|
||||
except Exception as e:
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
message=f"Errore connessione IMAP: {e}",
|
||||
latency_ms=round((time.monotonic() - start) * 1000, 1),
|
||||
)
|
||||
|
||||
async def _test_smtp(self, mailbox: Mailbox) -> ConnectionTestResult:
|
||||
"""Testa connessione SMTP – solo EHLO/NOOP, nessun invio (ADR-002)."""
|
||||
try:
|
||||
import aiosmtplib
|
||||
except ImportError:
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
message="aiosmtplib non installato. Installare nella fase SMTP.",
|
||||
)
|
||||
|
||||
creds = self.decrypt_smtp_credentials(mailbox)
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
smtp = aiosmtplib.SMTP(
|
||||
hostname=creds["host"],
|
||||
port=creds["port"],
|
||||
use_tls=creds["use_tls"],
|
||||
timeout=15,
|
||||
)
|
||||
await smtp.connect()
|
||||
await smtp.login(creds["user"], creds["password"])
|
||||
await smtp.quit()
|
||||
|
||||
return ConnectionTestResult(
|
||||
success=True,
|
||||
message="Connessione SMTP riuscita",
|
||||
latency_ms=round((time.monotonic() - start) * 1000, 1),
|
||||
)
|
||||
except Exception as e:
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
message=f"Errore connessione SMTP: {e}",
|
||||
latency_ms=round((time.monotonic() - start) * 1000, 1),
|
||||
)
|
||||
|
||||
# ─── Helper per costruire MailboxResponse ─────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def to_response_dict(mailbox: Mailbox) -> dict:
|
||||
"""
|
||||
Costruisce un dict con i dati decrittati per la risposta API.
|
||||
Le credenziali (user/pass) non sono incluse.
|
||||
"""
|
||||
imap_host = decrypt_credential(mailbox.imap_host_enc)
|
||||
imap_port = int(decrypt_credential(mailbox.imap_port_enc))
|
||||
smtp_host = decrypt_credential(mailbox.smtp_host_enc)
|
||||
smtp_port = int(decrypt_credential(mailbox.smtp_port_enc))
|
||||
|
||||
return {
|
||||
"id": mailbox.id,
|
||||
"tenant_id": mailbox.tenant_id,
|
||||
"email_address": mailbox.email_address,
|
||||
"display_name": mailbox.display_name,
|
||||
"provider": mailbox.provider,
|
||||
"imap_host": imap_host,
|
||||
"imap_port": imap_port,
|
||||
"imap_use_ssl": mailbox.imap_use_ssl,
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": smtp_port,
|
||||
"smtp_use_tls": mailbox.smtp_use_tls,
|
||||
"status": mailbox.status,
|
||||
"last_sync_at": mailbox.last_sync_at,
|
||||
"last_sync_uid": mailbox.last_sync_uid,
|
||||
"sync_error_msg": mailbox.sync_error_msg,
|
||||
"sync_error_count": mailbox.sync_error_count,
|
||||
"created_by": mailbox.created_by,
|
||||
"created_at": mailbox.created_at,
|
||||
"updated_at": mailbox.updated_at,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# WebSocket manager
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
WebSocket Connection Manager.
|
||||
|
||||
Gestisce le connessioni WebSocket raggruppate per tenant.
|
||||
Il worker pubblica eventi su Redis (canale ws:tenant:<tenant_id>);
|
||||
un task asyncio in background ascolta Redis e fa forward ai client WS.
|
||||
|
||||
Architettura fan-out:
|
||||
Worker → Redis PUBLISH ws:tenant:<tid> <payload>
|
||||
Backend → Redis SUBSCRIBE → forward a tutti i WebSocket del tenant
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""
|
||||
Gestisce N connessioni WebSocket per M tenant.
|
||||
Thread-safe per uso in contesto asyncio.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# tenant_id (str) → set of WebSocket
|
||||
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
# Lock per modifiche al dizionario
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket, tenant_id: uuid.UUID) -> None:
|
||||
"""Registra una nuova connessione WS per il tenant."""
|
||||
await websocket.accept()
|
||||
async with self._lock:
|
||||
self._connections[str(tenant_id)].add(websocket)
|
||||
logger.info(
|
||||
"WebSocket connesso",
|
||||
extra={"tenant_id": str(tenant_id), "total": self._count(tenant_id)},
|
||||
)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, tenant_id: uuid.UUID) -> None:
|
||||
"""Rimuove una connessione WS dal tenant."""
|
||||
async with self._lock:
|
||||
self._connections[str(tenant_id)].discard(websocket)
|
||||
if not self._connections[str(tenant_id)]:
|
||||
del self._connections[str(tenant_id)]
|
||||
logger.info(
|
||||
"WebSocket disconnesso",
|
||||
extra={"tenant_id": str(tenant_id)},
|
||||
)
|
||||
|
||||
async def broadcast_to_tenant(
|
||||
self, tenant_id: uuid.UUID | str, event: dict
|
||||
) -> None:
|
||||
"""
|
||||
Invia un evento JSON a tutti i client connessi del tenant.
|
||||
Le connessioni morte vengono rimosse silenziosamente.
|
||||
"""
|
||||
tid = str(tenant_id)
|
||||
async with self._lock:
|
||||
connections = set(self._connections.get(tid, set()))
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
payload = json.dumps(event, default=str)
|
||||
dead = set()
|
||||
|
||||
for ws in connections:
|
||||
try:
|
||||
await ws.send_text(payload)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
|
||||
if dead:
|
||||
async with self._lock:
|
||||
self._connections[tid] -= dead
|
||||
|
||||
def _count(self, tenant_id: uuid.UUID) -> int:
|
||||
return len(self._connections.get(str(tenant_id), set()))
|
||||
|
||||
@property
|
||||
def total_connections(self) -> int:
|
||||
return sum(len(v) for v in self._connections.values())
|
||||
|
||||
|
||||
# Istanza singleton – importata da main.py e dal Redis listener
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
# ─── Redis subscriber (background task) ──────────────────────────────────────
|
||||
|
||||
async def redis_subscriber_loop(redis_url: str) -> None:
|
||||
"""
|
||||
Task asyncio che si sottoscrive al canale Redis ws:* e
|
||||
fa forward degli eventi ai client WebSocket del tenant corretto.
|
||||
|
||||
Pubblicato dal worker con:
|
||||
PUBLISH ws:tenant:<tenant_id> <json_payload>
|
||||
"""
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
logger.info("Redis WS subscriber avviato", extra={"url": redis_url})
|
||||
|
||||
while True:
|
||||
try:
|
||||
client = aioredis.from_url(redis_url, decode_responses=True)
|
||||
pubsub = client.pubsub()
|
||||
await pubsub.psubscribe("ws:tenant:*") # pattern subscription
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] not in ("pmessage", "message"):
|
||||
continue
|
||||
|
||||
# Estrae tenant_id dal canale: ws:tenant:<uuid>
|
||||
channel: str = message.get("channel", "")
|
||||
if not channel.startswith("ws:tenant:"):
|
||||
continue
|
||||
|
||||
tenant_id_str = channel.removeprefix("ws:tenant:")
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(tenant_id_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(message["data"])
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
await manager.broadcast_to_tenant(tenant_uuid, payload)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Redis WS subscriber terminato")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Redis WS subscriber errore: {e}. Riconnessione in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
@@ -40,6 +40,15 @@ dependencies = [
|
||||
# Storage MinIO/S3
|
||||
"miniopy-async>=1.21.0",
|
||||
|
||||
# IMAP async (per test connessione nel backend + mailbox service)
|
||||
"aioimaplib>=2.0.0",
|
||||
|
||||
# Redis (async – per WebSocket pub/sub)
|
||||
"redis[asyncio]>=5.0.0",
|
||||
|
||||
# WebSocket
|
||||
"websockets>=12.0",
|
||||
|
||||
# Utilities
|
||||
"python-multipart>=0.0.9", # upload file
|
||||
"python-dotenv>=1.0.0",
|
||||
|
||||
Binary file not shown.
@@ -123,6 +123,58 @@ services:
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── Worker IMAP Sync (arq) ──────────────────────────────────────────────────
|
||||
worker:
|
||||
build:
|
||||
context: ./worker
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
volumes:
|
||||
- ./worker:/worker # hot-reload in development
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── GreenMail (server IMAP/SMTP mock per test) ───────────────────────────────
|
||||
greenmail:
|
||||
image: greenmail/standalone:2.1.2
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Crea utente di test: test@example.com / secret
|
||||
GREENMAIL_OPTS: >
|
||||
-Dgreenmail.setup.test.all
|
||||
-Dgreenmail.hostname=0.0.0.0
|
||||
-Dgreenmail.auth.disabled=false
|
||||
-Dgreenmail.users=test@example.com:secret
|
||||
-Dgreenmail.verbose=false
|
||||
ports:
|
||||
- "3025:3025" # SMTP
|
||||
- "3110:3110" # POP3
|
||||
- "3143:3143" # IMAP
|
||||
- "3465:3465" # SMTPS
|
||||
- "3993:3993" # IMAPS
|
||||
- "8080:8080" # API REST GreenMail
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/service/readiness"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
profiles:
|
||||
- greenmail # avviato solo con: docker compose --profile greenmail up
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── PgAdmin (solo dev) ──────────────────────────────────────────────────────
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Dipendenze di sistema
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /worker
|
||||
|
||||
# Installa dipendenze Python
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir -e ".[dev]"
|
||||
|
||||
# Copia codice sorgente
|
||||
COPY . .
|
||||
|
||||
# Healthcheck: verifica che il processo worker sia vivo
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import redis; r = redis.Redis.from_url('${REDIS_URL:-redis://redis:6379/0}'); r.ping()" || exit 1
|
||||
|
||||
# Entrypoint: avvia il worker arq
|
||||
CMD ["python", "-m", "app.main"]
|
||||
@@ -0,0 +1 @@
|
||||
# Worker PecFlow
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Configurazione worker – legge le stesse variabili d'ambiente del backend.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── Ambiente ──────────────────────────────────────────────────────────────
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────
|
||||
database_url: str = "postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow"
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────────────────────────
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
# ── MinIO ─────────────────────────────────────────────────────────────────
|
||||
minio_endpoint: str = "minio:9000"
|
||||
minio_access_key: str = "minioadmin"
|
||||
minio_secret_key: str = "minioadmin"
|
||||
minio_bucket: str = "pecflow"
|
||||
minio_use_ssl: bool = False
|
||||
|
||||
# ── Cifratura credenziali (ADR-002) ───────────────────────────────────────
|
||||
encryption_key: str = "0" * 64
|
||||
|
||||
# ── Parametri IMAP sync ───────────────────────────────────────────────────
|
||||
imap_idle_timeout_seconds: int = 1680 # 28 minuti (RFC 2177 ≤ 29 min)
|
||||
imap_polling_interval_seconds: int = 60 # polling se IDLE non supportato
|
||||
imap_max_fetch_per_cycle: int = 50 # max messaggi per ciclo di fetch
|
||||
imap_max_error_count: int = 5 # errori consecutivi → status=error
|
||||
imap_connect_timeout_seconds: int = 30 # timeout connessione iniziale
|
||||
|
||||
# ── Backoff esponenziale ──────────────────────────────────────────────────
|
||||
backoff_initial_seconds: float = 1.0
|
||||
backoff_multiplier: float = 2.0
|
||||
backoff_max_seconds: float = 300.0 # 5 minuti massimo
|
||||
|
||||
@field_validator("encryption_key")
|
||||
@classmethod
|
||||
def validate_encryption_key(cls, v: str) -> str:
|
||||
if len(v) != 64:
|
||||
raise ValueError("ENCRYPTION_KEY deve essere 64 caratteri hex")
|
||||
bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
@property
|
||||
def encryption_key_bytes(self) -> bytes:
|
||||
return bytes.fromhex(self.encryption_key)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> WorkerSettings:
|
||||
return WorkerSettings()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Connessione database per il worker – usa SQLAlchemy async (stesso stack del backend).
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncSession:
|
||||
"""Restituisce una nuova sessione DB – da usare come context manager."""
|
||||
return AsyncSessionLocal()
|
||||
@@ -0,0 +1 @@
|
||||
# IMAP sync engine
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
IMAPConnection – gestione singola connessione IMAP con:
|
||||
- IDLE con heartbeat 28 min (RFC 2177)
|
||||
- Polling fallback ogni 60s se IDLE non supportato
|
||||
- Backoff esponenziale su disconnessione
|
||||
- Aggiornamento stato mailbox su N errori consecutivi
|
||||
|
||||
Architettura (ADR-003):
|
||||
Un asyncio.Task per casella → overhead minimo, migliaia di caselle per host.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import aioimaplib
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.imap.reconnect import ExponentialBackoff
|
||||
from app.imap.sync import sync_new_messages
|
||||
from app.models import Mailbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _decrypt(enc: str) -> str:
|
||||
"""Decifra un campo credenziale AES-256-GCM."""
|
||||
import os
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
raw = base64.b64decode(enc.encode("ascii"))
|
||||
nonce = raw[:12]
|
||||
ciphertext_with_tag = raw[12:]
|
||||
return aesgcm.decrypt(nonce, ciphertext_with_tag, None).decode("utf-8")
|
||||
|
||||
|
||||
class IMAPConnection:
|
||||
"""
|
||||
Gestisce la connessione IMAP di una singola casella PEC.
|
||||
|
||||
Ciclo di vita:
|
||||
1. Connessione IMAP (SSL o plain)
|
||||
2. Login
|
||||
3. SELECT INBOX
|
||||
4. Sync iniziale (tutti i messaggi nuovi dall'ultimo UID noto)
|
||||
5. IDLE loop (o polling se IDLE non disponibile)
|
||||
6. Su EXISTS/EXPUNGE notify → fetch nuovi messaggi
|
||||
7. Su errore → backoff → torna al punto 1
|
||||
8. Su N errori consecutivi → imposta mailbox.status=error
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mailbox_id: str,
|
||||
redis_client: aioredis.Redis,
|
||||
) -> None:
|
||||
self.mailbox_id = mailbox_id
|
||||
self.redis = redis_client
|
||||
self._running = False
|
||||
self._client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL | None = None
|
||||
|
||||
async def run(self) -> None:
|
||||
"""
|
||||
Loop principale della connessione IMAP.
|
||||
Questo metodo non solleva mai eccezioni; gestisce internamente tutti gli errori.
|
||||
"""
|
||||
self._running = True
|
||||
backoff = ExponentialBackoff(label=f"mailbox:{self.mailbox_id[:8]}")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Carica mailbox dal DB
|
||||
mailbox = await db.get(Mailbox, self.mailbox_id)
|
||||
if not mailbox:
|
||||
logger.error(
|
||||
f"[{self.mailbox_id}] Mailbox non trovata in DB. Task terminato."
|
||||
)
|
||||
return
|
||||
|
||||
if mailbox.status in ("deleted", "paused"):
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] Status={mailbox.status}, task in pausa."
|
||||
)
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# Decifra credenziali
|
||||
creds = self._decrypt_creds(mailbox)
|
||||
|
||||
# Connetti e sincronizza
|
||||
await self._connect_and_run(mailbox, creds, db, backoff)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"[{self.mailbox_id}] Task IMAP cancellato.")
|
||||
self._running = False
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[{self.mailbox_id}] Errore inatteso nel loop IMAP: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
await backoff.wait(e)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Segnala al loop di terminare al prossimo ciclo."""
|
||||
self._running = False
|
||||
|
||||
# ─── Connessione e loop interno ──────────────────────────────────────────
|
||||
|
||||
async def _connect_and_run(
|
||||
self,
|
||||
mailbox: Mailbox,
|
||||
creds: dict,
|
||||
db: AsyncSession,
|
||||
backoff: ExponentialBackoff,
|
||||
) -> None:
|
||||
"""
|
||||
Tenta la connessione IMAP. Se riesce, avvia il loop IDLE/polling.
|
||||
Se fallisce, incrementa il contatore errori e aggiorna lo stato mailbox.
|
||||
"""
|
||||
try:
|
||||
client = await self._connect(creds)
|
||||
self._client = client
|
||||
except asyncio.TimeoutError:
|
||||
err_msg = f"Timeout connessione IMAP ({settings.imap_connect_timeout_seconds}s)"
|
||||
await self._record_error(mailbox, db, err_msg)
|
||||
await backoff.wait(TimeoutError(err_msg))
|
||||
return
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
await self._record_error(mailbox, db, err_msg)
|
||||
await backoff.wait(e)
|
||||
return
|
||||
|
||||
# Connessione riuscita
|
||||
backoff.reset()
|
||||
await self._reset_error_state(mailbox, db)
|
||||
|
||||
# Sync iniziale: porta il DB aggiornato fino all'ultimo UID disponibile
|
||||
logger.info(f"[{mailbox.email_address}] Sync iniziale...")
|
||||
try:
|
||||
n = await sync_new_messages(self._client, mailbox, db, self.redis)
|
||||
if n > 0:
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] Sync iniziale completata: {n} messaggi nuovi"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[{mailbox.email_address}] Errore sync iniziale: {e}", exc_info=True
|
||||
)
|
||||
|
||||
# Avvia IDLE o polling
|
||||
supports_idle = self._supports_idle(client)
|
||||
if supports_idle:
|
||||
logger.info(f"[{mailbox.email_address}] Avvio IDLE loop")
|
||||
await self._idle_loop(mailbox, db)
|
||||
else:
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] IDLE non supportato, avvio polling "
|
||||
f"ogni {settings.imap_polling_interval_seconds}s"
|
||||
)
|
||||
await self._polling_loop(mailbox, db)
|
||||
|
||||
async def _connect(
|
||||
self, creds: dict
|
||||
) -> aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL:
|
||||
"""Connette al server IMAP e fa login. Solleva eccezione su errore."""
|
||||
host = creds["host"]
|
||||
port = creds["port"]
|
||||
user = creds["user"]
|
||||
password = creds["password"]
|
||||
use_ssl = creds["use_ssl"]
|
||||
|
||||
logger.info(f"Connessione IMAP {user}@{host}:{port} ssl={use_ssl}")
|
||||
|
||||
if use_ssl:
|
||||
client = aioimaplib.IMAP4_SSL(
|
||||
host=host,
|
||||
port=port,
|
||||
timeout=settings.imap_connect_timeout_seconds,
|
||||
)
|
||||
else:
|
||||
client = aioimaplib.IMAP4(
|
||||
host=host,
|
||||
port=port,
|
||||
timeout=settings.imap_connect_timeout_seconds,
|
||||
)
|
||||
|
||||
await asyncio.wait_for(
|
||||
client.wait_hello_from_server(),
|
||||
timeout=settings.imap_connect_timeout_seconds,
|
||||
)
|
||||
|
||||
status, _ = await client.login(user, password)
|
||||
if status != "OK":
|
||||
await client.logout()
|
||||
raise ConnectionError(f"Login IMAP fallito: status={status}")
|
||||
|
||||
status, _ = await client.select("INBOX")
|
||||
if status != "OK":
|
||||
await client.logout()
|
||||
raise ConnectionError(f"SELECT INBOX fallito: status={status}")
|
||||
|
||||
logger.info(f"IMAP connesso: {user}@{host}:{port}")
|
||||
return client
|
||||
|
||||
def _supports_idle(
|
||||
self, client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL
|
||||
) -> bool:
|
||||
"""Verifica se il server supporta IDLE."""
|
||||
try:
|
||||
caps = client.protocol.capabilities
|
||||
return "IDLE" in caps
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ─── IDLE loop ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _idle_loop(self, mailbox: Mailbox, db: AsyncSession) -> None:
|
||||
"""
|
||||
Loop IMAP IDLE con heartbeat ogni 28 minuti (RFC 2177).
|
||||
|
||||
Quando il server segnala EXISTS (nuovi messaggi) → sync.
|
||||
Ogni 28 minuti → DONE + re-IDLE per mantenere connessione viva.
|
||||
"""
|
||||
client = self._client
|
||||
idle_timeout = settings.imap_idle_timeout_seconds # 28 min
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Avvia IDLE
|
||||
await client.idle_start(timeout=idle_timeout)
|
||||
|
||||
# Attendi server push con timeout (heartbeat)
|
||||
try:
|
||||
server_push = await asyncio.wait_for(
|
||||
client.wait_server_push(),
|
||||
timeout=float(idle_timeout),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Heartbeat: nessun nuovo messaggio in 28 minuti → re-IDLE
|
||||
server_push = []
|
||||
|
||||
# Termina IDLE
|
||||
await client.idle_done()
|
||||
|
||||
# Controlla se ci sono nuovi messaggi (EXISTS)
|
||||
has_new = any(
|
||||
b"EXISTS" in (line if isinstance(line, bytes) else line.encode())
|
||||
for line in server_push
|
||||
if line
|
||||
)
|
||||
|
||||
if has_new:
|
||||
logger.debug(
|
||||
f"[{mailbox.email_address}] EXISTS ricevuto, sync..."
|
||||
)
|
||||
# Ricarica mailbox dal DB per avere last_sync_uid aggiornato
|
||||
await db.refresh(mailbox)
|
||||
n = await sync_new_messages(client, mailbox, db, self.redis)
|
||||
if n > 0:
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] {n} nuovi messaggi sincronizzati"
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
await client.idle_done()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except (ConnectionError, IOError, OSError) as e:
|
||||
logger.warning(
|
||||
f"[{mailbox.email_address}] Connessione IDLE persa: {e}"
|
||||
)
|
||||
raise # propaga al loop esterno per backoff
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[{mailbox.email_address}] Errore IDLE loop: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
# ─── Polling loop ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _polling_loop(self, mailbox: Mailbox, db: AsyncSession) -> None:
|
||||
"""
|
||||
Polling IMAP ogni N secondi quando IDLE non è supportato.
|
||||
Esegue NOOP + SEARCH UID per verificare nuovi messaggi.
|
||||
"""
|
||||
client = self._client
|
||||
interval = settings.imap_polling_interval_seconds
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
# NOOP per mantenere connessione viva
|
||||
try:
|
||||
await client.noop()
|
||||
except Exception:
|
||||
raise ConnectionError("Connessione IMAP persa durante NOOP")
|
||||
|
||||
# Ricarica mailbox e controlla nuovi UID
|
||||
await db.refresh(mailbox)
|
||||
n = await sync_new_messages(client, mailbox, db, self.redis)
|
||||
if n > 0:
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] Polling: {n} nuovi messaggi"
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except (ConnectionError, IOError, OSError) as e:
|
||||
logger.warning(
|
||||
f"[{mailbox.email_address}] Connessione polling persa: {e}"
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[{mailbox.email_address}] Errore polling loop: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
# ─── Error management ─────────────────────────────────────────────────────
|
||||
|
||||
async def _record_error(
|
||||
self, mailbox: Mailbox, db: AsyncSession, error_msg: str
|
||||
) -> None:
|
||||
"""
|
||||
Incrementa sync_error_count. Se supera il limite → status=error.
|
||||
Pubblica evento Redis di errore.
|
||||
"""
|
||||
import json
|
||||
|
||||
mailbox.sync_error_count += 1
|
||||
mailbox.sync_error_msg = error_msg[:500]
|
||||
|
||||
if mailbox.sync_error_count >= settings.imap_max_error_count:
|
||||
if mailbox.status != "error":
|
||||
mailbox.status = "error"
|
||||
logger.error(
|
||||
f"[{mailbox.email_address}] Troppe anomalie "
|
||||
f"({mailbox.sync_error_count}), status=error"
|
||||
)
|
||||
# Pubblica evento WebSocket di errore
|
||||
try:
|
||||
event = {
|
||||
"type": "mailbox:sync_error",
|
||||
"mailbox_id": str(mailbox.id),
|
||||
"error": error_msg,
|
||||
"status": "error",
|
||||
}
|
||||
channel = f"ws:tenant:{mailbox.tenant_id}"
|
||||
await self.redis.publish(channel, json.dumps(event))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
async def _reset_error_state(
|
||||
self, mailbox: Mailbox, db: AsyncSession
|
||||
) -> None:
|
||||
"""Resetta il contatore errori dopo una connessione riuscita."""
|
||||
if mailbox.sync_error_count > 0 or mailbox.status == "error":
|
||||
mailbox.sync_error_count = 0
|
||||
mailbox.sync_error_msg = None
|
||||
if mailbox.status == "error":
|
||||
mailbox.status = "active"
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# ─── Decrypt credentials ──────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _decrypt_creds(mailbox: Mailbox) -> dict:
|
||||
"""Decifra le credenziali IMAP dalla mailbox."""
|
||||
return {
|
||||
"host": _decrypt(mailbox.imap_host_enc),
|
||||
"port": int(_decrypt(mailbox.imap_port_enc)),
|
||||
"user": _decrypt(mailbox.imap_user_enc),
|
||||
"password": _decrypt(mailbox.imap_pass_enc),
|
||||
"use_ssl": mailbox.imap_use_ssl,
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
MailboxPool – orchestratore async per N caselle IMAP in parallelo.
|
||||
|
||||
All'avvio del worker:
|
||||
1. Carica tutte le mailbox con status='active' dal DB
|
||||
2. Avvia un asyncio.Task per ogni casella (IMAPConnection.run)
|
||||
3. Monitora i task: se uno muore, lo riavvia dopo un breve delay
|
||||
4. Osserva eventi Redis per caselle aggiunte/rimosse/aggiornate a runtime
|
||||
|
||||
ADR-003: Un task async per casella – overhead < 10MB per casella.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.imap.connection import IMAPConnection
|
||||
from app.models import Mailbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# Canale Redis per eventi di gestione caselle
|
||||
MAILBOX_EVENTS_CHANNEL = "mailbox:events"
|
||||
|
||||
|
||||
class MailboxPool:
|
||||
"""
|
||||
Gestisce il pool di task IMAP asincroni.
|
||||
|
||||
Un task per casella, monitorato e riavviato automaticamente in caso di crash.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client: aioredis.Redis) -> None:
|
||||
self.redis = redis_client
|
||||
# mailbox_id (str) → asyncio.Task
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
# mailbox_id (str) → IMAPConnection
|
||||
self._connections: dict[str, IMAPConnection] = {}
|
||||
self._running = False
|
||||
self._monitor_task: asyncio.Task | None = None
|
||||
self._events_task: asyncio.Task | None = None
|
||||
|
||||
# ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
Carica le mailbox attive dal DB e avvia i task IMAP.
|
||||
Da chiamare nell'on_startup del worker arq.
|
||||
"""
|
||||
self._running = True
|
||||
logger.info("MailboxPool: avvio in corso...")
|
||||
|
||||
mailbox_ids = await self._load_active_mailbox_ids()
|
||||
logger.info(f"MailboxPool: {len(mailbox_ids)} caselle attive trovate")
|
||||
|
||||
for mid in mailbox_ids:
|
||||
await self._start_task(mid)
|
||||
|
||||
# Task di monitoraggio: riavvia task morti
|
||||
self._monitor_task = asyncio.create_task(
|
||||
self._monitor_loop(), name="mailbox-pool-monitor"
|
||||
)
|
||||
|
||||
# Task listener Redis: gestisce caselle aggiunte/rimosse a runtime
|
||||
self._events_task = asyncio.create_task(
|
||||
self._events_listener(), name="mailbox-pool-events"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"MailboxPool avviato: {len(self._tasks)} task IMAP in esecuzione"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""
|
||||
Ferma tutti i task IMAP.
|
||||
Da chiamare nell'on_shutdown del worker arq.
|
||||
"""
|
||||
logger.info("MailboxPool: arresto in corso...")
|
||||
self._running = False
|
||||
|
||||
# Cancella task di sistema
|
||||
for task in [self._monitor_task, self._events_task]:
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Ferma tutte le connessioni IMAP
|
||||
for conn in self._connections.values():
|
||||
conn.stop()
|
||||
|
||||
# Cancella i task
|
||||
if self._tasks:
|
||||
await asyncio.gather(
|
||||
*[t for t in self._tasks.values() if not t.done()],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
self._tasks.clear()
|
||||
self._connections.clear()
|
||||
logger.info("MailboxPool: tutti i task fermati")
|
||||
|
||||
# ─── Gestione task individuali ────────────────────────────────────────────
|
||||
|
||||
async def add_mailbox(self, mailbox_id: str) -> None:
|
||||
"""Aggiunge e avvia una nuova casella al pool a runtime."""
|
||||
if mailbox_id in self._tasks and not self._tasks[mailbox_id].done():
|
||||
logger.debug(f"MailboxPool: {mailbox_id[:8]} già nel pool")
|
||||
return
|
||||
await self._start_task(mailbox_id)
|
||||
logger.info(f"MailboxPool: casella aggiunta {mailbox_id[:8]}")
|
||||
|
||||
async def remove_mailbox(self, mailbox_id: str) -> None:
|
||||
"""Ferma e rimuove una casella dal pool a runtime."""
|
||||
if mailbox_id in self._connections:
|
||||
self._connections[mailbox_id].stop()
|
||||
|
||||
if mailbox_id in self._tasks:
|
||||
task = self._tasks[mailbox_id]
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=5.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
del self._tasks[mailbox_id]
|
||||
|
||||
if mailbox_id in self._connections:
|
||||
del self._connections[mailbox_id]
|
||||
|
||||
logger.info(f"MailboxPool: casella rimossa {mailbox_id[:8]}")
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
"""Numero di task IMAP attivi."""
|
||||
return sum(1 for t in self._tasks.values() if not t.done())
|
||||
|
||||
# ─── Loop di monitoraggio ─────────────────────────────────────────────────
|
||||
|
||||
async def _monitor_loop(self) -> None:
|
||||
"""
|
||||
Ogni 30 secondi verifica i task morti e li riavvia.
|
||||
Rimuove anche task di caselle che non esistono più nel DB.
|
||||
"""
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(30)
|
||||
|
||||
dead_ids = [
|
||||
mid for mid, task in self._tasks.items()
|
||||
if task.done() and not task.cancelled()
|
||||
]
|
||||
|
||||
if dead_ids:
|
||||
logger.info(
|
||||
f"MailboxPool monitor: {len(dead_ids)} task morti, riavvio..."
|
||||
)
|
||||
active_ids = await self._load_active_mailbox_ids()
|
||||
active_set = {str(mid) for mid in active_ids}
|
||||
|
||||
for mid in dead_ids:
|
||||
if mid in active_set:
|
||||
logger.info(
|
||||
f"MailboxPool: riavvio task {mid[:8]}..."
|
||||
)
|
||||
await self._start_task(mid)
|
||||
else:
|
||||
# Casella non più attiva, rimuovi dal pool
|
||||
logger.info(
|
||||
f"MailboxPool: casella {mid[:8]} non più attiva, rimossa"
|
||||
)
|
||||
self._tasks.pop(mid, None)
|
||||
self._connections.pop(mid, None)
|
||||
|
||||
# Aggiungi caselle nuove attive
|
||||
active_ids = await self._load_active_mailbox_ids()
|
||||
for mid in active_ids:
|
||||
mid_str = str(mid)
|
||||
if mid_str not in self._tasks or self._tasks[mid_str].done():
|
||||
logger.info(
|
||||
f"MailboxPool: rilevata nuova casella attiva {mid_str[:8]}"
|
||||
)
|
||||
await self._start_task(mid_str)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"MailboxPool monitor errore: {e}", exc_info=True)
|
||||
|
||||
# ─── Listener eventi Redis ─────────────────────────────────────────────────
|
||||
|
||||
async def _events_listener(self) -> None:
|
||||
"""
|
||||
Ascolta eventi Redis per aggiungere/rimuovere caselle a runtime.
|
||||
|
||||
Formato evento: {"action": "add"|"remove"|"refresh", "mailbox_id": "<uuid>"}
|
||||
Pubblicare con: PUBLISH mailbox:events '{"action":"add","mailbox_id":"<uuid>"}'
|
||||
"""
|
||||
pubsub = self.redis.pubsub()
|
||||
await pubsub.subscribe(MAILBOX_EVENTS_CHANNEL)
|
||||
|
||||
logger.debug(f"MailboxPool: in ascolto su Redis {MAILBOX_EVENTS_CHANNEL}")
|
||||
|
||||
try:
|
||||
async for message in pubsub.listen():
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
if message["type"] != "message":
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(message["data"])
|
||||
action = data.get("action")
|
||||
mid = data.get("mailbox_id")
|
||||
|
||||
if not action or not mid:
|
||||
continue
|
||||
|
||||
if action == "add":
|
||||
await self.add_mailbox(mid)
|
||||
elif action == "remove":
|
||||
await self.remove_mailbox(mid)
|
||||
elif action == "refresh":
|
||||
# Rimuovi e riavvia (utile dopo cambio credenziali)
|
||||
await self.remove_mailbox(mid)
|
||||
await asyncio.sleep(1)
|
||||
await self.add_mailbox(mid)
|
||||
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f"MailboxPool: evento Redis malformato: {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await pubsub.unsubscribe(MAILBOX_EVENTS_CHANNEL)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"MailboxPool events listener errore: {e}", exc_info=True)
|
||||
|
||||
# ─── Private ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _start_task(self, mailbox_id: str) -> None:
|
||||
"""Crea e avvia un nuovo task IMAPConnection per la casella."""
|
||||
conn = IMAPConnection(
|
||||
mailbox_id=mailbox_id,
|
||||
redis_client=self.redis,
|
||||
)
|
||||
self._connections[mailbox_id] = conn
|
||||
|
||||
task = asyncio.create_task(
|
||||
conn.run(),
|
||||
name=f"imap-{mailbox_id[:8]}",
|
||||
)
|
||||
self._tasks[mailbox_id] = task
|
||||
|
||||
async def _load_active_mailbox_ids(self) -> list[str]:
|
||||
"""Carica dal DB gli UUID di tutte le caselle con status=active."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(Mailbox.id).where(Mailbox.status == "active")
|
||||
)
|
||||
return [str(row[0]) for row in result.all()]
|
||||
except Exception as e:
|
||||
logger.error(f"MailboxPool: errore caricamento caselle: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Strategia backoff esponenziale per riconnessioni IMAP.
|
||||
|
||||
Parametri configurabili in WorkerSettings:
|
||||
backoff_initial_seconds = 1.0 (primo wait)
|
||||
backoff_multiplier = 2.0 (moltiplicatore)
|
||||
backoff_max_seconds = 300.0 (tetto massimo: 5 minuti)
|
||||
|
||||
Sequenza di attesa: 1s → 2s → 4s → 8s → 16s → 32s → 64s → 128s → 256s → 300s → 300s → ...
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class ExponentialBackoff:
|
||||
"""
|
||||
Gestisce il backoff esponenziale con jitter opzionale.
|
||||
|
||||
Uso:
|
||||
backoff = ExponentialBackoff(label="casella@pec.it")
|
||||
while True:
|
||||
try:
|
||||
await connect()
|
||||
backoff.reset() # connessione riuscita → resetta backoff
|
||||
await run_loop()
|
||||
except Exception as e:
|
||||
await backoff.wait(e) # attende prima di ritentare
|
||||
"""
|
||||
|
||||
def __init__(self, label: str = "", jitter: bool = True) -> None:
|
||||
self.label = label
|
||||
self.jitter = jitter
|
||||
self._attempt = 0
|
||||
self._current_wait = settings.backoff_initial_seconds
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Resetta il backoff dopo una connessione riuscita."""
|
||||
if self._attempt > 0:
|
||||
logger.info(
|
||||
f"[{self.label}] Connessione ristabilita dopo {self._attempt} tentativi"
|
||||
)
|
||||
self._attempt = 0
|
||||
self._current_wait = settings.backoff_initial_seconds
|
||||
|
||||
async def wait(self, error: Exception | None = None) -> None:
|
||||
"""
|
||||
Attende il tempo calcolato dal backoff, poi incrementa per il prossimo ciclo.
|
||||
Aggiunge jitter ±10% per evitare thundering herd su N caselle.
|
||||
"""
|
||||
self._attempt += 1
|
||||
wait_time = min(self._current_wait, settings.backoff_max_seconds)
|
||||
|
||||
if self.jitter:
|
||||
jitter_range = wait_time * 0.1
|
||||
wait_time += random.uniform(-jitter_range, jitter_range)
|
||||
wait_time = max(0.5, wait_time)
|
||||
|
||||
logger.warning(
|
||||
f"[{self.label}] Tentativo {self._attempt} fallito"
|
||||
f"{f': {error}' if error else ''}. "
|
||||
f"Riconnessione in {wait_time:.1f}s"
|
||||
)
|
||||
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# Incrementa per il prossimo tentativo
|
||||
self._current_wait = min(
|
||||
self._current_wait * settings.backoff_multiplier,
|
||||
settings.backoff_max_seconds,
|
||||
)
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
return self._attempt
|
||||
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
Logica di sincronizzazione messaggi IMAP.
|
||||
|
||||
Responsabilità:
|
||||
1. Fetch della lista UID > last_sync_uid
|
||||
2. Download envelope + raw EML per ogni UID
|
||||
3. Parsing base degli header (subject, from, to, date)
|
||||
4. Salvataggio in tabella messages
|
||||
5. Upload raw EML su MinIO
|
||||
6. Aggiornamento last_sync_uid e last_sync_at sulla mailbox
|
||||
7. Pubblicazione evento Redis per notifica WebSocket
|
||||
"""
|
||||
|
||||
import email
|
||||
import email.header
|
||||
import email.utils
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import aioimaplib
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Mailbox, Message
|
||||
from app.storage.minio_client import upload_eml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# ─── Helper: decodifica header email ─────────────────────────────────────────
|
||||
|
||||
def _decode_header(header_value: str | None) -> str | None:
|
||||
"""Decodifica header RFC 2047 (es. =?utf-8?b?...?=) in stringa Python."""
|
||||
if not header_value:
|
||||
return None
|
||||
try:
|
||||
parts = email.header.decode_header(header_value)
|
||||
decoded = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(part)
|
||||
return "".join(decoded).strip()
|
||||
except Exception:
|
||||
return str(header_value)
|
||||
|
||||
|
||||
def _extract_addresses(field: str | None) -> list[str]:
|
||||
"""Estrae lista di indirizzi email da un campo To/Cc."""
|
||||
if not field:
|
||||
return []
|
||||
try:
|
||||
addresses = email.utils.getaddresses([field])
|
||||
return [addr for _, addr in addresses if addr]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _parse_date(date_str: str | None) -> datetime | None:
|
||||
"""Converte stringa data RFC 2822 in datetime con timezone."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
parsed = email.utils.parsedate_to_datetime(date_str)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _classify_pec_type(msg: email.message.Message) -> str:
|
||||
"""
|
||||
Classifica il tipo PEC dal header X-Ricevuta / X-TipoRicevuta.
|
||||
Fase 3 fa il parsing completo; qui classifichiamo al meglio possibile.
|
||||
"""
|
||||
x_ricevuta = msg.get("X-Ricevuta", "").lower()
|
||||
x_tipo = msg.get("X-TipoRicevuta", "").lower()
|
||||
|
||||
TYPE_MAP = {
|
||||
"accettazione": "accettazione",
|
||||
"non-accettazione": "non_accettazione",
|
||||
"presa-in-carico": "presa_in_carico",
|
||||
"avvenuta-consegna": "avvenuta_consegna",
|
||||
"mancata-consegna": "mancata_consegna",
|
||||
"errore-consegna": "errore_consegna",
|
||||
"preavviso-mancata-consegna": "preavviso_mancata_consegna",
|
||||
"rilevazione-virus": "rilevazione_virus",
|
||||
}
|
||||
|
||||
value = x_tipo or x_ricevuta
|
||||
return TYPE_MAP.get(value, "posta_certificata")
|
||||
|
||||
|
||||
def _parse_eml(raw_bytes: bytes) -> dict:
|
||||
"""
|
||||
Parsing di base di un EML – estrae i campi necessari per la tabella messages.
|
||||
Il parsing completo (body, allegati, EML-in-EML) è in Fase 3.
|
||||
"""
|
||||
try:
|
||||
msg = email.message_from_bytes(raw_bytes)
|
||||
except Exception as e:
|
||||
logger.warning(f"Errore parsing EML: {e}")
|
||||
return {}
|
||||
|
||||
subject = _decode_header(msg.get("Subject"))
|
||||
from_addr = email.utils.parseaddr(msg.get("From", ""))[1] or None
|
||||
to_addrs = _extract_addresses(msg.get("To"))
|
||||
cc_addrs = _extract_addresses(msg.get("Cc"))
|
||||
message_id = msg.get("Message-ID", "").strip() or None
|
||||
date = _parse_date(msg.get("Date"))
|
||||
pec_type = _classify_pec_type(msg)
|
||||
|
||||
# Estrazione body text/html (best-effort – Fase 3 fa il parsing completo)
|
||||
body_text = None
|
||||
body_html = None
|
||||
has_attachments = False
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
disp = part.get("Content-Disposition", "")
|
||||
if "attachment" in disp or "inline" in disp:
|
||||
if part.get_filename():
|
||||
has_attachments = True
|
||||
elif ct == "text/plain" and body_text is None:
|
||||
try:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
body_text = part.get_payload(decode=True).decode(charset, errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
elif ct == "text/html" and body_html is None:
|
||||
try:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
body_html = part.get_payload(decode=True).decode(charset, errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
ct = msg.get_content_type()
|
||||
try:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
if ct == "text/plain":
|
||||
body_text = payload.decode(charset, errors="replace")
|
||||
elif ct == "text/html":
|
||||
body_html = payload.decode(charset, errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"subject": subject,
|
||||
"from_address": from_addr,
|
||||
"to_addresses": to_addrs if to_addrs else None,
|
||||
"cc_addresses": cc_addrs if cc_addrs else None,
|
||||
"message_id_header": message_id,
|
||||
"sent_at": date,
|
||||
"pec_type": pec_type,
|
||||
"body_text": body_text,
|
||||
"body_html": body_html,
|
||||
"has_attachments": has_attachments,
|
||||
}
|
||||
|
||||
|
||||
# ─── Core sync function ───────────────────────────────────────────────────────
|
||||
|
||||
async def sync_new_messages(
|
||||
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
||||
mailbox: Mailbox,
|
||||
db: AsyncSession,
|
||||
redis_client: aioredis.Redis,
|
||||
) -> int:
|
||||
"""
|
||||
Sincronizza i messaggi nuovi (UID > last_sync_uid) per la mailbox data.
|
||||
|
||||
Returns:
|
||||
Numero di nuovi messaggi sincronizzati.
|
||||
"""
|
||||
last_uid = mailbox.last_sync_uid or 0
|
||||
search_range = f"{last_uid + 1}:*"
|
||||
|
||||
# ── SEARCH UID > last_sync_uid ─────────────────────────────────────────────
|
||||
# aioimaplib non supporta uid('SEARCH',...) → usare search('UID', range)
|
||||
# che invia "SEARCH UID n:*" e restituisce numeri di sequenza
|
||||
try:
|
||||
status, search_data = await imap_client.search("UID", search_range)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{mailbox.email_address}] SEARCH fallito: {e}")
|
||||
return 0
|
||||
|
||||
if status != "OK":
|
||||
logger.warning(
|
||||
f"[{mailbox.email_address}] SEARCH status={status} data={search_data}"
|
||||
)
|
||||
return 0
|
||||
|
||||
# search() restituisce numeri di sequenza (non UID)
|
||||
raw_seqs = b" ".join(
|
||||
d if isinstance(d, bytes) else d.encode() for d in search_data
|
||||
).decode("ascii", errors="ignore").split()
|
||||
|
||||
seq_numbers = [s for s in raw_seqs if s.isdigit()]
|
||||
if not seq_numbers:
|
||||
return 0
|
||||
|
||||
# Limita il numero di fetch per ciclo
|
||||
seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle]
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] Trovati {len(seq_numbers)} messaggi nuovi da sincronizzare"
|
||||
)
|
||||
|
||||
synced_count = 0
|
||||
max_uid_synced = last_uid
|
||||
|
||||
for seq in seq_numbers:
|
||||
try:
|
||||
uid, synced = await _fetch_and_save_message_by_seq(
|
||||
imap_client=imap_client,
|
||||
seq=seq,
|
||||
last_uid=last_uid,
|
||||
mailbox=mailbox,
|
||||
db=db,
|
||||
redis_client=redis_client,
|
||||
)
|
||||
if synced and uid and uid > max_uid_synced:
|
||||
synced_count += 1
|
||||
max_uid_synced = uid
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[{mailbox.email_address}] Errore fetch seq {seq}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Aggiorna last_sync_uid e last_sync_at
|
||||
if max_uid_synced > last_uid:
|
||||
mailbox.last_sync_uid = max_uid_synced
|
||||
mailbox.last_sync_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
return synced_count
|
||||
|
||||
|
||||
async def _fetch_and_save_message_by_seq(
|
||||
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
||||
seq: str,
|
||||
last_uid: int,
|
||||
mailbox: Mailbox,
|
||||
db: AsyncSession,
|
||||
redis_client: aioredis.Redis,
|
||||
) -> tuple[int | None, bool]:
|
||||
"""
|
||||
Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID).
|
||||
Include UID nella richiesta FETCH per estrarlo dalla risposta.
|
||||
|
||||
Returns:
|
||||
(uid, saved): UID del messaggio e True se salvato, False altrimenti.
|
||||
"""
|
||||
# FETCH seq (UID RFC822 RFC822.SIZE)
|
||||
try:
|
||||
status, fetch_data = await imap_client.fetch(seq, "(UID RFC822 RFC822.SIZE)")
|
||||
except Exception as e:
|
||||
logger.error(f"[{mailbox.email_address}] FETCH seq {seq} fallito: {e}")
|
||||
return None, False
|
||||
|
||||
if status != "OK" or not fetch_data:
|
||||
logger.warning(
|
||||
f"[{mailbox.email_address}] FETCH seq {seq} risposta vuota: {status}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
# Debug: mostra la struttura di fetch_data
|
||||
items_info = [(type(x).__name__, len(x) if isinstance(x, (bytes, str)) else str(x)) for x in fetch_data]
|
||||
logger.debug(f"[{mailbox.email_address}] fetch_data seq {seq}: {items_info}")
|
||||
|
||||
# Estrae UID, raw EML e size dalla risposta.
|
||||
# NOTA CRITICA: aioimaplib restituisce il corpo EML come `bytearray` (non `bytes`)!
|
||||
# [0] bytes → FETCH response header con UID e RFC822.SIZE
|
||||
# [1] bytearray → raw EML (il corpo del messaggio)
|
||||
# [2] bytes → ')' (chiusura)
|
||||
# [3] bytes → riga OK finale
|
||||
uid: int | None = None
|
||||
raw_eml: bytes | None = None
|
||||
size_bytes: int | None = None
|
||||
|
||||
for item in fetch_data:
|
||||
if isinstance(item, bytearray):
|
||||
# Questo è il corpo del messaggio EML
|
||||
if len(item) > 200:
|
||||
raw_eml = bytes(item)
|
||||
elif isinstance(item, bytes):
|
||||
# Risposta header – estrae UID e RFC822.SIZE
|
||||
item_str = item.decode("ascii", errors="ignore")
|
||||
uid_match = re.search(r"UID\s+(\d+)", item_str)
|
||||
if uid_match:
|
||||
uid = int(uid_match.group(1))
|
||||
size_match = re.search(r"RFC822\.SIZE\s+(\d+)", item_str)
|
||||
if size_match:
|
||||
size_bytes = int(size_match.group(1))
|
||||
elif isinstance(item, str):
|
||||
uid_match = re.search(r"UID\s+(\d+)", item)
|
||||
if uid_match:
|
||||
uid = int(uid_match.group(1))
|
||||
size_match = re.search(r"RFC822\.SIZE\s+(\d+)", item)
|
||||
if size_match:
|
||||
size_bytes = int(size_match.group(1))
|
||||
|
||||
if uid is None or uid <= last_uid:
|
||||
# Questo messaggio ha un UID <= last_uid, non va sincronizzato
|
||||
return uid, False
|
||||
|
||||
if not raw_eml:
|
||||
logger.warning(f"[{mailbox.email_address}] seq {seq} UID {uid}: body mancante")
|
||||
return uid, False
|
||||
|
||||
if size_bytes is None:
|
||||
size_bytes = len(raw_eml)
|
||||
|
||||
return uid, await _save_message(
|
||||
uid=uid,
|
||||
raw_eml=raw_eml,
|
||||
size_bytes=size_bytes,
|
||||
mailbox=mailbox,
|
||||
db=db,
|
||||
redis_client=redis_client,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_and_save_message(
|
||||
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
||||
uid: int,
|
||||
mailbox: Mailbox,
|
||||
db: AsyncSession,
|
||||
redis_client: aioredis.Redis,
|
||||
) -> bool:
|
||||
"""
|
||||
Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot).
|
||||
Usa UID FETCH (aioimaplib uid() method).
|
||||
"""
|
||||
existing = await db.execute(
|
||||
select(Message.id).where(
|
||||
Message.mailbox_id == mailbox.id,
|
||||
Message.imap_uid == uid,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False
|
||||
|
||||
try:
|
||||
status, fetch_data = await imap_client.uid("FETCH", str(uid), "(RFC822 RFC822.SIZE)")
|
||||
except Exception as e:
|
||||
logger.error(f"[{mailbox.email_address}] UID FETCH {uid} fallito: {e}")
|
||||
return False
|
||||
|
||||
if status != "OK" or not fetch_data:
|
||||
return False
|
||||
|
||||
raw_eml: bytes | None = None
|
||||
size_bytes: int | None = None
|
||||
for item in fetch_data:
|
||||
if isinstance(item, bytes) and len(item) > 100:
|
||||
raw_eml = item
|
||||
elif isinstance(item, (bytes, str)):
|
||||
s = item.decode("ascii", errors="ignore") if isinstance(item, bytes) else item
|
||||
m = re.search(r"RFC822\.SIZE\s+(\d+)", s)
|
||||
if m:
|
||||
size_bytes = int(m.group(1))
|
||||
|
||||
if not raw_eml:
|
||||
return False
|
||||
|
||||
return await _save_message(
|
||||
uid=uid,
|
||||
raw_eml=raw_eml,
|
||||
size_bytes=size_bytes or len(raw_eml),
|
||||
mailbox=mailbox,
|
||||
db=db,
|
||||
redis_client=redis_client,
|
||||
)
|
||||
|
||||
|
||||
async def _save_message(
|
||||
uid: int,
|
||||
raw_eml: bytes,
|
||||
size_bytes: int,
|
||||
mailbox: Mailbox,
|
||||
db: AsyncSession,
|
||||
redis_client: aioredis.Redis,
|
||||
) -> bool:
|
||||
"""
|
||||
Salva un messaggio EML in DB e su MinIO. Pubblica evento WebSocket.
|
||||
"""
|
||||
# Idempotenza
|
||||
existing = await db.execute(
|
||||
select(Message.id).where(
|
||||
Message.mailbox_id == mailbox.id,
|
||||
Message.imap_uid == uid,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
logger.debug(f"[{mailbox.email_address}] UID {uid} già in DB, skip")
|
||||
return False
|
||||
|
||||
parsed = _parse_eml(raw_eml)
|
||||
received_at = datetime.now(UTC)
|
||||
|
||||
# Upload su MinIO
|
||||
eml_path: str | None = None
|
||||
try:
|
||||
eml_path = await upload_eml(
|
||||
tenant_id=str(mailbox.tenant_id),
|
||||
mailbox_id=str(mailbox.id),
|
||||
uid=uid,
|
||||
eml_bytes=raw_eml,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[{mailbox.email_address}] Upload MinIO UID {uid}: {e}")
|
||||
|
||||
# Salva in DB
|
||||
message = Message(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=mailbox.tenant_id,
|
||||
mailbox_id=mailbox.id,
|
||||
imap_uid=uid,
|
||||
imap_folder="INBOX",
|
||||
direction="inbound",
|
||||
state="received",
|
||||
pec_type=parsed.get("pec_type", "posta_certificata"),
|
||||
subject=parsed.get("subject"),
|
||||
from_address=parsed.get("from_address"),
|
||||
to_addresses=parsed.get("to_addresses"),
|
||||
cc_addresses=parsed.get("cc_addresses"),
|
||||
message_id_header=parsed.get("message_id_header"),
|
||||
sent_at=parsed.get("sent_at"),
|
||||
received_at=received_at,
|
||||
size_bytes=size_bytes,
|
||||
body_text=parsed.get("body_text"),
|
||||
body_html=parsed.get("body_html"),
|
||||
has_attachments=parsed.get("has_attachments", False),
|
||||
raw_eml_path=eml_path,
|
||||
is_read=False,
|
||||
)
|
||||
db.add(message)
|
||||
await db.flush()
|
||||
|
||||
# Pubblica evento Redis per WebSocket
|
||||
try:
|
||||
event = {
|
||||
"type": "mailbox:new_message",
|
||||
"mailbox_id": str(mailbox.id),
|
||||
"message_id": str(message.id),
|
||||
"subject": message.subject or "",
|
||||
"from_address": message.from_address or "",
|
||||
"pec_type": message.pec_type,
|
||||
"received_at": received_at.isoformat(),
|
||||
}
|
||||
await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event))
|
||||
except Exception as e:
|
||||
logger.warning(f"[{mailbox.email_address}] Redis publish UID {uid}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} "
|
||||
f"subject={message.subject!r} pec_type={message.pec_type}"
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1 @@
|
||||
# Definizioni job arq
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Job arq: sync_mailbox – trigger manuale per forzare la sincronizzazione di una casella.
|
||||
|
||||
Questo job viene usato per:
|
||||
- Forzare una sync immediata dopo la creazione di una nuova casella
|
||||
- Resync manuale da parte dell'admin
|
||||
- Retry dopo un errore (called dal pool monitor)
|
||||
|
||||
Non sostituisce il loop IMAP continuo (IMAPConnection); è un one-shot job.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.imap.reconnect import ExponentialBackoff
|
||||
from app.imap.sync import sync_new_messages
|
||||
from app.models import Mailbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict:
|
||||
"""
|
||||
Job arq: sincronizza una singola casella PEC.
|
||||
|
||||
Args:
|
||||
ctx: contesto arq (contiene redis, pool reference)
|
||||
mailbox_id: UUID della casella da sincronizzare
|
||||
|
||||
Returns:
|
||||
dict con risultato del job
|
||||
"""
|
||||
redis_client = ctx.get("redis")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
mailbox = await db.get(Mailbox, mailbox_id)
|
||||
|
||||
if not mailbox:
|
||||
return {"status": "error", "message": f"Mailbox {mailbox_id} non trovata"}
|
||||
|
||||
if mailbox.status not in ("active", "error"):
|
||||
return {
|
||||
"status": "skipped",
|
||||
"message": f"Mailbox status={mailbox.status}, skip",
|
||||
}
|
||||
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
try:
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
conn = IMAPConnection(mailbox_id=mailbox_id, redis_client=redis_client)
|
||||
client = await conn._connect(creds)
|
||||
|
||||
n = await sync_new_messages(client, mailbox, db, redis_client)
|
||||
|
||||
try:
|
||||
await client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"mailbox": mailbox.email_address,
|
||||
"new_messages": n,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[sync_mailbox] {mailbox_id} errore: {e}", exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"mailbox": mailbox.email_address,
|
||||
"message": str(e),
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Entrypoint worker arq – PecFlow IMAP Sync Engine.
|
||||
|
||||
Avvio: python -m app.main
|
||||
|
||||
Cosa fa:
|
||||
1. Connette a Redis tramite arq
|
||||
2. on_startup → avvia MailboxPool (N task IMAP asincroni)
|
||||
3. on_shutdown → ferma MailboxPool
|
||||
4. Registra job arq (sync_mailbox, future: send_pec, archive_batch, ecc.)
|
||||
5. Loop arq per processare job dalla coda Redis
|
||||
|
||||
L'event loop è condiviso tra arq e MailboxPool (asyncio task).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from arq import run_worker
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
from app.imap.pool import MailboxPool
|
||||
from app.jobs.sync_mailbox import sync_mailbox
|
||||
from app.storage.minio_client import ensure_bucket_exists
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pool globale (accessibile dalle callback)
|
||||
_mailbox_pool: MailboxPool | None = None
|
||||
|
||||
|
||||
# ─── Lifecycle callbacks arq ──────────────────────────────────────────────────
|
||||
|
||||
async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
"""
|
||||
Inizializzazione worker all'avvio.
|
||||
Avvia il MailboxPool con tutte le caselle attive.
|
||||
"""
|
||||
global _mailbox_pool
|
||||
|
||||
logger.info("🚀 PecFlow Worker avviato")
|
||||
logger.info(f" DB: {settings.database_url.split('@')[-1]}")
|
||||
logger.info(f" Redis: {settings.redis_url}")
|
||||
logger.info(f" MinIO: {settings.minio_endpoint}")
|
||||
|
||||
# Verifica/crea bucket MinIO
|
||||
try:
|
||||
await ensure_bucket_exists()
|
||||
except Exception as e:
|
||||
logger.warning(f"MinIO non disponibile al startup: {e}")
|
||||
|
||||
# Crea client Redis condiviso
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
ctx["redis"] = redis_client
|
||||
|
||||
# Avvia MailboxPool
|
||||
_mailbox_pool = MailboxPool(redis_client=redis_client)
|
||||
ctx["mailbox_pool"] = _mailbox_pool
|
||||
await _mailbox_pool.start()
|
||||
|
||||
logger.info(
|
||||
f"✅ Worker pronto: {_mailbox_pool.active_count} caselle IMAP attive"
|
||||
)
|
||||
|
||||
|
||||
async def on_shutdown(ctx: dict[str, Any]) -> None:
|
||||
"""Cleanup all'arresto del worker."""
|
||||
global _mailbox_pool
|
||||
|
||||
logger.info("🛑 PecFlow Worker in arresto...")
|
||||
|
||||
pool = ctx.get("mailbox_pool") or _mailbox_pool
|
||||
if pool:
|
||||
await pool.stop()
|
||||
|
||||
redis_client = ctx.get("redis")
|
||||
if redis_client:
|
||||
await redis_client.aclose()
|
||||
|
||||
logger.info("🛑 Worker fermato")
|
||||
|
||||
|
||||
# ─── Worker health check ──────────────────────────────────────────────────────
|
||||
|
||||
async def health_check(ctx: dict[str, Any]) -> dict:
|
||||
"""
|
||||
Job speciale per health check del worker.
|
||||
Può essere chiamato da monitoring esterno.
|
||||
"""
|
||||
pool: MailboxPool | None = ctx.get("mailbox_pool")
|
||||
return {
|
||||
"status": "ok",
|
||||
"active_imap_connections": pool.active_count if pool else 0,
|
||||
}
|
||||
|
||||
|
||||
# ─── WorkerSettings arq ───────────────────────────────────────────────────────
|
||||
|
||||
def _parse_redis_settings() -> RedisSettings:
|
||||
"""Parsa REDIS_URL in RedisSettings arq."""
|
||||
url = settings.redis_url
|
||||
# Supporta redis://host:port/db e redis://user:pass@host:port/db
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or 6379
|
||||
db = int(parsed.path.lstrip("/") or "0")
|
||||
password = parsed.password or None
|
||||
|
||||
return RedisSettings(host=host, port=port, database=db, password=password)
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
"""Configurazione del worker arq."""
|
||||
|
||||
# Funzioni/job registrati
|
||||
functions = [sync_mailbox, health_check]
|
||||
|
||||
# Callbacks lifecycle
|
||||
on_startup = on_startup
|
||||
on_shutdown = on_shutdown
|
||||
|
||||
# Redis
|
||||
redis_settings = _parse_redis_settings()
|
||||
|
||||
# Concorrenza
|
||||
max_jobs = 20
|
||||
|
||||
# Timeout per ogni job (secondi)
|
||||
job_timeout = 300
|
||||
|
||||
# Retry automatico in caso di errore
|
||||
max_tries = 3
|
||||
|
||||
# Polling interval (arq controlla la coda ogni N ms)
|
||||
poll_delay = 0.5
|
||||
|
||||
# Keep job results per N secondi
|
||||
keep_result = 3600
|
||||
|
||||
|
||||
# ─── Entrypoint ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Avvio PecFlow Worker (arq)...")
|
||||
run_worker(WorkerSettings)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Re-export dei modelli SQLAlchemy dal backend.
|
||||
|
||||
Il worker usa gli stessi modelli ORM del backend per leggere/scrivere nel DB.
|
||||
Importa da una base comune tramite il package condiviso.
|
||||
|
||||
Nota: i modelli sono definiti nel backend. Il worker li ridefinisce qui
|
||||
come classi identiche (stessa struttura) per non creare dipendenza circolare.
|
||||
Tuttavia, poiché i due container condividono lo stesso DB PostgreSQL,
|
||||
utilizziamo i modelli del backend ricopiando solo le parti necessarie.
|
||||
"""
|
||||
|
||||
# I modelli completi sono già in backend/app/models/.
|
||||
# Il worker li importa dalla propria copia locale per evitare
|
||||
# una dipendenza del package worker → backend.
|
||||
# In un monorepo reale si userebbe un shared/ package.
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY, BigInteger, Boolean, DateTime, Enum, ForeignKey,
|
||||
Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
MailboxStatus = Enum(
|
||||
"active", "paused", "error", "deleted",
|
||||
name="mailbox_status", create_type=False,
|
||||
)
|
||||
|
||||
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
|
||||
PecState = Enum(
|
||||
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
|
||||
name="pec_state", create_type=False,
|
||||
)
|
||||
PecMsgType = Enum(
|
||||
"posta_certificata", "accettazione", "non_accettazione", "presa_in_carico",
|
||||
"avvenuta_consegna", "mancata_consegna", "errore_consegna",
|
||||
"preavviso_mancata_consegna", "rilevazione_virus", "unknown",
|
||||
name="pec_msg_type", create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class Mailbox(Base):
|
||||
__tablename__ = "mailboxes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
email_address: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
imap_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
smtp_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
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_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
imap_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
imap_folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
|
||||
|
||||
direction: Mapped[str] = mapped_column(PecDirection, nullable=False)
|
||||
pec_type: Mapped[str] = mapped_column(PecMsgType, nullable=False, default="posta_certificata")
|
||||
state: Mapped[str] = mapped_column(PecState, nullable=False)
|
||||
|
||||
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
from_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
to_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
cc_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
parent_message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
|
||||
)
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
# MinIO storage client
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Client MinIO/S3 asincrono per il worker.
|
||||
|
||||
Percorso EML raw: pecflow/tenants/{tenant_id}/mailboxes/{mailbox_id}/raw/{uid}.eml
|
||||
Percorso allegati: pecflow/tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{msg_id}/{filename}
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from miniopy_async import Minio
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_minio_client() -> Minio:
|
||||
"""Restituisce l'istanza singleton del client MinIO."""
|
||||
return Minio(
|
||||
endpoint=settings.minio_endpoint,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=settings.minio_use_ssl,
|
||||
)
|
||||
|
||||
|
||||
async def upload_eml(
|
||||
tenant_id: str,
|
||||
mailbox_id: str,
|
||||
uid: int,
|
||||
eml_bytes: bytes,
|
||||
) -> str:
|
||||
"""
|
||||
Carica un raw EML su MinIO e restituisce il percorso oggetto.
|
||||
|
||||
Percorso: tenants/{tenant_id}/mailboxes/{mailbox_id}/raw/{uid}.eml
|
||||
"""
|
||||
client = get_minio_client()
|
||||
bucket = settings.minio_bucket
|
||||
object_path = f"tenants/{tenant_id}/mailboxes/{mailbox_id}/raw/{uid}.eml"
|
||||
|
||||
try:
|
||||
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 caricato: s3://{bucket}/{object_path} ({len(eml_bytes)} bytes)")
|
||||
return object_path
|
||||
except Exception as e:
|
||||
logger.error(f"Errore upload EML {object_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_bucket_exists() -> None:
|
||||
"""Verifica che il bucket MinIO esista, altrimenti lo crea."""
|
||||
client = get_minio_client()
|
||||
bucket = settings.minio_bucket
|
||||
try:
|
||||
found = await client.bucket_exists(bucket)
|
||||
if not found:
|
||||
await client.make_bucket(bucket)
|
||||
logger.info(f"Bucket MinIO creato: {bucket}")
|
||||
else:
|
||||
logger.debug(f"Bucket MinIO esistente: {bucket}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossibile verificare/creare bucket MinIO: {e}")
|
||||
@@ -0,0 +1,75 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pecflow-worker"
|
||||
version = "1.0.0"
|
||||
description = "PecFlow – Worker IMAP sync + background jobs"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
dependencies = [
|
||||
# Job queue
|
||||
"arq>=0.26.1",
|
||||
|
||||
# Database (stessi driver del backend)
|
||||
"sqlalchemy>=2.0.36",
|
||||
"asyncpg>=0.29.0",
|
||||
"alembic>=1.13.0",
|
||||
|
||||
# Configurazione
|
||||
"pydantic>=2.9.0",
|
||||
"pydantic-settings>=2.5.0",
|
||||
|
||||
# Redis
|
||||
"redis[asyncio]>=5.0.0",
|
||||
|
||||
# IMAP async
|
||||
"aioimaplib>=2.0.0",
|
||||
|
||||
# Storage MinIO/S3
|
||||
"miniopy-async>=1.21.0",
|
||||
|
||||
# Sicurezza (stesso modulo del backend per decifratura credenziali)
|
||||
"cryptography>=43.0.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"bcrypt>=4.0.0",
|
||||
|
||||
# Utilities
|
||||
"python-dotenv>=1.0.0",
|
||||
"email-validator>=2.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
"anyio>=4.6.0",
|
||||
"ruff>=0.7.0",
|
||||
"mypy>=1.13.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["app", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I", "B", "C4", "UP"]
|
||||
ignore = ["E501", "B008", "B904"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
"ignore::PendingDeprecationWarning",
|
||||
]
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Test di integrazione IMAP con GreenMail (server IMAP mock via Docker).
|
||||
|
||||
Prerequisiti:
|
||||
- GreenMail in esecuzione: docker compose --profile greenmail up -d greenmail
|
||||
- Endpoint IMAP: localhost:3143 (plain) o localhost:3993 (SSL)
|
||||
- Credenziali test: test@example.com / secret
|
||||
|
||||
Esecuzione:
|
||||
cd worker && pytest tests/integration/test_imap_sync.py -v
|
||||
|
||||
I test sono contrassegnati con @pytest.mark.integration e saltati se
|
||||
GREENMAIL_HOST non è raggiungibile.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Skip automatico se GreenMail non disponibile ─────────────────────────────
|
||||
|
||||
GREENMAIL_HOST = os.getenv("GREENMAIL_HOST", "localhost")
|
||||
GREENMAIL_IMAP_PORT = int(os.getenv("GREENMAIL_IMAP_PORT", "3143"))
|
||||
|
||||
|
||||
def _is_greenmail_running() -> bool:
|
||||
"""Verifica se GreenMail IMAP è raggiungibile."""
|
||||
try:
|
||||
with socket.create_connection((GREENMAIL_HOST, GREENMAIL_IMAP_PORT), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
skip_if_no_greenmail = pytest.mark.skipif(
|
||||
not _is_greenmail_running(),
|
||||
reason=f"GreenMail non disponibile su {GREENMAIL_HOST}:{GREENMAIL_IMAP_PORT}",
|
||||
)
|
||||
|
||||
# ─── Fixture: mailbox mock ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_mock_mailbox(
|
||||
email_address: str = "test@example.com",
|
||||
host: str = GREENMAIL_HOST,
|
||||
port: int = GREENMAIL_IMAP_PORT,
|
||||
user: str = "test@example.com",
|
||||
password: str = "secret",
|
||||
use_ssl: bool = False,
|
||||
last_sync_uid: int | None = None,
|
||||
) -> MagicMock:
|
||||
"""Crea un mock di Mailbox con credenziali cifrate per GreenMail."""
|
||||
from app.imap.connection import _decrypt
|
||||
from app.config import get_settings
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
settings = get_settings()
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
|
||||
def encrypt(value: str) -> str:
|
||||
import os as _os
|
||||
nonce = _os.urandom(12)
|
||||
ct = aesgcm.encrypt(nonce, value.encode(), None)
|
||||
return base64.b64encode(nonce + ct).decode()
|
||||
|
||||
mailbox = MagicMock()
|
||||
mailbox.id = uuid.uuid4()
|
||||
mailbox.tenant_id = uuid.uuid4()
|
||||
mailbox.email_address = email_address
|
||||
mailbox.status = "active"
|
||||
mailbox.last_sync_uid = last_sync_uid
|
||||
mailbox.sync_error_count = 0
|
||||
mailbox.sync_error_msg = None
|
||||
|
||||
mailbox.imap_host_enc = encrypt(host)
|
||||
mailbox.imap_port_enc = encrypt(str(port))
|
||||
mailbox.imap_user_enc = encrypt(user)
|
||||
mailbox.imap_pass_enc = encrypt(password)
|
||||
mailbox.imap_use_ssl = use_ssl
|
||||
|
||||
return mailbox
|
||||
|
||||
|
||||
# ─── Test: connessione IMAP a GreenMail ──────────────────────────────────────
|
||||
|
||||
@skip_if_no_greenmail
|
||||
@pytest.mark.asyncio
|
||||
async def test_imap_connect_greenmail():
|
||||
"""Verifica che IMAPConnection si connetta correttamente a GreenMail."""
|
||||
import aioimaplib
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
mailbox = make_mock_mailbox()
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
conn = IMAPConnection.__new__(IMAPConnection)
|
||||
client = await conn._connect(creds)
|
||||
|
||||
assert client is not None
|
||||
|
||||
# Verifica che SELECT INBOX abbia funzionato
|
||||
status, _ = await client.select("INBOX")
|
||||
assert status == "OK"
|
||||
|
||||
await client.logout()
|
||||
|
||||
|
||||
@skip_if_no_greenmail
|
||||
@pytest.mark.asyncio
|
||||
async def test_imap_search_empty_inbox():
|
||||
"""SEARCH su inbox vuota restituisce lista vuota."""
|
||||
import aioimaplib
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
mailbox = make_mock_mailbox(last_sync_uid=0)
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
conn = IMAPConnection.__new__(IMAPConnection)
|
||||
client = await conn._connect(creds)
|
||||
|
||||
# Cerca UID > 0 (tutti)
|
||||
status, data = await client.uid("SEARCH", "UID", "1:*")
|
||||
# Non deve sollevare eccezione
|
||||
assert status in ("OK", "NO")
|
||||
|
||||
await client.logout()
|
||||
|
||||
|
||||
# ─── Test: sync con DB mockato ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_new_messages_empty():
|
||||
"""sync_new_messages con inbox vuota restituisce 0."""
|
||||
from app.imap.sync import sync_new_messages
|
||||
|
||||
# Mock IMAP client che risponde con lista vuota
|
||||
mock_client = AsyncMock()
|
||||
mock_client.uid = AsyncMock(return_value=("OK", [b""]))
|
||||
|
||||
# Mock mailbox
|
||||
mailbox = MagicMock()
|
||||
mailbox.id = uuid.uuid4()
|
||||
mailbox.tenant_id = uuid.uuid4()
|
||||
mailbox.email_address = "test@example.com"
|
||||
mailbox.last_sync_uid = 0
|
||||
|
||||
# Mock DB
|
||||
mock_db = AsyncMock()
|
||||
mock_db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=lambda: None))
|
||||
|
||||
# Mock Redis
|
||||
mock_redis = AsyncMock()
|
||||
|
||||
n = await sync_new_messages(mock_client, mailbox, mock_db, mock_redis)
|
||||
assert n == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_skips_duplicate_uid():
|
||||
"""sync_new_messages salta UID già presenti nel DB."""
|
||||
from app.imap.sync import sync_new_messages
|
||||
from sqlalchemy.engine import Result
|
||||
|
||||
existing_uid = 42
|
||||
|
||||
mock_client = AsyncMock()
|
||||
# SEARCH restituisce UID 42
|
||||
mock_client.uid = AsyncMock(return_value=("OK", [f"{existing_uid}".encode()]))
|
||||
|
||||
mailbox = MagicMock()
|
||||
mailbox.id = uuid.uuid4()
|
||||
mailbox.tenant_id = uuid.uuid4()
|
||||
mailbox.email_address = "test@example.com"
|
||||
mailbox.last_sync_uid = 41
|
||||
|
||||
# DB: UID 42 già presente
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = uuid.uuid4() # esiste già
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
|
||||
n = await sync_new_messages(mock_client, mailbox, mock_db, mock_redis)
|
||||
# Deve restituire 0 perché il messaggio è già in DB
|
||||
assert n == 0
|
||||
|
||||
|
||||
# ─── Test: parsing EML ────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_pec_message():
|
||||
"""Parsing di un EML PEC tipico."""
|
||||
from app.imap.sync import _parse_eml
|
||||
|
||||
raw = b"""From: mittente@aruba.pec.it
|
||||
To: destinatario@pec.it
|
||||
Subject: Comunicazione ufficiale n. 2026/001
|
||||
Date: Wed, 18 Mar 2026 09:00:00 +0100
|
||||
Message-ID: <20260318090000.1234@aruba.pec.it>
|
||||
X-Ricevuta: posta-certificata
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Gentile destinatario,
|
||||
in riferimento alla pratica n. 2026/001...
|
||||
"""
|
||||
|
||||
parsed = _parse_eml(raw)
|
||||
assert parsed["subject"] == "Comunicazione ufficiale n. 2026/001"
|
||||
assert parsed["from_address"] == "mittente@aruba.pec.it"
|
||||
assert parsed["pec_type"] == "posta_certificata"
|
||||
assert parsed["sent_at"] is not None
|
||||
assert "destinatario@pec.it" in parsed["to_addresses"]
|
||||
|
||||
|
||||
def test_parse_ricevuta_avvenuta_consegna():
|
||||
"""Parsing di una ricevuta di avvenuta consegna."""
|
||||
from app.imap.sync import _parse_eml
|
||||
|
||||
raw = b"""From: posta-certificata@aruba.pec.it
|
||||
To: mittente@aruba.pec.it
|
||||
Subject: AVVENUTA CONSEGNA: Comunicazione n. 001
|
||||
Date: Wed, 18 Mar 2026 09:05:00 +0100
|
||||
Message-ID: <ricevuta.001@aruba.pec.it>
|
||||
X-Riferimento-Message-ID: <20260318090000.1234@aruba.pec.it>
|
||||
X-TipoRicevuta: avvenuta-consegna
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Il messaggio e' stato consegnato.
|
||||
"""
|
||||
|
||||
parsed = _parse_eml(raw)
|
||||
assert parsed["pec_type"] == "avvenuta_consegna"
|
||||
assert "AVVENUTA CONSEGNA" in parsed["subject"]
|
||||
|
||||
|
||||
# ─── Test: backoff con connessione reale ─────────────────────────────────────
|
||||
|
||||
@skip_if_no_greenmail
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_after_logout():
|
||||
"""Il sistema si riconnette dopo una disconnessione forzata."""
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
mailbox = make_mock_mailbox()
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
conn = IMAPConnection.__new__(IMAPConnection)
|
||||
|
||||
# Prima connessione
|
||||
client1 = await conn._connect(creds)
|
||||
assert client1 is not None
|
||||
|
||||
# Disconnessione forzata
|
||||
await client1.logout()
|
||||
|
||||
# Seconda connessione (simula riconnessione)
|
||||
client2 = await conn._connect(creds)
|
||||
assert client2 is not None
|
||||
|
||||
await client2.logout()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Unit test: ExponentialBackoff
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.imap.reconnect import ExponentialBackoff
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_increases():
|
||||
"""Il tempo di attesa aumenta ad ogni tentativo."""
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
|
||||
# Registra i tempi senza aspettare davvero (patch asyncio.sleep)
|
||||
waits = []
|
||||
|
||||
original_sleep = asyncio.sleep
|
||||
|
||||
async def fake_sleep(t):
|
||||
waits.append(t)
|
||||
|
||||
asyncio.sleep = fake_sleep
|
||||
try:
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
finally:
|
||||
asyncio.sleep = original_sleep
|
||||
|
||||
assert waits[1] > waits[0], "Il secondo wait deve essere maggiore del primo"
|
||||
assert waits[2] > waits[1], "Il terzo wait deve essere maggiore del secondo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_reset():
|
||||
"""reset() riporta il contatore a zero e il wait riparte dal valore iniziale."""
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
waits = []
|
||||
|
||||
async def fake_sleep(t):
|
||||
waits.append(t)
|
||||
|
||||
original_sleep = asyncio.sleep
|
||||
asyncio.sleep = fake_sleep
|
||||
try:
|
||||
await backoff.wait() # waits[0]: valore iniziale (es. 1.0)
|
||||
await backoff.wait() # waits[1]: valore incrementato (es. 2.0)
|
||||
backoff.reset()
|
||||
await backoff.wait() # waits[2]: deve tornare al valore iniziale
|
||||
finally:
|
||||
asyncio.sleep = original_sleep
|
||||
|
||||
# Dopo reset il wait riparte dal valore iniziale
|
||||
assert backoff.attempt == 1
|
||||
assert waits[2] == waits[0], (
|
||||
f"Dopo reset il wait deve tornare a {waits[0]}, ma era {waits[2]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_max():
|
||||
"""Il tempo di attesa non supera backoff_max_seconds."""
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
max_recorded = 0.0
|
||||
|
||||
async def fake_sleep(t):
|
||||
nonlocal max_recorded
|
||||
max_recorded = max(max_recorded, t)
|
||||
|
||||
original_sleep = asyncio.sleep
|
||||
asyncio.sleep = fake_sleep
|
||||
try:
|
||||
for _ in range(20):
|
||||
await backoff.wait()
|
||||
finally:
|
||||
asyncio.sleep = original_sleep
|
||||
|
||||
assert max_recorded <= settings.backoff_max_seconds + 0.1
|
||||
|
||||
|
||||
def test_backoff_attempt_count():
|
||||
"""Il contatore tentativi si incrementa correttamente."""
|
||||
import asyncio
|
||||
backoff = ExponentialBackoff(label="test", jitter=False)
|
||||
|
||||
async def run():
|
||||
async def fake_sleep(_):
|
||||
pass
|
||||
|
||||
asyncio.sleep = fake_sleep
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
await backoff.wait()
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(run())
|
||||
assert backoff.attempt == 3
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Unit test: parsing EML e classificazione tipo PEC.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.imap.sync import _decode_header, _extract_addresses, _parse_date, _parse_eml, _classify_pec_type
|
||||
import email
|
||||
|
||||
|
||||
# ─── Test _decode_header ─────────────────────────────────────────────────────
|
||||
|
||||
def test_decode_header_plain():
|
||||
assert _decode_header("Hello World") == "Hello World"
|
||||
|
||||
|
||||
def test_decode_header_none():
|
||||
assert _decode_header(None) is None
|
||||
|
||||
|
||||
def test_decode_header_encoded():
|
||||
# Header UTF-8 base64 encoded
|
||||
encoded = "=?utf-8?b?UEVDIHRlc3Q=?=" # "PEC test"
|
||||
assert _decode_header(encoded) == "PEC test"
|
||||
|
||||
|
||||
def test_decode_header_encoded_iso():
|
||||
# Header ISO-8859-1 quoted printable
|
||||
encoded = "=?iso-8859-1?q?Multa_n=2E_123?=" # "Multa n. 123"
|
||||
result = _decode_header(encoded)
|
||||
assert result is not None
|
||||
assert "Multa" in result
|
||||
|
||||
|
||||
# ─── Test _extract_addresses ──────────────────────────────────────────────────
|
||||
|
||||
def test_extract_addresses_single():
|
||||
addrs = _extract_addresses("test@example.com")
|
||||
assert "test@example.com" in addrs
|
||||
|
||||
|
||||
def test_extract_addresses_multiple():
|
||||
addrs = _extract_addresses("a@x.com, b@y.com, c@z.com")
|
||||
assert len(addrs) == 3
|
||||
assert "a@x.com" in addrs
|
||||
assert "b@y.com" in addrs
|
||||
|
||||
|
||||
def test_extract_addresses_with_display_name():
|
||||
addrs = _extract_addresses('"Mario Rossi" <mario@comune.it>')
|
||||
assert "mario@comune.it" in addrs
|
||||
|
||||
|
||||
def test_extract_addresses_empty():
|
||||
assert _extract_addresses(None) == []
|
||||
assert _extract_addresses("") == []
|
||||
|
||||
|
||||
# ─── Test _parse_date ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_date_valid():
|
||||
date = _parse_date("Wed, 18 Mar 2026 14:00:00 +0100")
|
||||
assert date is not None
|
||||
assert date.year == 2026
|
||||
assert date.month == 3
|
||||
assert date.day == 18
|
||||
|
||||
|
||||
def test_parse_date_none():
|
||||
assert _parse_date(None) is None
|
||||
|
||||
|
||||
def test_parse_date_invalid():
|
||||
assert _parse_date("not-a-date") is None
|
||||
|
||||
|
||||
# ─── Test _classify_pec_type ──────────────────────────────────────────────────
|
||||
|
||||
def test_classify_pec_type_avvenuta_consegna():
|
||||
msg = email.message_from_string(
|
||||
"From: server@pec.it\r\n"
|
||||
"X-TipoRicevuta: avvenuta-consegna\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "avvenuta_consegna"
|
||||
|
||||
|
||||
def test_classify_pec_type_accettazione():
|
||||
msg = email.message_from_string(
|
||||
"From: server@pec.it\r\n"
|
||||
"X-Ricevuta: accettazione\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "accettazione"
|
||||
|
||||
|
||||
def test_classify_pec_type_posta_certificata():
|
||||
msg = email.message_from_string(
|
||||
"From: mittente@pec.it\r\n"
|
||||
"Subject: Messaggio PEC\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "posta_certificata"
|
||||
|
||||
|
||||
def test_classify_pec_type_x_tipo_prevalente():
|
||||
"""X-TipoRicevuta ha precedenza su X-Ricevuta."""
|
||||
msg = email.message_from_string(
|
||||
"From: server@pec.it\r\n"
|
||||
"X-Ricevuta: accettazione\r\n"
|
||||
"X-TipoRicevuta: avvenuta-consegna\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
assert _classify_pec_type(msg) == "avvenuta_consegna"
|
||||
|
||||
|
||||
# ─── Test _parse_eml completo ─────────────────────────────────────────────────
|
||||
|
||||
RAW_EML = b"""From: mittente@pec.it
|
||||
To: destinatario@pec.it
|
||||
Cc: copia@pec.it
|
||||
Subject: Test PEC Fase 2
|
||||
Message-ID: <test123@pec.it>
|
||||
Date: Wed, 18 Mar 2026 10:00:00 +0100
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Corpo del messaggio di test.
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_eml_subject():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["subject"] == "Test PEC Fase 2"
|
||||
|
||||
|
||||
def test_parse_eml_from():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["from_address"] == "mittente@pec.it"
|
||||
|
||||
|
||||
def test_parse_eml_to():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert "destinatario@pec.it" in parsed["to_addresses"]
|
||||
|
||||
|
||||
def test_parse_eml_cc():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert "copia@pec.it" in parsed["cc_addresses"]
|
||||
|
||||
|
||||
def test_parse_eml_message_id():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["message_id_header"] == "<test123@pec.it>"
|
||||
|
||||
|
||||
def test_parse_eml_body_text():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert "Corpo del messaggio" in (parsed.get("body_text") or "")
|
||||
|
||||
|
||||
def test_parse_eml_no_attachments():
|
||||
parsed = _parse_eml(RAW_EML)
|
||||
assert parsed["has_attachments"] is False
|
||||
|
||||
|
||||
RAW_EML_WITH_ATTACHMENT = b"""From: mittente@pec.it
|
||||
To: destinatario@pec.it
|
||||
Subject: PEC con allegato
|
||||
Date: Wed, 18 Mar 2026 10:00:00 +0100
|
||||
Content-Type: multipart/mixed; boundary="----boundary123"
|
||||
|
||||
------boundary123
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Testo del messaggio.
|
||||
|
||||
------boundary123
|
||||
Content-Type: application/pdf; name="documento.pdf"
|
||||
Content-Disposition: attachment; filename="documento.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQ=
|
||||
|
||||
------boundary123--
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_eml_with_attachment():
|
||||
parsed = _parse_eml(RAW_EML_WITH_ATTACHMENT)
|
||||
assert parsed["has_attachments"] is True
|
||||
assert "Testo del messaggio" in (parsed.get("body_text") or "")
|
||||
|
||||
|
||||
def test_parse_eml_empty():
|
||||
parsed = _parse_eml(b"")
|
||||
# Non deve sollevare eccezione
|
||||
assert isinstance(parsed, dict)
|
||||
Reference in New Issue
Block a user