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]
@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,
+1
View File
@@ -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}
+50 -2
View File
@@ -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
@@ -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:
+10
View File
@@ -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<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. */
get: (id: string) =>
apiClient.get<VirtualBoxResponse>(`/virtual-boxes/${id}`).then((r) => r.data),
+72 -19
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 { 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<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 (
<div className="flex flex-col h-full">
@@ -197,27 +242,35 @@ export function ComposePage() {
{/* Casella mittente */}
<div className="space-y-1.5">
<Label htmlFor="mailbox_id">Casella mittente *</Label>
{mailboxesLoading ? (
{isLoadingMailboxes ? (
<div className="h-10 rounded-md border bg-muted animate-pulse" />
) : activeCaselle.length === 0 ? (
<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.
</div>
) : (
<select
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"
{...register('mailbox_id', {
required: 'Seleziona una casella mittente',
})}
>
<option value="">Seleziona casella...</option>
{activeCaselle.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.display_name || mb.email_address} ({mb.email_address})
</option>
))}
</select>
<>
<select
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"
{...register('mailbox_id', {
required: 'Seleziona una casella mittente',
})}
>
<option value="">Seleziona casella...</option>
{activeCaselle.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.fromVbox ? '📥 ' : ''}{mb.display_name || mb.email_address} ({mb.email_address})
</option>
))}
</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 && (
<p className="text-xs text-destructive">{errors.mailbox_id.message}</p>
+1
View File
@@ -353,6 +353,7 @@ export interface MailboxBriefResponse {
id: string
email_address: string
display_name: string | null
status: string
}
export interface VirtualBoxCreate {
+9 -6
View File
@@ -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")
+3 -3
View File
@@ -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())