mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
vboxes fix
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Router tag (Label).
|
||||
|
||||
Endpoint:
|
||||
- GET /labels – elenca i tag del tenant
|
||||
- POST /labels – crea un nuovo tag (admin)
|
||||
- PATCH /labels/{id} – modifica un tag (admin)
|
||||
- DELETE /labels/{id} – elimina un tag (admin)
|
||||
|
||||
- GET /messages/{id}/labels – tag di un messaggio
|
||||
- PUT /messages/{id}/labels – imposta i tag di un messaggio (sostituisce)
|
||||
- POST /messages/{id}/labels/add – aggiunge tag a un messaggio
|
||||
- POST /messages/{id}/labels/remove – rimuove tag da un messaggio
|
||||
|
||||
- POST /messages/bulk-labels – aggiunge/rimuove tag in blocco
|
||||
|
||||
Permessi:
|
||||
- GET /labels: tutti gli utenti autenticati
|
||||
- POST/PATCH/DELETE: solo admin
|
||||
- Operazioni su messaggi: utenti con accesso alla casella del messaggio
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.models.message import Message
|
||||
from app.schemas.label import (
|
||||
LabelCreate,
|
||||
LabelResponse,
|
||||
LabelUpdate,
|
||||
MessageBulkLabelRequest,
|
||||
MessageBulkLabelResponse,
|
||||
MessageLabelAddRequest,
|
||||
MessageLabelRemoveRequest,
|
||||
MessageLabelSetRequest,
|
||||
)
|
||||
from app.services.label_service import LabelService
|
||||
|
||||
router = APIRouter(tags=["Labels"])
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _check_message_access(
|
||||
message_id: uuid.UUID,
|
||||
current_user,
|
||||
db,
|
||||
) -> Message:
|
||||
"""Verifica che il messaggio esista e l'utente vi abbia accesso."""
|
||||
result = await db.execute(
|
||||
select(Message).where(
|
||||
Message.id == message_id,
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
message = result.scalar_one_or_none()
|
||||
if not message:
|
||||
raise NotFoundError(f"Messaggio {message_id} non trovato")
|
||||
|
||||
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, message.mailbox_id):
|
||||
raise ForbiddenError("Accesso al messaggio non autorizzato")
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# ─── CRUD Label ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/labels", response_model=list[LabelResponse])
|
||||
async def list_labels(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[LabelResponse]:
|
||||
"""Elenca tutti i tag del tenant corrente."""
|
||||
svc = LabelService(db)
|
||||
labels = await svc.list_labels(current_user.tenant_id)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
|
||||
|
||||
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_label(
|
||||
data: LabelCreate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> LabelResponse:
|
||||
"""Crea un nuovo tag (solo admin)."""
|
||||
svc = LabelService(db)
|
||||
label = await svc.create_label(current_user.tenant_id, data)
|
||||
return LabelResponse.model_validate(label)
|
||||
|
||||
|
||||
@router.patch("/labels/{label_id}", response_model=LabelResponse)
|
||||
async def update_label(
|
||||
label_id: uuid.UUID,
|
||||
data: LabelUpdate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> LabelResponse:
|
||||
"""Modifica un tag esistente (solo admin)."""
|
||||
svc = LabelService(db)
|
||||
label = await svc.update_label(current_user.tenant_id, label_id, data)
|
||||
return LabelResponse.model_validate(label)
|
||||
|
||||
|
||||
@router.delete("/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_label(
|
||||
label_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""Elimina un tag (solo admin). Viene rimosso automaticamente da tutti i messaggi."""
|
||||
svc = LabelService(db)
|
||||
await svc.delete_label(current_user.tenant_id, label_id)
|
||||
|
||||
|
||||
# ─── Tag su singolo messaggio ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/messages/{message_id}/labels", response_model=list[LabelResponse])
|
||||
async def get_message_labels(
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[LabelResponse]:
|
||||
"""Elenca i tag assegnati a un messaggio."""
|
||||
await _check_message_access(message_id, current_user, db)
|
||||
svc = LabelService(db)
|
||||
labels = await svc.get_message_labels(message_id, current_user.tenant_id)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
|
||||
|
||||
@router.put("/messages/{message_id}/labels", response_model=list[LabelResponse])
|
||||
async def set_message_labels(
|
||||
message_id: uuid.UUID,
|
||||
data: MessageLabelSetRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[LabelResponse]:
|
||||
"""
|
||||
Sostituisce tutti i tag di un messaggio.
|
||||
Passare una lista vuota per rimuovere tutti i tag.
|
||||
"""
|
||||
await _check_message_access(message_id, current_user, db)
|
||||
svc = LabelService(db)
|
||||
labels = await svc.set_message_labels(
|
||||
message_id, current_user.tenant_id, data.label_ids
|
||||
)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/labels/add", response_model=list[LabelResponse])
|
||||
async def add_message_labels(
|
||||
message_id: uuid.UUID,
|
||||
data: MessageLabelAddRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[LabelResponse]:
|
||||
"""Aggiunge tag a un messaggio senza rimuovere quelli esistenti."""
|
||||
await _check_message_access(message_id, current_user, db)
|
||||
svc = LabelService(db)
|
||||
labels = await svc.add_message_labels(
|
||||
message_id, current_user.tenant_id, data.label_ids
|
||||
)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/labels/remove", response_model=list[LabelResponse])
|
||||
async def remove_message_labels(
|
||||
message_id: uuid.UUID,
|
||||
data: MessageLabelRemoveRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[LabelResponse]:
|
||||
"""Rimuove specifici tag da un messaggio."""
|
||||
await _check_message_access(message_id, current_user, db)
|
||||
svc = LabelService(db)
|
||||
labels = await svc.remove_message_labels(
|
||||
message_id, current_user.tenant_id, data.label_ids
|
||||
)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
|
||||
|
||||
# ─── Bulk labels ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/messages/bulk-labels", response_model=MessageBulkLabelResponse)
|
||||
async def bulk_labels(
|
||||
data: MessageBulkLabelRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> MessageBulkLabelResponse:
|
||||
"""
|
||||
Aggiunge o rimuove tag da più messaggi in blocco.
|
||||
|
||||
- action="add": aggiunge i tag ai messaggi indicati
|
||||
- action="remove": rimuove i tag dai messaggi indicati
|
||||
|
||||
I messaggi non accessibili all'utente vengono silenziosamente ignorati.
|
||||
"""
|
||||
if not data.message_ids or not data.label_ids:
|
||||
return MessageBulkLabelResponse(updated=0)
|
||||
|
||||
# Per utenti non-admin filtra per caselle accessibili
|
||||
message_ids = [str(mid) for mid in data.message_ids]
|
||||
if not current_user.is_admin:
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
visible = await perm_svc.get_visible_mailboxes(current_user)
|
||||
visible_set = set(visible) if visible else set()
|
||||
|
||||
# Filtra i messaggi per caselle visibili
|
||||
result = await db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(data.message_ids),
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
Message.mailbox_id.in_(visible_set),
|
||||
)
|
||||
)
|
||||
filtered_ids = list(result.scalars().all())
|
||||
else:
|
||||
filtered_ids = data.message_ids
|
||||
|
||||
svc = LabelService(db)
|
||||
if data.action == "add":
|
||||
updated = await svc.bulk_add_labels(
|
||||
filtered_ids, current_user.tenant_id, data.label_ids
|
||||
)
|
||||
else:
|
||||
updated = await svc.bulk_remove_labels(
|
||||
filtered_ids, current_user.tenant_id, data.label_ids
|
||||
)
|
||||
|
||||
return MessageBulkLabelResponse(updated=updated)
|
||||
@@ -23,11 +23,13 @@ from fastapi import APIRouter, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser, DB
|
||||
from app.models.label import Label
|
||||
from app.models.message import Attachment, Message
|
||||
from app.schemas.message import (
|
||||
AttachmentResponse,
|
||||
@@ -107,12 +109,20 @@ async def _resolve_message(
|
||||
current_user,
|
||||
db: AsyncSession,
|
||||
) -> Message:
|
||||
"""Carica il messaggio e verifica i permessi di accesso."""
|
||||
"""Carica il messaggio e verifica i permessi di accesso.
|
||||
|
||||
L'accesso è consentito se:
|
||||
1. L'utente è admin del tenant, oppure
|
||||
2. L'utente ha un permesso diretto can_read sulla casella, oppure
|
||||
3. L'utente è assegnato a una Virtual Box attiva che include la casella.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Message).where(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.id == message_id,
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
.options(selectinload(Message.labels))
|
||||
)
|
||||
message = result.scalar_one_or_none()
|
||||
if not message:
|
||||
@@ -121,8 +131,39 @@ async def _resolve_message(
|
||||
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, message.mailbox_id):
|
||||
raise ForbiddenError("Accesso al messaggio non autorizzato")
|
||||
has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id)
|
||||
|
||||
if not has_direct_access:
|
||||
# Verifica accesso tramite Virtual Box:
|
||||
# l'utente deve essere assegnato a una VBox attiva
|
||||
# che include la casella del messaggio.
|
||||
from app.models.virtual_box import (
|
||||
VirtualBox,
|
||||
VirtualBoxAssignment,
|
||||
virtual_box_mailboxes,
|
||||
)
|
||||
vbox_result = await db.execute(
|
||||
select(VirtualBox.id)
|
||||
.join(
|
||||
VirtualBoxAssignment,
|
||||
VirtualBox.id == VirtualBoxAssignment.virtual_box_id,
|
||||
)
|
||||
.join(
|
||||
virtual_box_mailboxes,
|
||||
VirtualBox.id == virtual_box_mailboxes.c.virtual_box_id,
|
||||
)
|
||||
.where(
|
||||
VirtualBoxAssignment.user_id == current_user.id,
|
||||
virtual_box_mailboxes.c.mailbox_id == message.mailbox_id,
|
||||
VirtualBox.tenant_id == current_user.tenant_id,
|
||||
VirtualBox.is_active == True,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
has_vbox_access = vbox_result.scalar_one_or_none() is not None
|
||||
|
||||
if not has_vbox_access:
|
||||
raise ForbiddenError("Accesso al messaggio non autorizzato")
|
||||
|
||||
return message
|
||||
|
||||
@@ -160,7 +201,6 @@ async def list_messages(
|
||||
# ── Filtro Virtual Box ────────────────────────────────────────────────────
|
||||
vbox_rules: list = []
|
||||
if vbox_id is not None:
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment
|
||||
|
||||
vbox_result = await db.execute(
|
||||
@@ -258,7 +298,8 @@ async def list_messages(
|
||||
|
||||
# Ordinamento e paginazione
|
||||
q = (
|
||||
q.order_by(
|
||||
q.options(selectinload(Message.labels))
|
||||
.order_by(
|
||||
Message.received_at.desc().nullslast(),
|
||||
Message.created_at.desc(),
|
||||
)
|
||||
|
||||
+94
-30
@@ -1,20 +1,22 @@
|
||||
"""
|
||||
Router API – Invio PEC (Fase 4).
|
||||
Router API – Invio PEC.
|
||||
|
||||
Endpoint:
|
||||
POST /send – invia una nuova PEC (crea Message + SendJob, accoda job)
|
||||
GET /send/jobs – lista job di invio del tenant (paginata)
|
||||
GET /send/jobs/{id} – dettaglio di un singolo job
|
||||
DELETE /send/jobs/{id} – annulla job se ancora pending/retrying
|
||||
POST /send – invia una PEC via JSON (body_text, senza allegati)
|
||||
POST /send/multipart – invia una PEC con allegati (multipart/form-data)
|
||||
GET /send/jobs – lista job di invio del tenant (paginata)
|
||||
GET /send/jobs/{id} – dettaglio di un singolo job
|
||||
DELETE /send/jobs/{id} – annulla job se ancora pending/retrying
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
|
||||
|
||||
from app.core.exceptions import ForbiddenError
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.dependencies import CurrentUser, DB
|
||||
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
|
||||
from app.services.permission_service import PermissionService
|
||||
from app.services.send_service import SendService
|
||||
@@ -32,18 +34,16 @@ def _job_response(job) -> SendJobResponse:
|
||||
return SendJobResponse.model_validate(job)
|
||||
|
||||
|
||||
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─── POST /send (JSON – retrocompatibile) ────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SendJobResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Invia una PEC",
|
||||
summary="Invia una PEC (JSON, senza allegati)",
|
||||
description=(
|
||||
"Crea un messaggio PEC in uscita e accoda il job di invio SMTP. "
|
||||
"Il job viene eseguito in background con retry automatico. "
|
||||
"Richiede permesso **can_send** sulla casella (gli admin possono inviare da qualsiasi casella del tenant)."
|
||||
"Per inviare allegati utilizzare `POST /send/multipart`."
|
||||
),
|
||||
)
|
||||
async def create_send_job(
|
||||
@@ -54,11 +54,87 @@ async def create_send_job(
|
||||
svc = _svc(db)
|
||||
job = await svc.create_send_job(current_user=current_user, data=data)
|
||||
await db.commit()
|
||||
# Refresh per ottenere tutti i valori default dal DB
|
||||
await db.refresh(job)
|
||||
return _job_response(job)
|
||||
|
||||
|
||||
# ─── POST /send/multipart (Form + files) ─────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/multipart",
|
||||
response_model=SendJobResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Invia una PEC con allegati (multipart/form-data)",
|
||||
description=(
|
||||
"Accetta un form multipart con:\n"
|
||||
"- **data**: JSON string con i campi della PEC (stessa struttura di `POST /send`, "
|
||||
" più il campo opzionale `body_html`)\n"
|
||||
"- **attachments**: zero o più file allegati (max 20 MB ciascuno)\n\n"
|
||||
"Carica gli allegati su MinIO e crea i record `Attachment` nel DB prima di accodare il job."
|
||||
),
|
||||
)
|
||||
async def create_send_job_multipart(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
data: str = Form(
|
||||
...,
|
||||
description="JSON con i dati della PEC (campi SendPecRequest + body_html opzionale)",
|
||||
),
|
||||
attachments: list[UploadFile] = File(
|
||||
default=[],
|
||||
description="File allegati (0 o più, max 20 MB ciascuno)",
|
||||
),
|
||||
) -> SendJobResponse:
|
||||
# ── Parse del JSON ────────────────────────────────────────────────────────
|
||||
try:
|
||||
raw = json.loads(data)
|
||||
pec_data = SendPecRequest.model_validate(raw)
|
||||
# Estrai body_html (non presente nel modello base)
|
||||
body_html: str | None = raw.get("body_html") or None
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Dati PEC non validi: {exc}",
|
||||
)
|
||||
|
||||
# ── Leggi file allegati ───────────────────────────────────────────────────
|
||||
MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
files_data: list[dict] = []
|
||||
|
||||
for upload in attachments:
|
||||
content = await upload.read()
|
||||
if not content:
|
||||
continue # Salta file vuoti
|
||||
if len(content) > MAX_FILE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Il file '{upload.filename}' supera il limite di 20 MB",
|
||||
)
|
||||
files_data.append(
|
||||
{
|
||||
"filename": upload.filename or "allegato",
|
||||
"content": content,
|
||||
"content_type": upload.content_type or "application/octet-stream",
|
||||
}
|
||||
)
|
||||
|
||||
# ── Sovrascrive body_html se fornito ──────────────────────────────────────
|
||||
if body_html:
|
||||
pec_data.body_html = body_html # type: ignore[attr-defined]
|
||||
|
||||
svc = _svc(db)
|
||||
job = await svc.create_send_job(
|
||||
current_user=current_user,
|
||||
data=pec_data,
|
||||
attachments=files_data if files_data else None,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return _job_response(job)
|
||||
|
||||
|
||||
# ─── GET /send/jobs ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/jobs",
|
||||
response_model=SendJobListResponse,
|
||||
@@ -76,15 +152,8 @@ async def list_send_jobs(
|
||||
description="Filtra per stato: pending | sending | sent | failed | retrying",
|
||||
),
|
||||
) -> SendJobListResponse:
|
||||
"""
|
||||
Elenca i job di invio del tenant.
|
||||
|
||||
Gli admin vedono tutti i job; gli operatori vedono solo i job
|
||||
delle caselle su cui hanno permesso can_read.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
|
||||
# Filtro opzionale per casella: verifica accesso se non admin
|
||||
if mailbox_id and not current_user.is_admin:
|
||||
perm_svc = PermissionService(db)
|
||||
if not await perm_svc.check_can_read(current_user, mailbox_id):
|
||||
@@ -105,6 +174,8 @@ async def list_send_jobs(
|
||||
)
|
||||
|
||||
|
||||
# ─── GET /send/jobs/{id} ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/jobs/{job_id}",
|
||||
response_model=SendJobResponse,
|
||||
@@ -115,11 +186,9 @@ async def get_send_job(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> SendJobResponse:
|
||||
"""Recupera lo stato di un singolo job di invio."""
|
||||
svc = _svc(db)
|
||||
job = await svc.get_send_job(job_id, current_user.tenant_id)
|
||||
|
||||
# Verifica accesso alla casella se non admin
|
||||
if not current_user.is_admin:
|
||||
perm_svc = PermissionService(db)
|
||||
if not await perm_svc.check_can_read(current_user, job.mailbox_id):
|
||||
@@ -128,6 +197,8 @@ async def get_send_job(
|
||||
return _job_response(job)
|
||||
|
||||
|
||||
# ─── DELETE /send/jobs/{id} ────────────────────────────────────────────────────
|
||||
|
||||
@router.delete(
|
||||
"/jobs/{job_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
@@ -138,16 +209,9 @@ async def cancel_send_job(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""
|
||||
Annulla un job di invio se è ancora in stato **pending** o **retrying**.
|
||||
|
||||
Non è possibile annullare un invio già partito (stato sending) o
|
||||
completato (sent).
|
||||
"""
|
||||
svc = _svc(db)
|
||||
|
||||
# Verifica che l'utente possa agire su questo job
|
||||
job = await svc.get_send_job(job_id, current_user.tenant_id)
|
||||
|
||||
if not current_user.is_admin:
|
||||
perm_svc = PermissionService(db)
|
||||
if not await perm_svc.check_can_send(current_user, job.mailbox_id):
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import auth, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
|
||||
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.database import engine
|
||||
@@ -93,6 +93,7 @@ app.include_router(send.router, prefix=API_PREFIX)
|
||||
app.include_router(ws.router, prefix=API_PREFIX)
|
||||
app.include_router(virtual_boxes.router, prefix=API_PREFIX)
|
||||
app.include_router(notifications.router, prefix=API_PREFIX)
|
||||
app.include_router(labels.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -108,6 +108,11 @@ class Message(Base):
|
||||
children: Mapped[list["Message"]] = relationship(
|
||||
"Message", foreign_keys=[parent_message_id]
|
||||
)
|
||||
labels: Mapped[list["Label"]] = relationship( # type: ignore[name-defined]
|
||||
"Label",
|
||||
secondary="message_labels",
|
||||
lazy="select",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_messages_tenant", "tenant_id"),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Schemi Pydantic per Label (tag) e operazioni correlate.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LabelCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
|
||||
|
||||
class LabelUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
|
||||
|
||||
class LabelResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Richieste per assegnazione tag a messaggi ────────────────────────────────
|
||||
|
||||
class MessageLabelSetRequest(BaseModel):
|
||||
"""Sostituisce tutti i tag di un messaggio con quelli indicati."""
|
||||
label_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class MessageLabelAddRequest(BaseModel):
|
||||
"""Aggiunge tag a un messaggio senza rimuovere quelli esistenti."""
|
||||
label_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class MessageLabelRemoveRequest(BaseModel):
|
||||
"""Rimuove specifici tag da un messaggio."""
|
||||
label_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class MessageBulkLabelRequest(BaseModel):
|
||||
"""Aggiunge o rimuove tag da più messaggi in blocco."""
|
||||
message_ids: list[uuid.UUID]
|
||||
label_ids: list[uuid.UUID]
|
||||
action: str = Field(..., pattern=r'^(add|remove)$')
|
||||
|
||||
|
||||
class MessageBulkLabelResponse(BaseModel):
|
||||
updated: int
|
||||
@@ -8,6 +8,8 @@ from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
from app.schemas.label import LabelResponse
|
||||
|
||||
|
||||
class AttachmentResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
@@ -50,6 +52,7 @@ class MessageResponse(BaseModel):
|
||||
raw_eml_path: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
labels: list[LabelResponse] = []
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Service per la gestione delle Label (tag) e la loro assegnazione ai messaggi.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, NotFoundError
|
||||
from app.models.label import Label, MessageLabel
|
||||
from app.models.message import Message
|
||||
from app.schemas.label import LabelCreate, LabelUpdate
|
||||
|
||||
|
||||
class LabelService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ─── CRUD Label ───────────────────────────────────────────────────────────
|
||||
|
||||
async def list_labels(self, tenant_id: uuid.UUID) -> list[Label]:
|
||||
result = await self.db.execute(
|
||||
select(Label)
|
||||
.where(Label.tenant_id == tenant_id)
|
||||
.order_by(Label.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> Label:
|
||||
result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id == label_id,
|
||||
Label.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
label = result.scalar_one_or_none()
|
||||
if not label:
|
||||
raise NotFoundError(f"Tag {label_id} non trovato")
|
||||
return label
|
||||
|
||||
async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label:
|
||||
# Verifica unicità
|
||||
existing = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.tenant_id == tenant_id,
|
||||
Label.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Tag '{data.name}' già esistente")
|
||||
|
||||
label = Label(
|
||||
tenant_id=tenant_id,
|
||||
name=data.name,
|
||||
color=data.color,
|
||||
)
|
||||
self.db.add(label)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(label)
|
||||
return label
|
||||
|
||||
async def update_label(
|
||||
self, tenant_id: uuid.UUID, label_id: uuid.UUID, data: LabelUpdate
|
||||
) -> Label:
|
||||
label = await self.get_label(tenant_id, label_id)
|
||||
|
||||
if data.name is not None:
|
||||
# Verifica unicità del nuovo nome
|
||||
existing = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.tenant_id == tenant_id,
|
||||
Label.name == data.name,
|
||||
Label.id != label_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Tag '{data.name}' già esistente")
|
||||
label.name = data.name
|
||||
|
||||
if data.color is not None:
|
||||
label.color = data.color
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(label)
|
||||
return label
|
||||
|
||||
async def delete_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> None:
|
||||
label = await self.get_label(tenant_id, label_id)
|
||||
await self.db.delete(label)
|
||||
await self.db.commit()
|
||||
|
||||
# ─── Assegnazione tag a singolo messaggio ─────────────────────────────────
|
||||
|
||||
async def get_message_labels(
|
||||
self, message_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> list[Label]:
|
||||
result = await self.db.execute(
|
||||
select(Label)
|
||||
.join(MessageLabel, Label.id == MessageLabel.label_id)
|
||||
.where(
|
||||
MessageLabel.message_id == message_id,
|
||||
Label.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(Label.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def set_message_labels(
|
||||
self,
|
||||
message_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
label_ids: list[uuid.UUID],
|
||||
) -> list[Label]:
|
||||
"""Sostituisce tutti i tag di un messaggio con quelli indicati."""
|
||||
# Verifica che i label appartengano al tenant
|
||||
valid_ids: set[uuid.UUID] = set()
|
||||
if label_ids:
|
||||
result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id.in_(label_ids),
|
||||
Label.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
valid_ids = {lbl.id for lbl in result.scalars().all()}
|
||||
|
||||
# Rimuovi tutti i tag esistenti dal messaggio
|
||||
await self.db.execute(
|
||||
delete(MessageLabel).where(MessageLabel.message_id == message_id)
|
||||
)
|
||||
|
||||
# Aggiungi i nuovi tag validi
|
||||
for lbl_id in valid_ids:
|
||||
self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id))
|
||||
|
||||
await self.db.commit()
|
||||
return await self.get_message_labels(message_id, tenant_id)
|
||||
|
||||
async def add_message_labels(
|
||||
self,
|
||||
message_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
label_ids: list[uuid.UUID],
|
||||
) -> list[Label]:
|
||||
"""Aggiunge tag a un messaggio senza rimuovere quelli esistenti."""
|
||||
if not label_ids:
|
||||
return await self.get_message_labels(message_id, tenant_id)
|
||||
|
||||
# Verifica appartenenza al tenant
|
||||
result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id.in_(label_ids),
|
||||
Label.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
valid_labels = list(result.scalars().all())
|
||||
|
||||
# Carica tag esistenti per evitare duplicati
|
||||
existing_result = await self.db.execute(
|
||||
select(MessageLabel.label_id).where(
|
||||
MessageLabel.message_id == message_id
|
||||
)
|
||||
)
|
||||
existing_ids = set(existing_result.scalars().all())
|
||||
|
||||
for lbl in valid_labels:
|
||||
if lbl.id not in existing_ids:
|
||||
self.db.add(MessageLabel(message_id=message_id, label_id=lbl.id))
|
||||
|
||||
await self.db.commit()
|
||||
return await self.get_message_labels(message_id, tenant_id)
|
||||
|
||||
async def remove_message_labels(
|
||||
self,
|
||||
message_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
label_ids: list[uuid.UUID],
|
||||
) -> list[Label]:
|
||||
"""Rimuove specifici tag da un messaggio."""
|
||||
if label_ids:
|
||||
await self.db.execute(
|
||||
delete(MessageLabel).where(
|
||||
MessageLabel.message_id == message_id,
|
||||
MessageLabel.label_id.in_(label_ids),
|
||||
)
|
||||
)
|
||||
await self.db.commit()
|
||||
return await self.get_message_labels(message_id, tenant_id)
|
||||
|
||||
# ─── Azioni bulk ──────────────────────────────────────────────────────────
|
||||
|
||||
async def bulk_add_labels(
|
||||
self,
|
||||
message_ids: list[uuid.UUID],
|
||||
tenant_id: uuid.UUID,
|
||||
label_ids: list[uuid.UUID],
|
||||
) -> int:
|
||||
"""Aggiunge tag a più messaggi in blocco."""
|
||||
if not label_ids or not message_ids:
|
||||
return 0
|
||||
|
||||
# Verifica label del tenant
|
||||
lbl_result = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id.in_(label_ids),
|
||||
Label.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()]
|
||||
|
||||
# Verifica messaggi del tenant
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(message_ids),
|
||||
Message.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
valid_message_ids = list(msg_result.scalars().all())
|
||||
|
||||
if not valid_label_ids or not valid_message_ids:
|
||||
return 0
|
||||
|
||||
# Carica coppie esistenti per evitare duplicati
|
||||
existing_result = await self.db.execute(
|
||||
select(MessageLabel).where(
|
||||
MessageLabel.message_id.in_(valid_message_ids),
|
||||
MessageLabel.label_id.in_(valid_label_ids),
|
||||
)
|
||||
)
|
||||
existing_pairs = {
|
||||
(ml.message_id, ml.label_id) for ml in existing_result.scalars().all()
|
||||
}
|
||||
|
||||
for msg_id in valid_message_ids:
|
||||
for lbl_id in valid_label_ids:
|
||||
if (msg_id, lbl_id) not in existing_pairs:
|
||||
self.db.add(MessageLabel(message_id=msg_id, label_id=lbl_id))
|
||||
|
||||
await self.db.commit()
|
||||
return len(valid_message_ids)
|
||||
|
||||
async def bulk_remove_labels(
|
||||
self,
|
||||
message_ids: list[uuid.UUID],
|
||||
tenant_id: uuid.UUID,
|
||||
label_ids: list[uuid.UUID],
|
||||
) -> int:
|
||||
"""Rimuove tag da più messaggi in blocco."""
|
||||
if not label_ids or not message_ids:
|
||||
return 0
|
||||
|
||||
# Verifica messaggi del tenant
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.id).where(
|
||||
Message.id.in_(message_ids),
|
||||
Message.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
valid_message_ids = list(msg_result.scalars().all())
|
||||
|
||||
if not valid_message_ids:
|
||||
return 0
|
||||
|
||||
await self.db.execute(
|
||||
delete(MessageLabel).where(
|
||||
MessageLabel.message_id.in_(valid_message_ids),
|
||||
MessageLabel.label_id.in_(label_ids),
|
||||
)
|
||||
)
|
||||
await self.db.commit()
|
||||
return len(valid_message_ids)
|
||||
@@ -1,18 +1,20 @@
|
||||
"""
|
||||
SendService – logica di business per l'invio PEC (Fase 4).
|
||||
SendService – logica di business per l'invio PEC.
|
||||
|
||||
Responsabilità:
|
||||
1. Valida permessi (check_can_send) sulla casella selezionata
|
||||
2. Crea il record Message (direction=outbound, state=queued)
|
||||
3. Crea il record SendJob (status=pending)
|
||||
4. Enqueue il job arq 'send_pec' tramite il pool Redis/arq
|
||||
5. Ritorna SendJobResponse
|
||||
3. [Opzionale] Carica gli allegati su MinIO e crea i record Attachment
|
||||
4. Crea il record SendJob (status=pending)
|
||||
5. Enqueue il job arq 'send_pec' tramite il pool Redis/arq
|
||||
6. Ritorna SendJob
|
||||
|
||||
Il worker (arq) gestisce la connessione SMTP, il retry e la scrittura
|
||||
del raw EML su MinIO.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -21,13 +23,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||
from app.models.mailbox import Mailbox
|
||||
from app.models.message import Message, SendJob
|
||||
from app.models.message import Attachment, Message, SendJob
|
||||
from app.models.user import User
|
||||
from app.schemas.send import SendJobResponse, SendPecRequest
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
# ─── Pool arq (singleton lazy) ────────────────────────────────────────────────
|
||||
# Usato per fare enqueue dei job send_pec senza avviare un worker nel backend.
|
||||
|
||||
_arq_pool = None
|
||||
_arq_pool_lock = asyncio.Lock()
|
||||
@@ -68,6 +69,40 @@ async def close_arq_pool() -> None:
|
||||
_arq_pool = None
|
||||
|
||||
|
||||
# ─── Helper: HTML → testo semplice ────────────────────────────────────────────
|
||||
|
||||
def _html_to_plain(html: str) -> str:
|
||||
"""
|
||||
Converte HTML in testo semplice usando html.parser della stdlib.
|
||||
Usato per generare la parte text/plain quando viene fornito solo body_html.
|
||||
"""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
BLOCK_TAGS = {
|
||||
"p", "div", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"blockquote", "pre", "hr", "tr",
|
||||
}
|
||||
|
||||
class _Extractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self._parts.append(data)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001
|
||||
if tag.lower() in BLOCK_TAGS:
|
||||
self._parts.append("\n")
|
||||
|
||||
def text(self) -> str:
|
||||
return "".join(self._parts).strip()
|
||||
|
||||
extractor = _Extractor()
|
||||
extractor.feed(html)
|
||||
return extractor.text()
|
||||
|
||||
|
||||
# ─── SendService ──────────────────────────────────────────────────────────────
|
||||
|
||||
class SendService:
|
||||
@@ -80,20 +115,22 @@ class SendService:
|
||||
self,
|
||||
current_user: User,
|
||||
data: SendPecRequest,
|
||||
attachments: list[dict] | None = None,
|
||||
) -> SendJob:
|
||||
"""
|
||||
Crea Message + SendJob e accoda il job di invio.
|
||||
Crea Message + (Attachment*) + SendJob e accoda il job di invio.
|
||||
|
||||
Args:
|
||||
current_user: utente autenticato che richiede l'invio
|
||||
data: dati della PEC da inviare
|
||||
attachments: lista opzionale di dict {filename, content: bytes, content_type}
|
||||
|
||||
Returns:
|
||||
SendJob appena creato
|
||||
|
||||
Raises:
|
||||
NotFoundError: casella non trovata o non appartenente al tenant
|
||||
ForbiddenError: utente senza can_send sulla casella
|
||||
NotFoundError: casella non trovata o non appartenente al tenant
|
||||
ForbiddenError: utente senza can_send sulla casella
|
||||
"""
|
||||
# ── Verifica casella ──────────────────────────────────────────────────
|
||||
mailbox = await self.db.get(Mailbox, data.mailbox_id)
|
||||
@@ -105,7 +142,6 @@ class SendService:
|
||||
raise NotFoundError("casella PEC mittente")
|
||||
|
||||
if mailbox.status != "active":
|
||||
from app.core.exceptions import ForbiddenError
|
||||
raise ForbiddenError(
|
||||
f"La casella è in stato '{mailbox.status}' e non può inviare"
|
||||
)
|
||||
@@ -118,8 +154,18 @@ class SendService:
|
||||
"Non hai il permesso di inviare da questa casella"
|
||||
)
|
||||
|
||||
# ── Ricava body_text ──────────────────────────────────────────────────
|
||||
body_text = data.body_text or ""
|
||||
body_html: str | None = getattr(data, "body_html", None) or data.body_html # type: ignore[attr-defined]
|
||||
|
||||
# Se il body_text è vuoto ma abbiamo HTML, genera il plain text
|
||||
if not body_text and body_html:
|
||||
body_text = _html_to_plain(body_html)
|
||||
|
||||
# ── Crea il messaggio outbound ────────────────────────────────────────
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
has_files = bool(attachments)
|
||||
|
||||
message = Message(
|
||||
tenant_id=current_user.tenant_id,
|
||||
mailbox_id=data.mailbox_id,
|
||||
@@ -130,9 +176,9 @@ class SendService:
|
||||
from_address=mailbox.email_address,
|
||||
to_addresses=[str(a) for a in data.to_addresses],
|
||||
cc_addresses=[str(a) for a in data.cc_addresses] if data.cc_addresses else [],
|
||||
body_text=data.body_text or "",
|
||||
body_html=data.body_html,
|
||||
has_attachments=False, # allegati in Fase 5
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
has_attachments=has_files,
|
||||
sent_at=None,
|
||||
received_at=None,
|
||||
)
|
||||
@@ -144,7 +190,16 @@ class SendService:
|
||||
message.parent_message_id = data.reply_to_message_id
|
||||
|
||||
self.db.add(message)
|
||||
await self.db.flush()
|
||||
await self.db.flush() # Ottieni message.id
|
||||
|
||||
# ── Carica allegati su MinIO e crea record Attachment ─────────────────
|
||||
if has_files:
|
||||
await self._upload_attachments(
|
||||
message=message,
|
||||
attachments=attachments, # type: ignore[arg-type]
|
||||
tenant_id=current_user.tenant_id,
|
||||
mailbox_id=data.mailbox_id,
|
||||
)
|
||||
|
||||
# ── Crea il SendJob ───────────────────────────────────────────────────
|
||||
job = SendJob(
|
||||
@@ -171,11 +226,70 @@ class SendService:
|
||||
f"[send_service] Impossibile enqueue send_pec job {job.id}: {e}. "
|
||||
"Il job resterà in stato 'pending' per pickup manuale."
|
||||
)
|
||||
# Non alziamo eccezione: il job è nel DB e verrà processato
|
||||
# dal cron di polling se disponibile
|
||||
|
||||
return job
|
||||
|
||||
# ── Upload allegati ────────────────────────────────────────────────────────
|
||||
|
||||
async def _upload_attachments(
|
||||
self,
|
||||
message: Message,
|
||||
attachments: list[dict],
|
||||
tenant_id: uuid.UUID,
|
||||
mailbox_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""
|
||||
Carica ogni allegato su MinIO e crea il record Attachment nel DB.
|
||||
|
||||
Gestisce gli errori di upload singolarmente: un allegato che non
|
||||
riesce a caricarsi non blocca gli altri.
|
||||
"""
|
||||
from app.core.logging import get_logger
|
||||
from app.storage.minio_client import upload_attachment as minio_upload
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
for att in attachments:
|
||||
filename: str = att.get("filename") or "allegato"
|
||||
content: bytes = att.get("content") or b""
|
||||
content_type: str = att.get("content_type") or "application/octet-stream"
|
||||
|
||||
if not content:
|
||||
continue
|
||||
|
||||
try:
|
||||
storage_path = await minio_upload(
|
||||
tenant_id=str(tenant_id),
|
||||
mailbox_id=str(mailbox_id),
|
||||
message_id=str(message.id),
|
||||
filename=filename,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
)
|
||||
checksum = hashlib.sha256(content).hexdigest()
|
||||
|
||||
attachment_record = Attachment(
|
||||
tenant_id=tenant_id,
|
||||
message_id=message.id,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
size_bytes=len(content),
|
||||
storage_path=storage_path,
|
||||
checksum_sha256=checksum,
|
||||
)
|
||||
self.db.add(attachment_record)
|
||||
logger.debug(
|
||||
f"[send_service] Allegato caricato: {storage_path} "
|
||||
f"({len(content)} bytes)"
|
||||
)
|
||||
except Exception as upload_err:
|
||||
logger.error(
|
||||
f"[send_service] Errore upload allegato '{filename}': {upload_err}"
|
||||
)
|
||||
# Non sollevare: l'invio continuerà senza questo allegato
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
# ── Lista job di invio ────────────────────────────────────────────────────
|
||||
|
||||
async def list_send_jobs(
|
||||
@@ -226,10 +340,7 @@ class SendService:
|
||||
job_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> SendJob:
|
||||
"""
|
||||
Annulla un job di invio se è ancora in stato 'pending'.
|
||||
Non cancella il messaggio associato.
|
||||
"""
|
||||
"""Annulla un job di invio se è ancora in stato 'pending' o 'retrying'."""
|
||||
job = await self.get_send_job(job_id, tenant_id)
|
||||
|
||||
if job.status not in ("pending", "retrying"):
|
||||
@@ -240,7 +351,6 @@ class SendService:
|
||||
job.status = "failed"
|
||||
job.last_error = "Annullato dall'utente"
|
||||
|
||||
# Aggiorna anche il messaggio
|
||||
if job.message_id:
|
||||
msg = await self.db.get(Message, job.message_id)
|
||||
if msg and msg.state in ("queued", "draft"):
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
|
||||
from app.models.mailbox import Mailbox
|
||||
from app.models.user import User
|
||||
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment, VirtualBoxRule
|
||||
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment, VirtualBoxRule, virtual_box_mailboxes
|
||||
from app.schemas.virtual_box import (
|
||||
AssignedUserResponse,
|
||||
VirtualBoxCreate,
|
||||
@@ -65,10 +65,15 @@ class VirtualBoxService:
|
||||
)
|
||||
self.db.add(rule)
|
||||
|
||||
# Associa le caselle reali
|
||||
# Associa le caselle reali (INSERT diretto sulla tabella di associazione
|
||||
# per evitare MissingGreenlet con SQLAlchemy async)
|
||||
if data.mailbox_ids:
|
||||
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
|
||||
vbox.mailboxes = mailboxes
|
||||
valid_mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
|
||||
if valid_mailboxes:
|
||||
await self.db.execute(
|
||||
virtual_box_mailboxes.insert(),
|
||||
[{"virtual_box_id": vbox.id, "mailbox_id": mb.id} for mb in valid_mailboxes],
|
||||
)
|
||||
|
||||
await self.db.flush()
|
||||
return await self._load_full(vbox.id)
|
||||
@@ -136,10 +141,19 @@ class VirtualBoxService:
|
||||
if data.is_active is not None:
|
||||
vbox.is_active = data.is_active
|
||||
|
||||
# Aggiorna le caselle associate se fornito
|
||||
# Aggiorna le caselle associate se fornito (INSERT/DELETE diretti)
|
||||
if data.mailbox_ids is not None:
|
||||
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
|
||||
vbox.mailboxes = mailboxes
|
||||
await self.db.execute(
|
||||
virtual_box_mailboxes.delete().where(
|
||||
virtual_box_mailboxes.c.virtual_box_id == vbox_id
|
||||
)
|
||||
)
|
||||
valid_mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
|
||||
if valid_mailboxes:
|
||||
await self.db.execute(
|
||||
virtual_box_mailboxes.insert(),
|
||||
[{"virtual_box_id": vbox_id, "mailbox_id": mb.id} for mb in valid_mailboxes],
|
||||
)
|
||||
|
||||
await self.db.flush()
|
||||
return await self._load_full(vbox_id)
|
||||
@@ -196,8 +210,18 @@ class VirtualBoxService:
|
||||
if not vbox or vbox.tenant_id != tenant_id:
|
||||
raise NotFoundError("Virtual Box")
|
||||
|
||||
mailboxes = await self._load_mailboxes(mailbox_ids, tenant_id)
|
||||
vbox.mailboxes = mailboxes
|
||||
# Sostituzione completa con INSERT/DELETE diretti
|
||||
await self.db.execute(
|
||||
virtual_box_mailboxes.delete().where(
|
||||
virtual_box_mailboxes.c.virtual_box_id == vbox_id
|
||||
)
|
||||
)
|
||||
valid_mailboxes = await self._load_mailboxes(mailbox_ids, tenant_id)
|
||||
if valid_mailboxes:
|
||||
await self.db.execute(
|
||||
virtual_box_mailboxes.insert(),
|
||||
[{"virtual_box_id": vbox_id, "mailbox_id": mb.id} for mb in valid_mailboxes],
|
||||
)
|
||||
|
||||
await self.db.flush()
|
||||
return await self._load_full(vbox_id)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Storage utilities per il backend (MinIO/S3)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Client MinIO per il backend – upload allegati outbound.
|
||||
|
||||
Percorso allegati outbound:
|
||||
tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{message_id}/{filename}
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from miniopy_async import Minio
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_minio_client() -> Minio:
|
||||
"""Restituisce l'istanza singleton del client MinIO."""
|
||||
settings = get_settings()
|
||||
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_attachment(
|
||||
tenant_id: str,
|
||||
mailbox_id: str,
|
||||
message_id: str,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""
|
||||
Carica un allegato outbound su MinIO.
|
||||
|
||||
Args:
|
||||
tenant_id: UUID del tenant (stringa)
|
||||
mailbox_id: UUID della casella mittente
|
||||
message_id: UUID del messaggio associato
|
||||
filename: Nome file originale
|
||||
content: Byte del file
|
||||
content_type: MIME type
|
||||
|
||||
Returns:
|
||||
Percorso oggetto su MinIO (senza nome bucket)
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = get_minio_client()
|
||||
bucket = settings.minio_bucket
|
||||
|
||||
safe_filename = _sanitize_filename(filename)
|
||||
object_path = (
|
||||
f"tenants/{tenant_id}/mailboxes/{mailbox_id}"
|
||||
f"/attachments/{message_id}/{safe_filename}"
|
||||
)
|
||||
|
||||
data_stream = io.BytesIO(content)
|
||||
await client.put_object(
|
||||
bucket_name=bucket,
|
||||
object_name=object_path,
|
||||
data=data_stream,
|
||||
length=len(content),
|
||||
content_type=content_type,
|
||||
)
|
||||
logger.debug(
|
||||
f"Allegato outbound caricato: {object_path} "
|
||||
f"({len(content)} bytes, {content_type})"
|
||||
)
|
||||
return object_path
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitizza il nome file per uso sicuro come path MinIO."""
|
||||
safe = filename.replace("/", "_").replace("\\", "_").replace("\x00", "")
|
||||
safe = re.sub(r"[^\w.\-() ]", "_", safe, flags=re.UNICODE)
|
||||
if len(safe) > 200:
|
||||
parts = safe.rsplit(".", 1)
|
||||
if len(parts) == 2:
|
||||
safe = parts[0][:196] + "." + parts[1]
|
||||
else:
|
||||
safe = safe[:200]
|
||||
return safe or "attachment"
|
||||
Reference in New Issue
Block a user