This commit is contained in:
2026-03-19 15:47:42 +01:00
parent 4e19090f0f
commit 7fc9108d2a
9 changed files with 194 additions and 30 deletions
+21
View File
@@ -94,6 +94,27 @@ async def my_virtual_boxes(
return [_to_response(v) for v in items] 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( @router.get(
"/{vbox_id}", "/{vbox_id}",
response_model=VirtualBoxResponse, response_model=VirtualBoxResponse,
+1
View File
@@ -42,6 +42,7 @@ class MailboxBriefResponse(BaseModel):
id: uuid.UUID id: uuid.UUID
email_address: str email_address: str
display_name: str | None display_name: str | None
status: str = "active"
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+50 -2
View File
@@ -61,12 +61,23 @@ class PermissionService:
async def check_can_send( async def check_can_send(
self, user: User, mailbox_id: uuid.UUID self, user: User, mailbox_id: uuid.UUID
) -> bool: ) -> 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"): if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id) return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_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( async def check_can_manage(
self, user: User, mailbox_id: uuid.UUID self, user: User, mailbox_id: uuid.UUID
@@ -234,3 +245,40 @@ class PermissionService:
) )
) )
return result.scalar_one_or_none() is not None 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
@@ -341,6 +341,33 @@ class VirtualBoxService:
) )
return list(result.scalars().all()) 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 ───────────────────────────────────────────────────────────── # ─── Private ─────────────────────────────────────────────────────────────
async def _load_full(self, vbox_id: uuid.UUID) -> VirtualBox | None: async def _load_full(self, vbox_id: uuid.UUID) -> VirtualBox | None:
+10
View File
@@ -12,6 +12,7 @@ import type {
VirtualBoxUpdate, VirtualBoxUpdate,
} from '@/types/api.types' } from '@/types/api.types'
export const virtualBoxesApi = { export const virtualBoxesApi = {
/** Crea una nuova Virtual Box. */ /** Crea una nuova Virtual Box. */
create: (data: VirtualBoxCreate) => create: (data: VirtualBoxCreate) =>
@@ -27,6 +28,15 @@ export const virtualBoxesApi = {
myVirtualBoxes: () => myVirtualBoxes: () =>
apiClient.get<VirtualBoxResponse[]>('/virtual-boxes/my').then((r) => r.data), apiClient.get<VirtualBoxResponse[]>('/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<MailboxBriefResponse[]>('/virtual-boxes/my/mailboxes')
.then((r) => r.data),
/** Dettaglio Virtual Box. */ /** Dettaglio Virtual Box. */
get: (id: string) => get: (id: string) =>
apiClient.get<VirtualBoxResponse>(`/virtual-boxes/${id}`).then((r) => r.data), apiClient.get<VirtualBoxResponse>(`/virtual-boxes/${id}`).then((r) => r.data),
+59 -6
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react' import { useState, useRef, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useForm, useFieldArray } from 'react-hook-form' 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 { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -10,9 +10,19 @@ import { Label } from '@/components/ui/Label'
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import { sendApi } from '@/api/send.api' import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api' import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { getErrorMessage } from '@/api/client' import { getErrorMessage } from '@/api/client'
import type { MessageResponse } from '@/types/api.types' 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 { interface ComposeFormValues {
mailbox_id: string mailbox_id: string
to_addresses: { value: string }[] to_addresses: { value: string }[]
@@ -91,12 +101,18 @@ export function ComposePage() {
remove: removeCc, remove: removeCc,
} = useFieldArray({ control, name: 'cc_addresses' }) } = 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({ const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
queryKey: ['mailboxes'], queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(), 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({ const sendMutation = useMutation({
mutationFn: (args: { mutationFn: (args: {
data: Parameters<typeof sendApi.sendMultipart>[0] data: Parameters<typeof sendApi.sendMultipart>[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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -197,13 +242,14 @@ export function ComposePage() {
{/* Casella mittente */} {/* Casella mittente */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="mailbox_id">Casella mittente *</Label> <Label htmlFor="mailbox_id">Casella mittente *</Label>
{mailboxesLoading ? ( {isLoadingMailboxes ? (
<div className="h-10 rounded-md border bg-muted animate-pulse" /> <div className="h-10 rounded-md border bg-muted animate-pulse" />
) : activeCaselle.length === 0 ? ( ) : activeCaselle.length === 0 ? (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
Nessuna casella PEC attiva disponibile. Contatta l'amministratore. Nessuna casella PEC attiva disponibile. Contatta l'amministratore.
</div> </div>
) : ( ) : (
<>
<select <select
id="mailbox_id" id="mailbox_id"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
@@ -214,10 +260,17 @@ export function ComposePage() {
<option value="">Seleziona casella...</option> <option value="">Seleziona casella...</option>
{activeCaselle.map((mb) => ( {activeCaselle.map((mb) => (
<option key={mb.id} value={mb.id}> <option key={mb.id} value={mb.id}>
{mb.display_name || mb.email_address} ({mb.email_address}) {mb.fromVbox ? '📥 ' : ''}{mb.display_name || mb.email_address} ({mb.email_address})
</option> </option>
))} ))}
</select> </select>
{activeCaselle.some((m) => m.fromVbox) && (
<p className="text-xs text-purple-600 flex items-center gap-1">
<Filter className="h-3 w-3" />
Le caselle con 📥 sono accessibili tramite Virtual Box
</p>
)}
</>
)} )}
{errors.mailbox_id && ( {errors.mailbox_id && (
<p className="text-xs text-destructive">{errors.mailbox_id.message}</p> <p className="text-xs text-destructive">{errors.mailbox_id.message}</p>
+1
View File
@@ -353,6 +353,7 @@ export interface MailboxBriefResponse {
id: string id: string
email_address: string email_address: string
display_name: string | null display_name: string | null
status: string
} }
export interface VirtualBoxCreate { export interface VirtualBoxCreate {
+9 -6
View File
@@ -49,6 +49,9 @@ async def on_startup(ctx: dict[str, Any]) -> None:
""" """
Inizializzazione worker all'avvio. Inizializzazione worker all'avvio.
Avvia il MailboxPool con tutte le caselle attive. 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 global _mailbox_pool
@@ -63,9 +66,10 @@ async def on_startup(ctx: dict[str, Any]) -> None:
except Exception as e: except Exception as e:
logger.warning(f"MinIO non disponibile al startup: {e}") logger.warning(f"MinIO non disponibile al startup: {e}")
# Crea client Redis condiviso # Usa il client Redis ArqRedis già presente nel contesto (messo da arq).
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) # ArqRedis estende Redis, quindi funziona sia per MailboxPool sia per
ctx["redis"] = redis_client # enqueue_job() nei job asincroni (send_pec, watch_receipt, ecc.).
redis_client = ctx["redis"]
# Avvia MailboxPool # Avvia MailboxPool
_mailbox_pool = MailboxPool(redis_client=redis_client) _mailbox_pool = MailboxPool(redis_client=redis_client)
@@ -87,9 +91,8 @@ async def on_shutdown(ctx: dict[str, Any]) -> None:
if pool: if pool:
await pool.stop() await pool.stop()
redis_client = ctx.get("redis") # NON chiudere ctx["redis"]: è l'ArqRedis gestito da arq,
if redis_client: # che ne gestisce il ciclo di vita autonomamente.
await redis_client.aclose()
logger.info("🛑 Worker fermato") logger.info("🛑 Worker fermato")
+3 -3
View File
@@ -232,9 +232,9 @@ class SmtpSender:
await smtp.connect() await smtp.connect()
await smtp.login(creds["user"], creds["password"]) await smtp.login(creds["user"], creds["password"])
errors, response = await smtp.sendmail( errors, response = await smtp.sendmail(
sender=self.mailbox.email_address, self.mailbox.email_address,
recipients=all_recipients, all_recipients,
message=raw_eml, raw_eml,
) )
if errors: if errors:
failed = ", ".join(f"{addr}: {err}" for addr, err in errors.items()) failed = ", ".join(f"{addr}: {err}" for addr, err in errors.items())