diff --git a/backend/app/api/v1/virtual_boxes.py b/backend/app/api/v1/virtual_boxes.py index db81449..c47524c 100644 --- a/backend/app/api/v1/virtual_boxes.py +++ b/backend/app/api/v1/virtual_boxes.py @@ -94,6 +94,27 @@ async def my_virtual_boxes( return [_to_response(v) for v in items] +@router.get( + "/my/mailboxes", + response_model=list[MailboxBriefResponse], + summary="Caselle PEC da cui l'utente può inviare tramite Virtual Box", + description=( + "Restituisce le caselle PEC attive associate alle Virtual Box " + "a cui l'utente corrente è assegnato. " + "Usato dalla pagina di composizione per mostrare le caselle mittente disponibili." + ), +) +async def my_sendable_mailboxes( + current_user: CurrentUser, + db: DB, +) -> list[MailboxBriefResponse]: + service = VirtualBoxService(db) + mailboxes = await service.get_user_sendable_mailboxes( + current_user.id, current_user.tenant_id + ) + return [MailboxBriefResponse.model_validate(m) for m in mailboxes] + + @router.get( "/{vbox_id}", response_model=VirtualBoxResponse, diff --git a/backend/app/schemas/virtual_box.py b/backend/app/schemas/virtual_box.py index 7e63d58..cc83501 100644 --- a/backend/app/schemas/virtual_box.py +++ b/backend/app/schemas/virtual_box.py @@ -42,6 +42,7 @@ class MailboxBriefResponse(BaseModel): id: uuid.UUID email_address: str display_name: str | None + status: str = "active" model_config = {"from_attributes": True} diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py index 1080a57..e35ab1d 100644 --- a/backend/app/services/permission_service.py +++ b/backend/app/services/permission_service.py @@ -61,12 +61,23 @@ class PermissionService: async def check_can_send( self, user: User, mailbox_id: uuid.UUID ) -> bool: - """Verifica se l'utente può inviare dalla casella.""" + """ + Verifica se l'utente può inviare dalla casella. + + L'accesso in invio è concesso se: + 1. L'utente è admin del tenant, oppure + 2. L'utente ha un permesso diretto can_send sulla casella, oppure + 3. L'utente è assegnato a una Virtual Box attiva che include la casella. + """ if user.role in ("super_admin", "admin"): return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id) perm = await self._get_permission(user.id, mailbox_id) - return perm is not None and perm.can_send + if perm is not None and perm.can_send: + return True + + # Fallback: verifica accesso tramite Virtual Box + return await self._check_vbox_mailbox_access(user.id, mailbox_id, user.tenant_id) async def check_can_manage( self, user: User, mailbox_id: uuid.UUID @@ -234,3 +245,40 @@ class PermissionService: ) ) return result.scalar_one_or_none() is not None + + async def _check_vbox_mailbox_access( + self, + user_id: uuid.UUID, + mailbox_id: uuid.UUID, + tenant_id: uuid.UUID, + ) -> bool: + """ + Verifica se l'utente ha accesso a una casella tramite Virtual Box. + Restituisce True se l'utente è assegnato ad almeno una VBox attiva + che include la casella specificata. + """ + from app.models.virtual_box import ( + VirtualBox, + VirtualBoxAssignment, + virtual_box_mailboxes, + ) + + result = await self.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 == user_id, + virtual_box_mailboxes.c.mailbox_id == mailbox_id, + VirtualBox.tenant_id == tenant_id, + VirtualBox.is_active == True, + ) + .limit(1) + ) + return result.scalar_one_or_none() is not None diff --git a/backend/app/services/virtual_box_service.py b/backend/app/services/virtual_box_service.py index d1f8751..aeefd7e 100644 --- a/backend/app/services/virtual_box_service.py +++ b/backend/app/services/virtual_box_service.py @@ -341,6 +341,33 @@ class VirtualBoxService: ) return list(result.scalars().all()) + async def get_user_sendable_mailboxes( + self, + user_id: uuid.UUID, + tenant_id: uuid.UUID, + ) -> list[Mailbox]: + """ + Restituisce le caselle PEC da cui l'utente può inviare tramite VBox. + + Aggregazione delle caselle associate a tutte le VBox attive a cui + l'utente è assegnato. Filtra solo caselle in stato 'active'. + """ + result = await self.db.execute( + select(Mailbox) + .join(virtual_box_mailboxes, Mailbox.id == virtual_box_mailboxes.c.mailbox_id) + .join(VirtualBox, virtual_box_mailboxes.c.virtual_box_id == VirtualBox.id) + .join(VirtualBoxAssignment, VirtualBox.id == VirtualBoxAssignment.virtual_box_id) + .where( + VirtualBoxAssignment.user_id == user_id, + VirtualBox.tenant_id == tenant_id, + VirtualBox.is_active == True, + Mailbox.status == "active", + ) + .distinct() + .order_by(Mailbox.email_address) + ) + return list(result.scalars().all()) + # ─── Private ───────────────────────────────────────────────────────────── async def _load_full(self, vbox_id: uuid.UUID) -> VirtualBox | None: diff --git a/frontend/src/api/virtual_boxes.api.ts b/frontend/src/api/virtual_boxes.api.ts index 994ec1c..d9fe91a 100644 --- a/frontend/src/api/virtual_boxes.api.ts +++ b/frontend/src/api/virtual_boxes.api.ts @@ -12,6 +12,7 @@ import type { VirtualBoxUpdate, } from '@/types/api.types' + export const virtualBoxesApi = { /** Crea una nuova Virtual Box. */ create: (data: VirtualBoxCreate) => @@ -27,6 +28,15 @@ export const virtualBoxesApi = { myVirtualBoxes: () => apiClient.get('/virtual-boxes/my').then((r) => r.data), + /** + * Caselle PEC attive da cui l'utente può inviare tramite le sue Virtual Box. + * Usato nella pagina di composizione per mostrare le caselle mittente disponibili. + */ + getMyMailboxes: () => + apiClient + .get('/virtual-boxes/my/mailboxes') + .then((r) => r.data), + /** Dettaglio Virtual Box. */ get: (id: string) => apiClient.get(`/virtual-boxes/${id}`).then((r) => r.data), diff --git a/frontend/src/pages/Compose/ComposePage.tsx b/frontend/src/pages/Compose/ComposePage.tsx index e88872e..9abacf5 100644 --- a/frontend/src/pages/Compose/ComposePage.tsx +++ b/frontend/src/pages/Compose/ComposePage.tsx @@ -1,7 +1,7 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useMemo } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useForm, useFieldArray } from 'react-hook-form' -import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload } from 'lucide-react' +import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter } from 'lucide-react' import { useQuery, useMutation } from '@tanstack/react-query' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' @@ -10,9 +10,19 @@ import { Label } from '@/components/ui/Label' import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' import { sendApi } from '@/api/send.api' import { mailboxesApi } from '@/api/mailboxes.api' +import { virtualBoxesApi } from '@/api/virtual_boxes.api' import { getErrorMessage } from '@/api/client' import type { MessageResponse } from '@/types/api.types' +/** Tipo unificato casella mittente (sia da permessi diretti che da Virtual Box) */ +interface MailboxSelectItem { + id: string + email_address: string + display_name: string | null + status: string + fromVbox?: boolean +} + interface ComposeFormValues { mailbox_id: string to_addresses: { value: string }[] @@ -91,12 +101,18 @@ export function ComposePage() { remove: removeCc, } = useFieldArray({ control, name: 'cc_addresses' }) - // Carica caselle disponibili per l'invio + // Carica caselle disponibili per l'invio (permessi diretti) const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({ queryKey: ['mailboxes'], queryFn: () => mailboxesApi.list(), }) + // Carica caselle disponibili tramite Virtual Box (operatori senza permessi diretti) + const { data: vboxMailboxes = [], isLoading: vboxMailboxesLoading } = useQuery({ + queryKey: ['virtual-boxes', 'my-mailboxes'], + queryFn: () => virtualBoxesApi.getMyMailboxes(), + }) + const sendMutation = useMutation({ mutationFn: (args: { data: Parameters[0] @@ -158,7 +174,36 @@ export function ComposePage() { }) } - const activeCaselle = mailboxesData?.items.filter((m) => m.status === 'active') || [] + // ── Lista unificata caselle mittente ────────────────────────────────────── + // Combina caselle con permesso diretto + caselle accessibili via VBox + const activeCaselle = useMemo((): MailboxSelectItem[] => { + const regularActive: MailboxSelectItem[] = ( + mailboxesData?.items.filter((m) => m.status === 'active') || [] + ).map((m) => ({ + id: m.id, + email_address: m.email_address, + display_name: m.display_name, + status: m.status, + fromVbox: false, + })) + + const regularIds = new Set(regularActive.map((m) => m.id)) + + // Aggiungi caselle VBox non già presenti nella lista diretta + const vboxActive: MailboxSelectItem[] = vboxMailboxes + .filter((m) => m.status === 'active' && !regularIds.has(m.id)) + .map((m) => ({ + id: m.id, + email_address: m.email_address, + display_name: m.display_name, + status: m.status, + fromVbox: true, + })) + + return [...regularActive, ...vboxActive] + }, [mailboxesData, vboxMailboxes]) + + const isLoadingMailboxes = mailboxesLoading || vboxMailboxesLoading return (
@@ -197,27 +242,35 @@ export function ComposePage() { {/* Casella mittente */}
- {mailboxesLoading ? ( + {isLoadingMailboxes ? (
) : activeCaselle.length === 0 ? (
Nessuna casella PEC attiva disponibile. Contatta l'amministratore.
) : ( - + <> + + {activeCaselle.some((m) => m.fromVbox) && ( +

+ + Le caselle con 📥 sono accessibili tramite Virtual Box +

+ )} + )} {errors.mailbox_id && (

{errors.mailbox_id.message}

diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index 7dd054a..ef2b12c 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -353,6 +353,7 @@ export interface MailboxBriefResponse { id: string email_address: string display_name: string | null + status: string } export interface VirtualBoxCreate { diff --git a/worker/app/main.py b/worker/app/main.py index c7ce3f6..151c235 100644 --- a/worker/app/main.py +++ b/worker/app/main.py @@ -49,6 +49,9 @@ async def on_startup(ctx: dict[str, Any]) -> None: """ Inizializzazione worker all'avvio. Avvia il MailboxPool con tutte le caselle attive. + + NOTA: ctx["redis"] è già un ArqRedis (con enqueue_job) impostato da arq + prima di chiamare on_startup – NON sovrascrivere con aioredis standard. """ global _mailbox_pool @@ -63,9 +66,10 @@ async def on_startup(ctx: dict[str, Any]) -> None: 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 + # Usa il client Redis ArqRedis già presente nel contesto (messo da arq). + # ArqRedis estende Redis, quindi funziona sia per MailboxPool sia per + # enqueue_job() nei job asincroni (send_pec, watch_receipt, ecc.). + redis_client = ctx["redis"] # Avvia MailboxPool _mailbox_pool = MailboxPool(redis_client=redis_client) @@ -87,9 +91,8 @@ async def on_shutdown(ctx: dict[str, Any]) -> None: if pool: await pool.stop() - redis_client = ctx.get("redis") - if redis_client: - await redis_client.aclose() + # NON chiudere ctx["redis"]: è l'ArqRedis gestito da arq, + # che ne gestisce il ciclo di vita autonomamente. logger.info("🛑 Worker fermato") diff --git a/worker/app/smtp/sender.py b/worker/app/smtp/sender.py index 332967f..34278bb 100644 --- a/worker/app/smtp/sender.py +++ b/worker/app/smtp/sender.py @@ -232,9 +232,9 @@ class SmtpSender: await smtp.connect() await smtp.login(creds["user"], creds["password"]) errors, response = await smtp.sendmail( - sender=self.mailbox.email_address, - recipients=all_recipients, - message=raw_eml, + self.mailbox.email_address, + all_recipients, + raw_eml, ) if errors: failed = ", ".join(f"{addr}: {err}" for addr, err in errors.items())