vboxes fix

This commit is contained in:
2026-03-19 14:28:09 +01:00
parent b7f7c1f7c0
commit 06dfbfcbc4
30 changed files with 4405 additions and 166 deletions
+271
View File
@@ -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)
+131 -21
View File
@@ -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"):
+33 -9
View File
@@ -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)