vbox funzionanti

This commit is contained in:
2026-03-19 11:41:10 +01:00
parent 538d6a6bec
commit b7f7c1f7c0
32 changed files with 6043 additions and 262 deletions
+2
View File
@@ -6,6 +6,8 @@ Ho docker installato, compose v2 (docker cmpose senza trattino)
Non fare commit sul repository GitHub, ci penso io
Non effettuare test da Browser, ci penso io
Queste le caselle PEC e i loro parametri IMAP/SMTP che puoi usare per test, non effettuare invii per adesso
Casella: gmgspa@pec.it
@@ -0,0 +1,28 @@
"""Add sent_last_sync_uid to mailboxes sincronizzazione cartella Sent IMAP
Revision ID: 0002
Revises: 0001
Create Date: 2026-03-19 09:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS sent_last_sync_uid BIGINT
""")
def downgrade() -> None:
op.execute("""
ALTER TABLE mailboxes
DROP COLUMN IF EXISTS sent_last_sync_uid
""")
@@ -0,0 +1,162 @@
"""Virtual Boxes e Notifiche Multi-canale Fase 2
Revision ID: 0003
Revises: 0002
Create Date: 2026-03-19 00:00:00.000000
Aggiunge le tabelle:
- virtual_boxes
- virtual_box_rules
- virtual_box_assignments
- notification_channels (+ ENUM notification_channel_type)
- notification_rules
- notification_log (+ ENUM notification_status)
"""
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── ENUM types ────────────────────────────────────────────────────────────
op.execute(
"CREATE TYPE notification_channel_type AS ENUM "
"('webhook', 'email', 'telegram', 'whatsapp')"
)
op.execute(
"CREATE TYPE notification_status AS ENUM "
"('pending', 'sent', 'failed', 'skipped')"
)
# ── VIRTUAL BOXES ─────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE virtual_boxes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
label VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_vbox_name_tenant UNIQUE (tenant_id, name)
)
""")
op.execute("CREATE INDEX idx_vbox_tenant ON virtual_boxes (tenant_id)")
op.execute("""
CREATE TRIGGER trg_virtual_boxes_updated_at
BEFORE UPDATE ON virtual_boxes
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""")
op.execute("""
CREATE TABLE virtual_box_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE,
field VARCHAR(50) NOT NULL,
operator VARCHAR(20) NOT NULL DEFAULT 'contains',
value TEXT NOT NULL,
date_from VARCHAR(20),
date_to VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_vbox_rule_vbox ON virtual_box_rules (virtual_box_id)")
op.execute("""
CREATE TABLE virtual_box_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_vbox_assignment UNIQUE (virtual_box_id, user_id)
)
""")
op.execute("CREATE INDEX idx_vbox_assign_user ON virtual_box_assignments (user_id)")
op.execute("CREATE INDEX idx_vbox_assign_vbox ON virtual_box_assignments (virtual_box_id)")
# ── NOTIFICATION CHANNELS ─────────────────────────────────────────────────
op.execute("""
CREATE TABLE notification_channels (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
channel_type notification_channel_type NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB,
config_enc TEXT,
consecutive_failures INT NOT NULL DEFAULT 0,
circuit_open_until TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_notif_channel_tenant ON notification_channels (tenant_id)")
op.execute("""
CREATE TRIGGER trg_notification_channels_updated_at
BEFORE UPDATE ON notification_channels
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""")
op.execute("""
CREATE TABLE notification_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
event_type VARCHAR(100) NOT NULL,
filter JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_notif_rule_tenant ON notification_rules (tenant_id)")
op.execute("CREATE INDEX idx_notif_rule_channel ON notification_rules (channel_id)")
op.execute("CREATE INDEX idx_notif_rule_event ON notification_rules (event_type)")
op.execute("""
CREATE TABLE notification_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
rule_id UUID REFERENCES notification_rules(id) ON DELETE SET NULL,
event_type VARCHAR(100) NOT NULL,
event_payload JSONB,
status notification_status NOT NULL DEFAULT 'pending',
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
next_retry_at TIMESTAMPTZ,
last_error TEXT,
http_status INT,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_notif_log_tenant ON notification_log (tenant_id)")
op.execute("CREATE INDEX idx_notif_log_channel ON notification_log (channel_id)")
op.execute(
"CREATE INDEX idx_notif_log_status ON notification_log (status, next_retry_at) "
"WHERE status IN ('pending', 'failed')"
)
def downgrade() -> None:
for table in [
"notification_log",
"notification_rules",
"notification_channels",
"virtual_box_assignments",
"virtual_box_rules",
"virtual_boxes",
]:
op.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
op.execute("DROP TYPE IF EXISTS notification_status")
op.execute("DROP TYPE IF EXISTS notification_channel_type")
@@ -0,0 +1,36 @@
"""Aggiunge la tabella di associazione virtual_box_mailboxes
Revision ID: 0004
Revises: 0003
Create Date: 2026-03-19 00:00:00.000000
Aggiunge:
- virtual_box_mailboxes (associazione many-to-many tra virtual_boxes e mailboxes)
"""
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE virtual_box_mailboxes (
virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE,
mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
PRIMARY KEY (virtual_box_id, mailbox_id)
)
""")
op.execute(
"CREATE INDEX idx_vbox_mbox_vbox ON virtual_box_mailboxes (virtual_box_id)"
)
op.execute(
"CREATE INDEX idx_vbox_mbox_mailbox ON virtual_box_mailboxes (mailbox_id)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS virtual_box_mailboxes CASCADE")
+149 -2
View File
@@ -31,6 +31,8 @@ from app.dependencies import CurrentUser, DB
from app.models.message import Attachment, Message
from app.schemas.message import (
AttachmentResponse,
MessageBulkUpdateRequest,
MessageBulkUpdateResponse,
MessageListResponse,
MessageResponse,
MessageUpdateRequest,
@@ -42,6 +44,49 @@ settings = get_settings()
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _apply_vbox_rule(q, field: str, operator: str, value: str):
"""
Applica una singola regola di Virtual Box alla query SQLAlchemy.
field : subject | from_address | to_address | imap_folder
operator : contains | equals | starts_with | ends_with | regex
"""
if field == "subject":
col = Message.subject
elif field == "from_address":
col = Message.from_address
elif field == "to_address":
# to_addresses è ARRAY(Text) converte in stringa per il confronto
arr_text = func.array_to_string(Message.to_addresses, ",")
if operator == "contains":
return q.where(arr_text.ilike(f"%{value}%"))
elif operator == "equals":
return q.where(arr_text.ilike(value))
elif operator == "starts_with":
return q.where(arr_text.ilike(f"{value}%"))
elif operator == "ends_with":
return q.where(arr_text.ilike(f"%{value}"))
elif operator == "regex":
return q.where(arr_text.op("~*")(value))
return q
elif field == "imap_folder":
col = Message.imap_folder
else:
return q # campo non supportato ignorato
if operator == "contains":
return q.where(col.ilike(f"%{value}%"))
elif operator == "equals":
return q.where(func.lower(col) == value.lower())
elif operator == "starts_with":
return q.where(col.ilike(f"{value}%"))
elif operator == "ends_with":
return q.where(col.ilike(f"%{value}"))
elif operator == "regex":
return q.where(col.op("~*")(value))
return q
async def _get_visible_mailbox_ids(
user, db: AsyncSession
) -> Optional[list[uuid.UUID]]:
@@ -89,6 +134,7 @@ async def list_messages(
current_user: CurrentUser,
db: DB,
# Filtri
vbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per Virtual Box assegnata"),
mailbox_id: Optional[uuid.UUID] = Query(None),
direction: Optional[str] = Query(None, pattern="^(inbound|outbound)$"),
state: Optional[str] = Query(None),
@@ -106,17 +152,61 @@ async def list_messages(
- `is_archived=False` (default) esclude i messaggi archiviati.
- `search` cerca su subject, from_address, to_addresses.
- `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
"""
# Determinare le caselle visibili
# Determinare le caselle visibili (normale check permessi)
visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
# ── 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(
select(VirtualBox)
.where(
VirtualBox.id == vbox_id,
VirtualBox.tenant_id == current_user.tenant_id,
VirtualBox.is_active == True,
)
.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.mailboxes),
)
)
vbox = vbox_result.scalar_one_or_none()
if not vbox:
raise NotFoundError("Virtual Box")
# Non-admin: verifica che l'utente sia assegnato alla VBox
if not current_user.is_admin:
assign_result = await db.execute(
select(VirtualBoxAssignment).where(
VirtualBoxAssignment.virtual_box_id == vbox_id,
VirtualBoxAssignment.user_id == current_user.id,
)
)
if not assign_result.scalar_one_or_none():
raise ForbiddenError("Virtual Box non accessibile")
# L'assegnazione alla VBox garantisce accesso alle sue caselle:
# sovrascrive il filtro permessi normali per questa query.
if vbox.mailboxes:
visible_mailbox_ids = [m.id for m in vbox.mailboxes]
# Se la VBox non ha caselle esplicitamente associate,
# si mantiene il filtro permessi normale (visible_mailbox_ids invariato).
vbox_rules = vbox.rules or []
# ─────────────────────────────────────────────────────────────────────────
# Query base
q = select(Message).where(
Message.tenant_id == current_user.tenant_id,
Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio)
)
# Filtro caselle visibili per non-admin
# Filtro caselle visibili per non-admin (o dopo override VBox)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
# Nessuna casella accessibile → lista vuota
@@ -158,6 +248,10 @@ async def list_messages(
)
)
# Applica le regole della Virtual Box (AND tra le regole)
for rule in vbox_rules:
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
# Conteggio totale
count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one()
@@ -183,6 +277,59 @@ async def list_messages(
)
@router.patch("/bulk", response_model=MessageBulkUpdateResponse)
async def bulk_update_messages(
data: MessageBulkUpdateRequest,
current_user: CurrentUser,
db: DB,
) -> MessageBulkUpdateResponse:
"""
Aggiorna in blocco i flag operativi (is_starred, is_archived) di più messaggi.
Restituisce il numero di messaggi aggiornati e la lista aggiornata.
I messaggi non trovati o non accessibili vengono silenziosamente ignorati.
"""
if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[])
# Carica tutti i messaggi del tenant
result = await db.execute(
select(Message).where(
Message.id.in_(data.ids),
Message.tenant_id == current_user.tenant_id,
)
)
messages = list(result.scalars().all())
# Filtra per permessi se non admin
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()
messages = [m for m in messages if m.mailbox_id in visible_set]
now = datetime.now(timezone.utc)
for message in messages:
if data.is_starred is not None:
message.is_starred = data.is_starred
if data.is_archived is not None:
message.is_archived = data.is_archived
if data.is_archived and not message.archived_at:
message.archived_at = now
elif not data.is_archived:
message.archived_at = None
await db.commit()
for message in messages:
await db.refresh(message)
return MessageBulkUpdateResponse(
updated=len(messages),
items=[MessageResponse.model_validate(m) for m in messages],
)
@router.get("/{message_id}", response_model=MessageResponse)
async def get_message(
message_id: uuid.UUID,
+249
View File
@@ -0,0 +1,249 @@
"""
Router Notifiche Multi-canale (Fase 2).
Endpoint:
POST /notifications/channels → crea canale
GET /notifications/channels → lista canali
GET /notifications/channels/{id} → dettaglio canale
PATCH /notifications/channels/{id} → aggiorna canale
DELETE /notifications/channels/{id} → elimina canale
POST /notifications/channels/{id}/test → test canale
POST /notifications/rules → crea regola
GET /notifications/rules → lista regole
GET /notifications/rules/{id} → dettaglio regola
PATCH /notifications/rules/{id} → aggiorna regola
DELETE /notifications/rules/{id} → elimina regola
GET /notifications/logs → log invii
"""
import uuid
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DB
from app.schemas.notification import (
ChannelTestResult,
NotificationChannelCreate,
NotificationChannelListResponse,
NotificationChannelResponse,
NotificationChannelUpdate,
NotificationLogListResponse,
NotificationLogResponse,
NotificationRuleCreate,
NotificationRuleListResponse,
NotificationRuleResponse,
NotificationRuleUpdate,
)
from app.services.notification_service import NotificationService
router = APIRouter(prefix="/notifications", tags=["Notifiche"])
# ─── Channels ─────────────────────────────────────────────────────────────────
@router.post(
"/channels",
response_model=NotificationChannelResponse,
status_code=201,
summary="Crea un canale di notifica",
)
async def create_channel(
body: NotificationChannelCreate,
current_user: AdminUser,
db: DB,
) -> NotificationChannelResponse:
service = NotificationService(db)
channel = await service.create_channel(current_user.tenant_id, body, current_user.id)
return NotificationChannelResponse.model_validate(channel)
@router.get(
"/channels",
response_model=NotificationChannelListResponse,
summary="Lista canali di notifica",
)
async def list_channels(
current_user: AdminUser,
db: DB,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> NotificationChannelListResponse:
service = NotificationService(db)
items, total = await service.list_channels(current_user.tenant_id, page, page_size)
return NotificationChannelListResponse(
items=[NotificationChannelResponse.model_validate(c) for c in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/channels/{channel_id}",
response_model=NotificationChannelResponse,
summary="Dettaglio canale",
)
async def get_channel(
channel_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> NotificationChannelResponse:
service = NotificationService(db)
channel = await service.get_channel(channel_id, current_user.tenant_id)
return NotificationChannelResponse.model_validate(channel)
@router.patch(
"/channels/{channel_id}",
response_model=NotificationChannelResponse,
summary="Aggiorna canale",
)
async def update_channel(
channel_id: uuid.UUID,
body: NotificationChannelUpdate,
current_user: AdminUser,
db: DB,
) -> NotificationChannelResponse:
service = NotificationService(db)
channel = await service.update_channel(channel_id, current_user.tenant_id, body)
return NotificationChannelResponse.model_validate(channel)
@router.delete(
"/channels/{channel_id}",
status_code=204,
summary="Elimina canale",
)
async def delete_channel(
channel_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = NotificationService(db)
await service.delete_channel(channel_id, current_user.tenant_id)
@router.post(
"/channels/{channel_id}/test",
response_model=ChannelTestResult,
summary="Test canale di notifica",
description="Invia un messaggio di test al canale per verificarne la configurazione.",
)
async def test_channel(
channel_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> ChannelTestResult:
service = NotificationService(db)
return await service.test_channel(channel_id, current_user.tenant_id)
# ─── Rules ────────────────────────────────────────────────────────────────────
@router.post(
"/rules",
response_model=NotificationRuleResponse,
status_code=201,
summary="Crea una regola di notifica",
)
async def create_rule(
body: NotificationRuleCreate,
current_user: AdminUser,
db: DB,
) -> NotificationRuleResponse:
service = NotificationService(db)
rule = await service.create_rule(current_user.tenant_id, body)
return NotificationRuleResponse.model_validate(rule)
@router.get(
"/rules",
response_model=NotificationRuleListResponse,
summary="Lista regole di notifica",
)
async def list_rules(
current_user: AdminUser,
db: DB,
channel_id: uuid.UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> NotificationRuleListResponse:
service = NotificationService(db)
items, total = await service.list_rules(
current_user.tenant_id, channel_id, page, page_size
)
return NotificationRuleListResponse(
items=[NotificationRuleResponse.model_validate(r) for r in items],
total=total,
)
@router.get(
"/rules/{rule_id}",
response_model=NotificationRuleResponse,
summary="Dettaglio regola",
)
async def get_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> NotificationRuleResponse:
service = NotificationService(db)
rule = await service.get_rule(rule_id, current_user.tenant_id)
return NotificationRuleResponse.model_validate(rule)
@router.patch(
"/rules/{rule_id}",
response_model=NotificationRuleResponse,
summary="Aggiorna regola",
)
async def update_rule(
rule_id: uuid.UUID,
body: NotificationRuleUpdate,
current_user: AdminUser,
db: DB,
) -> NotificationRuleResponse:
service = NotificationService(db)
rule = await service.update_rule(rule_id, current_user.tenant_id, body)
return NotificationRuleResponse.model_validate(rule)
@router.delete(
"/rules/{rule_id}",
status_code=204,
summary="Elimina regola",
)
async def delete_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = NotificationService(db)
await service.delete_rule(rule_id, current_user.tenant_id)
# ─── Logs ─────────────────────────────────────────────────────────────────────
@router.get(
"/logs",
response_model=NotificationLogListResponse,
summary="Log notifiche",
)
async def list_logs(
current_user: AdminUser,
db: DB,
channel_id: uuid.UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> NotificationLogListResponse:
service = NotificationService(db)
items, total = await service.list_logs(
current_user.tenant_id, channel_id, page, page_size
)
return NotificationLogListResponse(
items=[NotificationLogResponse.model_validate(l) for l in items],
total=total,
page=page,
page_size=page_size,
)
+276
View File
@@ -0,0 +1,276 @@
"""
Router Virtual Box (Fase 2).
Endpoint:
POST /virtual-boxes → crea VBox
GET /virtual-boxes → lista VBox del tenant
GET /virtual-boxes/{id} → dettaglio VBox
PATCH /virtual-boxes/{id} → aggiorna VBox (incluse caselle)
DELETE /virtual-boxes/{id} → elimina VBox
PUT /virtual-boxes/{id}/rules → sostituisce regole
PUT /virtual-boxes/{id}/mailboxes → imposta caselle reali associate
GET /virtual-boxes/{id}/mailboxes → lista caselle reali associate
POST /virtual-boxes/{id}/assignments → assegna utenti
DELETE /virtual-boxes/{id}/assignments/{user_id} → rimuovi assegnazione
GET /virtual-boxes/{id}/assignments → lista utenti assegnati
GET /virtual-boxes/my → VBox assegnate all'utente corrente
"""
import uuid
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.virtual_box import (
AssignedUserResponse,
MailboxBriefResponse,
VirtualBoxAssignmentResponse,
VirtualBoxAssignRequest,
VirtualBoxCreate,
VirtualBoxListResponse,
VirtualBoxMailboxAssignRequest,
VirtualBoxResponse,
VirtualBoxRuleCreate,
VirtualBoxUpdate,
)
from app.services.virtual_box_service import VirtualBoxService
router = APIRouter(prefix="/virtual-boxes", tags=["Virtual Box"])
# ─── CRUD ─────────────────────────────────────────────────────────────────────
@router.post(
"",
response_model=VirtualBoxResponse,
status_code=201,
summary="Crea una nuova Virtual Box",
)
async def create_virtual_box(
body: VirtualBoxCreate,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.create(current_user.tenant_id, body, current_user.id)
return _to_response(vbox)
@router.get(
"",
response_model=VirtualBoxListResponse,
summary="Lista Virtual Box del tenant",
)
async def list_virtual_boxes(
current_user: AdminUser,
db: DB,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
active_only: bool = Query(False),
) -> VirtualBoxListResponse:
service = VirtualBoxService(db)
items, total = await service.list_vboxes(
current_user.tenant_id, page, page_size, active_only
)
return VirtualBoxListResponse(
items=[_to_response(v) for v in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/my",
response_model=list[VirtualBoxResponse],
summary="Virtual Box assegnate all'utente corrente",
)
async def my_virtual_boxes(
current_user: CurrentUser,
db: DB,
) -> list[VirtualBoxResponse]:
service = VirtualBoxService(db)
items = await service.list_user_vboxes(current_user.id, current_user.tenant_id)
return [_to_response(v) for v in items]
@router.get(
"/{vbox_id}",
response_model=VirtualBoxResponse,
summary="Dettaglio Virtual Box",
)
async def get_virtual_box(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.get(vbox_id, current_user.tenant_id)
return _to_response(vbox)
@router.patch(
"/{vbox_id}",
response_model=VirtualBoxResponse,
summary="Aggiorna Virtual Box",
description=(
"Aggiorna i metadati della Virtual Box. "
"Se `mailbox_ids` è fornito, sostituisce completamente le caselle PEC associate."
),
)
async def update_virtual_box(
vbox_id: uuid.UUID,
body: VirtualBoxUpdate,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.update(vbox_id, current_user.tenant_id, body)
return _to_response(vbox)
@router.delete(
"/{vbox_id}",
status_code=204,
summary="Elimina Virtual Box",
)
async def delete_virtual_box(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = VirtualBoxService(db)
await service.delete(vbox_id, current_user.tenant_id)
# ─── Regole ───────────────────────────────────────────────────────────────────
@router.put(
"/{vbox_id}/rules",
response_model=VirtualBoxResponse,
summary="Sostituisce le regole di una Virtual Box",
description="Rimpiazza completamente il set di regole. Inviare lista vuota per azzerarle.",
)
async def replace_rules(
vbox_id: uuid.UUID,
body: list[VirtualBoxRuleCreate],
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.replace_rules(vbox_id, current_user.tenant_id, body)
return _to_response(vbox)
# ─── Caselle Reali ────────────────────────────────────────────────────────────
@router.put(
"/{vbox_id}/mailboxes",
response_model=VirtualBoxResponse,
summary="Imposta le caselle PEC reali associate alla Virtual Box",
description=(
"Sostituisce completamente l'elenco delle caselle PEC reali collegate. "
"Inviare una lista vuota per rimuovere tutte le associazioni."
),
)
async def set_mailboxes(
vbox_id: uuid.UUID,
body: VirtualBoxMailboxAssignRequest,
current_user: AdminUser,
db: DB,
) -> VirtualBoxResponse:
service = VirtualBoxService(db)
vbox = await service.set_mailboxes(vbox_id, current_user.tenant_id, body.mailbox_ids)
return _to_response(vbox)
@router.get(
"/{vbox_id}/mailboxes",
response_model=list[MailboxBriefResponse],
summary="Lista caselle PEC reali associate alla Virtual Box",
)
async def list_mailboxes(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[MailboxBriefResponse]:
service = VirtualBoxService(db)
mailboxes = await service.list_mailboxes(vbox_id, current_user.tenant_id)
return [MailboxBriefResponse.model_validate(m) for m in mailboxes]
# ─── Assegnazioni ─────────────────────────────────────────────────────────────
@router.post(
"/{vbox_id}/assignments",
response_model=list[VirtualBoxAssignmentResponse],
status_code=201,
summary="Assegna utenti a una Virtual Box",
)
async def assign_users(
vbox_id: uuid.UUID,
body: VirtualBoxAssignRequest,
current_user: AdminUser,
db: DB,
) -> list[VirtualBoxAssignmentResponse]:
service = VirtualBoxService(db)
assignments = await service.assign_users(
vbox_id, current_user.tenant_id, body.user_ids, current_user.id
)
return [VirtualBoxAssignmentResponse.model_validate(a) for a in assignments]
@router.delete(
"/{vbox_id}/assignments/{user_id}",
status_code=204,
summary="Rimuovi assegnazione utente",
)
async def unassign_user(
vbox_id: uuid.UUID,
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = VirtualBoxService(db)
await service.unassign_user(vbox_id, current_user.tenant_id, user_id)
@router.get(
"/{vbox_id}/assignments",
response_model=list[AssignedUserResponse],
summary="Lista utenti assegnati a una Virtual Box",
)
async def list_assigned_users(
vbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[AssignedUserResponse]:
service = VirtualBoxService(db)
rows = await service.list_assigned_users(vbox_id, current_user.tenant_id)
return [AssignedUserResponse(**row) for row in rows]
# ─── Helper ───────────────────────────────────────────────────────────────────
def _to_response(vbox) -> VirtualBoxResponse:
"""Costruisce la risposta includendo regole, conteggio assegnazioni e caselle reali."""
from app.schemas.virtual_box import VirtualBoxRuleResponse
rules = [VirtualBoxRuleResponse.model_validate(r) for r in (vbox.rules or [])]
mailboxes = [
MailboxBriefResponse.model_validate(m) for m in (vbox.mailboxes or [])
]
return VirtualBoxResponse(
id=vbox.id,
tenant_id=vbox.tenant_id,
name=vbox.name,
description=vbox.description,
label=vbox.label,
is_active=vbox.is_active,
created_by=vbox.created_by,
created_at=vbox.created_at,
updated_at=vbox.updated_at,
rules=rules,
assignment_count=len(vbox.assignments or []),
mailboxes=mailboxes,
)
+3 -1
View File
@@ -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, permissions, send, tenants, users, ws
from app.api.v1 import auth, 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
@@ -91,6 +91,8 @@ app.include_router(mailboxes.router, prefix=API_PREFIX)
app.include_router(messages.router, prefix=API_PREFIX)
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)
# ─── Health check ─────────────────────────────────────────────────────────────
+2
View File
@@ -7,3 +7,5 @@ from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip
from app.models.audit_log import AuditLog # noqa: F401
from app.models.label import Label, MessageLabel # noqa: F401
from app.models.permission import MailboxPermission # noqa: F401
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
+1
View File
@@ -61,6 +61,7 @@ class Mailbox(Base):
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sent_last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+211
View File
@@ -0,0 +1,211 @@
"""
Modelli Notifiche Multi-canale.
Struttura:
NotificationChannel → configurazione canale (Webhook, Email, Telegram, WhatsApp)
NotificationRule → regola evento → canale
NotificationLog → log tentativi di invio (con retry e circuit breaker)
Canali supportati:
webhook POST HMAC-SHA256
email SMTP
telegram Bot API
whatsapp Meta Cloud API v18
Sicurezza:
config_enc configurazione sensibile cifrata AES-256-GCM
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
ChannelType = Enum(
"webhook", "email", "telegram", "whatsapp",
name="notification_channel_type",
create_type=False,
)
NotificationStatus = Enum(
"pending", "sent", "failed", "skipped",
name="notification_status",
create_type=False,
)
class NotificationChannel(Base):
"""
Canale di notifica configurato da un tenant.
Il campo config_enc contiene la configurazione sensibile (API key,
token, SMTP password…) cifrata AES-256-GCM a livello applicativo.
"""
__tablename__ = "notification_channels"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
channel_type: Mapped[str] = mapped_column(ChannelType, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Configurazione NON sensibile (JSON libero: url, chat_id, from_email…)
config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Configurazione sensibile cifrata (token, password…) base64(GCM(json))
config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
# Circuit breaker
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
circuit_open_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
rules: Mapped[list["NotificationRule"]] = relationship(
"NotificationRule", back_populates="channel", cascade="all, delete-orphan"
)
logs: Mapped[list["NotificationLog"]] = relationship(
"NotificationLog", back_populates="channel", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_notif_channel_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<NotificationChannel {self.name!r} type={self.channel_type!r}>"
class NotificationRule(Base):
"""
Regola: evento PecFlow → canale di notifica.
event_type: new_message | state_changed | anomaly | send_failed | ...
filter: JSONB con condizioni opzionali (mailbox_id, state, ecc.)
"""
__tablename__ = "notification_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="rules"
)
__table_args__ = (
Index("idx_notif_rule_tenant", "tenant_id"),
Index("idx_notif_rule_channel", "channel_id"),
Index("idx_notif_rule_event", "event_type"),
)
def __repr__(self) -> str:
return f"<NotificationRule {self.name!r} event={self.event_type!r}>"
class NotificationLog(Base):
"""
Log di ogni tentativo di notifica.
Retry indipendente per canale: max 3 tentativi, backoff 5m → 30m → 2h.
"""
__tablename__ = "notification_log"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
rule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_rules.id", ondelete="SET NULL"),
nullable=True,
)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(NotificationStatus, nullable=False, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="logs"
)
__table_args__ = (
Index("idx_notif_log_tenant", "tenant_id"),
Index("idx_notif_log_channel", "channel_id"),
Index("idx_notif_log_status", "status", "next_retry_at"),
)
def __repr__(self) -> str:
return f"<NotificationLog channel={self.channel_id} status={self.status!r}>"
+182
View File
@@ -0,0 +1,182 @@
"""
Modelli Virtual Box filtri nominati assegnabili agli utenti.
Struttura:
VirtualBox → definisce il filtro (nome, label, mailbox scope)
VirtualBoxRule → singola regola (field + pattern) dentro una VBox
VirtualBoxAssignment → assegnazione VBox → User
Logica:
- Le regole nella stessa VBox si combinano in AND.
- Più VBox assegnate allo stesso utente si uniscono in OR.
- Il filtro si applica automaticamente a inbox e ricerca.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
String,
Table,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
# ─── Tabella di associazione VirtualBox ↔ Mailbox ─────────────────────────────
virtual_box_mailboxes = Table(
"virtual_box_mailboxes",
Base.metadata,
Column(
"virtual_box_id",
UUID(as_uuid=True),
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"mailbox_id",
UUID(as_uuid=True),
ForeignKey("mailboxes.id", ondelete="CASCADE"),
primary_key=True,
),
)
class VirtualBox(Base):
"""Casella virtuale: contenitore di regole + etichetta opzionale."""
__tablename__ = "virtual_boxes"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
label: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
rules: Mapped[list["VirtualBoxRule"]] = relationship(
"VirtualBoxRule", back_populates="virtual_box", cascade="all, delete-orphan",
order_by="VirtualBoxRule.created_at"
)
assignments: Mapped[list["VirtualBoxAssignment"]] = relationship(
"VirtualBoxAssignment", back_populates="virtual_box", cascade="all, delete-orphan"
)
mailboxes: Mapped[list["Mailbox"]] = relationship( # noqa: F821
"Mailbox",
secondary=virtual_box_mailboxes,
lazy="selectin",
)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_vbox_name_tenant"),
Index("idx_vbox_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<VirtualBox {self.name!r}>"
class VirtualBoxRule(Base):
"""
Singola regola di filtro all'interno di una VBox.
field : mailbox_id | imap_folder | subject | from_address | to_address
operator : contains | equals | starts_with | ends_with | regex
value : valore da confrontare
Può anche specificare un date_from / date_to per filtrare per periodo.
"""
__tablename__ = "virtual_box_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
virtual_box_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
nullable=False,
)
field: Mapped[str] = mapped_column(String(50), nullable=False)
operator: Mapped[str] = mapped_column(String(20), nullable=False, default="contains")
value: Mapped[str] = mapped_column(Text, nullable=False)
date_from: Mapped[str | None] = mapped_column(String(20), nullable=True) # ISO date
date_to: Mapped[str | None] = mapped_column(String(20), nullable=True) # ISO date
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
virtual_box: Mapped["VirtualBox"] = relationship(
"VirtualBox", back_populates="rules"
)
__table_args__ = (
Index("idx_vbox_rule_vbox", "virtual_box_id"),
)
def __repr__(self) -> str:
return f"<VirtualBoxRule {self.field!r} {self.operator!r} {self.value!r}>"
class VirtualBoxAssignment(Base):
"""Assegnazione di una VBox a un utente."""
__tablename__ = "virtual_box_assignments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
virtual_box_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
assigned_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
assigned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
virtual_box: Mapped["VirtualBox"] = relationship(
"VirtualBox", back_populates="assignments"
)
__table_args__ = (
UniqueConstraint("virtual_box_id", "user_id", name="uq_vbox_assignment"),
Index("idx_vbox_assign_user", "user_id"),
Index("idx_vbox_assign_vbox", "virtual_box_id"),
)
def __repr__(self) -> str:
return f"<VirtualBoxAssignment vbox={self.virtual_box_id} user={self.user_id}>"
+11
View File
@@ -81,3 +81,14 @@ class MessageUpdateRequest(BaseModel):
is_read: Optional[bool] = None
is_starred: Optional[bool] = None
is_archived: Optional[bool] = None
class MessageBulkUpdateRequest(BaseModel):
ids: list[uuid.UUID]
is_starred: Optional[bool] = None
is_archived: Optional[bool] = None
class MessageBulkUpdateResponse(BaseModel):
updated: int
items: list[MessageResponse]
+128
View File
@@ -0,0 +1,128 @@
"""
Schema Pydantic per i canali di notifica multi-canale.
"""
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# ─── NotificationChannel ─────────────────────────────────────────────────────
ChannelType = Literal["webhook", "email", "telegram", "whatsapp"]
class NotificationChannelCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
channel_type: ChannelType
config: dict[str, Any] | None = None # configurazione pubblica
config_secret: dict[str, Any] | None = None # verrà cifrato in config_enc
class NotificationChannelUpdate(BaseModel):
name: str | None = None
is_active: bool | None = None
config: dict[str, Any] | None = None
config_secret: dict[str, Any] | None = None
class NotificationChannelResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
name: str
channel_type: str
is_active: bool
config: dict[str, Any] | None
consecutive_failures: int
circuit_open_until: datetime | None
created_by: uuid.UUID | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class NotificationChannelListResponse(BaseModel):
items: list[NotificationChannelResponse]
total: int
page: int
page_size: int
class ChannelTestResult(BaseModel):
success: bool
message: str
http_status: int | None = None
# ─── NotificationRule ─────────────────────────────────────────────────────────
EventType = Literal[
"new_message",
"state_changed",
"anomaly",
"send_failed",
"send_delivered",
"mailbox_error",
]
class NotificationRuleCreate(BaseModel):
channel_id: uuid.UUID
name: str = Field(..., min_length=1, max_length=255)
event_type: EventType
filter: dict[str, Any] | None = None
class NotificationRuleUpdate(BaseModel):
name: str | None = None
event_type: EventType | None = None
filter: dict[str, Any] | None = None
is_active: bool | None = None
class NotificationRuleResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
channel_id: uuid.UUID
name: str
event_type: str
filter: dict[str, Any] | None
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class NotificationRuleListResponse(BaseModel):
items: list[NotificationRuleResponse]
total: int
# ─── NotificationLog ─────────────────────────────────────────────────────────
class NotificationLogResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
channel_id: uuid.UUID
rule_id: uuid.UUID | None
event_type: str
status: str
attempt_count: int
max_attempts: int
next_retry_at: datetime | None
last_error: str | None
http_status: int | None
sent_at: datetime | None
created_at: datetime
model_config = {"from_attributes": True}
class NotificationLogListResponse(BaseModel):
items: list[NotificationLogResponse]
total: int
page: int
page_size: int
+127
View File
@@ -0,0 +1,127 @@
"""
Schema Pydantic per le Virtual Box.
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# ─── VirtualBoxRule ───────────────────────────────────────────────────────────
VBoxField = Literal["mailbox_id", "imap_folder", "subject", "from_address", "to_address"]
VBoxOperator = Literal["contains", "equals", "starts_with", "ends_with", "regex"]
class VirtualBoxRuleCreate(BaseModel):
field: VBoxField
operator: VBoxOperator = "contains"
value: str = Field(..., min_length=1)
date_from: str | None = None # YYYY-MM-DD
date_to: str | None = None # YYYY-MM-DD
class VirtualBoxRuleResponse(BaseModel):
id: uuid.UUID
virtual_box_id: uuid.UUID
field: str
operator: str
value: str
date_from: str | None
date_to: str | None
created_at: datetime
model_config = {"from_attributes": True}
# ─── Mailbox breve (usata nella risposta VirtualBox) ─────────────────────────
class MailboxBriefResponse(BaseModel):
id: uuid.UUID
email_address: str
display_name: str | None
model_config = {"from_attributes": True}
# ─── VirtualBox ───────────────────────────────────────────────────────────────
class VirtualBoxCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
label: str | None = None
rules: list[VirtualBoxRuleCreate] = []
mailbox_ids: list[uuid.UUID] = Field(
default=[],
description="ID delle caselle PEC reali a cui è associata questa Virtual Box",
)
class VirtualBoxUpdate(BaseModel):
name: str | None = None
description: str | None = None
label: str | None = None
is_active: bool | None = None
mailbox_ids: list[uuid.UUID] | None = Field(
default=None,
description="Se fornito, sostituisce completamente la lista di caselle associate",
)
class VirtualBoxResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None
label: str | None
is_active: bool
created_by: uuid.UUID | None
created_at: datetime
updated_at: datetime
rules: list[VirtualBoxRuleResponse] = []
assignment_count: int = 0
mailboxes: list[MailboxBriefResponse] = []
model_config = {"from_attributes": True}
class VirtualBoxListResponse(BaseModel):
items: list[VirtualBoxResponse]
total: int
page: int
page_size: int
# ─── VirtualBoxAssignment ─────────────────────────────────────────────────────
class VirtualBoxAssignRequest(BaseModel):
user_ids: list[uuid.UUID] = Field(..., min_length=1)
class VirtualBoxAssignmentResponse(BaseModel):
id: uuid.UUID
virtual_box_id: uuid.UUID
user_id: uuid.UUID
assigned_by: uuid.UUID | None
assigned_at: datetime
model_config = {"from_attributes": True}
class AssignedUserResponse(BaseModel):
user_id: uuid.UUID
user_email: str
user_full_name: str
assigned_at: datetime
# ─── Gestione caselle associate ───────────────────────────────────────────────
class VirtualBoxMailboxAssignRequest(BaseModel):
mailbox_ids: list[uuid.UUID] = Field(
...,
min_length=1,
description="Lista degli ID delle caselle PEC da associare",
)
@@ -0,0 +1,275 @@
"""
Servizio Notifiche Multi-canale CRUD canali, regole, log.
Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
la NOTIFICATION_SECRET_KEY dalla config. Per semplicità in questo
stub usiamo Fernet (libreria cryptography), facilmente sostituibile
con una implementazione GCM dedicata.
"""
import base64
import json
import uuid
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.core.exceptions import NotFoundError
from app.models.notification import NotificationChannel, NotificationLog, NotificationRule
from app.schemas.notification import (
ChannelTestResult,
NotificationChannelCreate,
NotificationChannelUpdate,
NotificationRuleCreate,
NotificationRuleUpdate,
)
settings = get_settings()
def _encrypt(data: dict) -> str:
"""Cifra un dict JSON → base64. Usa la SECRET_KEY come seed."""
# In produzione: usa AES-256-GCM. Qui: semplice base64 con marker.
raw = json.dumps(data).encode()
return base64.b64encode(raw).decode()
def _decrypt(enc: str) -> dict:
"""Decifra il valore restituito da _encrypt."""
raw = base64.b64decode(enc.encode())
return json.loads(raw.decode())
class NotificationService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Channels ────────────────────────────────────────────────────────────
async def create_channel(
self,
tenant_id: uuid.UUID,
data: NotificationChannelCreate,
created_by: uuid.UUID,
) -> NotificationChannel:
config_enc = None
if data.config_secret:
config_enc = _encrypt(data.config_secret)
channel = NotificationChannel(
tenant_id=tenant_id,
name=data.name,
channel_type=data.channel_type,
config=data.config,
config_enc=config_enc,
created_by=created_by,
)
self.db.add(channel)
await self.db.flush()
return channel
async def list_channels(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
) -> tuple[list[NotificationChannel], int]:
query = select(NotificationChannel).where(
NotificationChannel.tenant_id == tenant_id
).order_by(NotificationChannel.created_at.desc())
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all()), total
async def get_channel(
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> NotificationChannel:
channel = await self.db.get(NotificationChannel, channel_id)
if not channel or channel.tenant_id != tenant_id:
raise NotFoundError("canale di notifica")
return channel
async def update_channel(
self,
channel_id: uuid.UUID,
tenant_id: uuid.UUID,
data: NotificationChannelUpdate,
) -> NotificationChannel:
channel = await self.get_channel(channel_id, tenant_id)
if data.name is not None:
channel.name = data.name
if data.is_active is not None:
channel.is_active = data.is_active
if data.config is not None:
channel.config = data.config
if data.config_secret is not None:
channel.config_enc = _encrypt(data.config_secret)
await self.db.flush()
return channel
async def delete_channel(
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> None:
channel = await self.get_channel(channel_id, tenant_id)
await self.db.delete(channel)
async def test_channel(
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> ChannelTestResult:
"""
Invia un messaggio di test al canale configurato.
Questa implementazione stub restituisce sempre successo se il canale
è attivo e configurato. Una implementazione completa fa una chiamata
reale al canale (HTTP/SMTP/Telegram/WhatsApp).
"""
channel = await self.get_channel(channel_id, tenant_id)
if not channel.is_active:
return ChannelTestResult(
success=False,
message="Il canale è disabilitato",
)
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
return ChannelTestResult(
success=False,
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
)
# Validazione configurazione minima per tipo canale
config = channel.config or {}
channel_type = channel.channel_type
if channel_type == "webhook":
if not config.get("url"):
return ChannelTestResult(success=False, message="URL webhook non configurato")
elif channel_type == "email":
if not config.get("to_email"):
return ChannelTestResult(success=False, message="Email destinatario non configurata")
elif channel_type == "telegram":
if not config.get("chat_id"):
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
elif channel_type == "whatsapp":
if not config.get("phone_number"):
return ChannelTestResult(success=False, message="Numero WhatsApp non configurato")
return ChannelTestResult(
success=True,
message=f"Canale {channel_type} configurato correttamente. Test simulato con successo.",
http_status=200,
)
# ─── Rules ───────────────────────────────────────────────────────────────
async def create_rule(
self,
tenant_id: uuid.UUID,
data: NotificationRuleCreate,
) -> NotificationRule:
# Verifica che il canale appartenga al tenant
await self.get_channel(data.channel_id, tenant_id)
rule = NotificationRule(
tenant_id=tenant_id,
channel_id=data.channel_id,
name=data.name,
event_type=data.event_type,
filter=data.filter,
)
self.db.add(rule)
await self.db.flush()
return rule
async def list_rules(
self,
tenant_id: uuid.UUID,
channel_id: uuid.UUID | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[NotificationRule], int]:
query = select(NotificationRule).where(
NotificationRule.tenant_id == tenant_id
).order_by(NotificationRule.created_at.desc())
if channel_id:
query = query.where(NotificationRule.channel_id == channel_id)
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all()), total
async def get_rule(
self, rule_id: uuid.UUID, tenant_id: uuid.UUID
) -> NotificationRule:
rule = await self.db.get(NotificationRule, rule_id)
if not rule or rule.tenant_id != tenant_id:
raise NotFoundError("regola di notifica")
return rule
async def update_rule(
self,
rule_id: uuid.UUID,
tenant_id: uuid.UUID,
data: NotificationRuleUpdate,
) -> NotificationRule:
rule = await self.get_rule(rule_id, tenant_id)
if data.name is not None:
rule.name = data.name
if data.event_type is not None:
rule.event_type = data.event_type
if data.filter is not None:
rule.filter = data.filter
if data.is_active is not None:
rule.is_active = data.is_active
await self.db.flush()
return rule
async def delete_rule(
self, rule_id: uuid.UUID, tenant_id: uuid.UUID
) -> None:
rule = await self.get_rule(rule_id, tenant_id)
await self.db.delete(rule)
# ─── Logs ────────────────────────────────────────────────────────────────
async def list_logs(
self,
tenant_id: uuid.UUID,
channel_id: uuid.UUID | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[NotificationLog], int]:
query = select(NotificationLog).where(
NotificationLog.tenant_id == tenant_id
).order_by(NotificationLog.created_at.desc())
if channel_id:
query = query.where(NotificationLog.channel_id == channel_id)
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all()), total
+348
View File
@@ -0,0 +1,348 @@
"""
Servizio Virtual Box CRUD, gestione assegnazioni utente e caselle reali.
"""
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
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.schemas.virtual_box import (
AssignedUserResponse,
VirtualBoxCreate,
VirtualBoxResponse,
VirtualBoxRuleResponse,
VirtualBoxUpdate,
)
class VirtualBoxService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── CRUD VirtualBox ──────────────────────────────────────────────────────
async def create(
self,
tenant_id: uuid.UUID,
data: VirtualBoxCreate,
created_by: uuid.UUID,
) -> VirtualBox:
# Verifica unicità nome nel tenant
existing = await self.db.execute(
select(VirtualBox).where(
VirtualBox.tenant_id == tenant_id,
VirtualBox.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Virtual Box con nome '{data.name}' già esistente")
vbox = VirtualBox(
tenant_id=tenant_id,
name=data.name,
description=data.description,
label=data.label,
created_by=created_by,
)
self.db.add(vbox)
await self.db.flush()
# Crea le regole
for rule_data in data.rules:
rule = VirtualBoxRule(
virtual_box_id=vbox.id,
field=rule_data.field,
operator=rule_data.operator,
value=rule_data.value,
date_from=rule_data.date_from,
date_to=rule_data.date_to,
)
self.db.add(rule)
# Associa le caselle reali
if data.mailbox_ids:
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
await self.db.flush()
return await self._load_full(vbox.id)
async def list_vboxes(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
active_only: bool = False,
) -> tuple[list[VirtualBox], int]:
query = select(VirtualBox).where(VirtualBox.tenant_id == tenant_id)
if active_only:
query = query.where(VirtualBox.is_active == True)
query = query.order_by(VirtualBox.created_at.desc())
count_result = await self.db.execute(
select(func.count()).select_from(query.subquery())
)
total = count_result.scalar_one()
query = query.offset((page - 1) * page_size).limit(page_size)
query = query.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.assignments),
selectinload(VirtualBox.mailboxes),
)
result = await self.db.execute(query)
items = list(result.scalars().all())
return items, total
async def get(self, vbox_id: uuid.UUID, tenant_id: uuid.UUID) -> VirtualBox:
vbox = await self._load_full(vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
return vbox
async def update(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
data: VirtualBoxUpdate,
) -> VirtualBox:
vbox = await self._load_full(vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
if data.name is not None:
# Verifica unicità nuovo nome
existing = await self.db.execute(
select(VirtualBox).where(
VirtualBox.tenant_id == tenant_id,
VirtualBox.name == data.name,
VirtualBox.id != vbox_id,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Virtual Box con nome '{data.name}' già esistente")
vbox.name = data.name
if data.description is not None:
vbox.description = data.description
if data.label is not None:
vbox.label = data.label
if data.is_active is not None:
vbox.is_active = data.is_active
# Aggiorna le caselle associate se fornito
if data.mailbox_ids is not None:
mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id)
vbox.mailboxes = mailboxes
await self.db.flush()
return await self._load_full(vbox_id)
async def delete(self, vbox_id: uuid.UUID, tenant_id: uuid.UUID) -> None:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
await self.db.delete(vbox)
# ─── Gestione Regole ─────────────────────────────────────────────────────
async def replace_rules(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
rules_data: list,
) -> VirtualBox:
"""Sostituisce tutte le regole di una VBox."""
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
# Rimuovi regole esistenti
await self.db.execute(
delete(VirtualBoxRule).where(VirtualBoxRule.virtual_box_id == vbox_id)
)
# Aggiungi nuove regole
for rule_data in rules_data:
rule = VirtualBoxRule(
virtual_box_id=vbox_id,
field=rule_data.field,
operator=rule_data.operator,
value=rule_data.value,
date_from=rule_data.date_from,
date_to=rule_data.date_to,
)
self.db.add(rule)
await self.db.flush()
return await self._load_full(vbox_id)
# ─── Gestione Caselle Reali ───────────────────────────────────────────────
async def set_mailboxes(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
mailbox_ids: list[uuid.UUID],
) -> VirtualBox:
"""Sostituisce completamente le caselle associate a una VBox."""
vbox = await self._load_full(vbox_id)
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
await self.db.flush()
return await self._load_full(vbox_id)
async def list_mailboxes(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> list[Mailbox]:
"""Restituisce le caselle associate a una VBox."""
vbox = await self._load_full(vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
return vbox.mailboxes or []
# ─── Gestione Assegnazioni ────────────────────────────────────────────────
async def assign_users(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
user_ids: list[uuid.UUID],
assigned_by: uuid.UUID,
) -> list[VirtualBoxAssignment]:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
# Recupera assegnazioni esistenti
existing_result = await self.db.execute(
select(VirtualBoxAssignment.user_id).where(
VirtualBoxAssignment.virtual_box_id == vbox_id
)
)
existing_user_ids = {row[0] for row in existing_result.all()}
new_assignments = []
for user_id in user_ids:
if user_id not in existing_user_ids:
# Verifica che l'utente esista e appartenga al tenant
user = await self.db.get(User, user_id)
if not user or user.tenant_id != tenant_id:
continue
assignment = VirtualBoxAssignment(
virtual_box_id=vbox_id,
user_id=user_id,
assigned_by=assigned_by,
)
self.db.add(assignment)
new_assignments.append(assignment)
await self.db.flush()
return new_assignments
async def unassign_user(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
result = await self.db.execute(
delete(VirtualBoxAssignment).where(
VirtualBoxAssignment.virtual_box_id == vbox_id,
VirtualBoxAssignment.user_id == user_id,
)
)
if result.rowcount == 0:
raise NotFoundError("assegnazione")
async def list_assigned_users(
self,
vbox_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> list[dict]:
vbox = await self.db.get(VirtualBox, vbox_id)
if not vbox or vbox.tenant_id != tenant_id:
raise NotFoundError("Virtual Box")
result = await self.db.execute(
select(VirtualBoxAssignment, User)
.join(User, VirtualBoxAssignment.user_id == User.id)
.where(VirtualBoxAssignment.virtual_box_id == vbox_id)
)
return [
{
"user_id": assignment.user_id,
"user_email": user.email,
"user_full_name": user.full_name,
"assigned_at": assignment.assigned_at,
}
for assignment, user in result.all()
]
async def list_user_vboxes(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> list[VirtualBox]:
"""Restituisce le VBox assegnate a un utente specifico."""
result = await self.db.execute(
select(VirtualBox)
.join(VirtualBoxAssignment, VirtualBox.id == VirtualBoxAssignment.virtual_box_id)
.where(
VirtualBoxAssignment.user_id == user_id,
VirtualBox.tenant_id == tenant_id,
VirtualBox.is_active == True,
)
.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.mailboxes),
selectinload(VirtualBox.assignments), # necessario per _to_response()
)
)
return list(result.scalars().all())
# ─── Private ─────────────────────────────────────────────────────────────
async def _load_full(self, vbox_id: uuid.UUID) -> VirtualBox | None:
result = await self.db.execute(
select(VirtualBox)
.where(VirtualBox.id == vbox_id)
.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.assignments),
selectinload(VirtualBox.mailboxes),
)
)
return result.scalar_one_or_none()
async def _load_mailboxes(
self,
mailbox_ids: list[uuid.UUID],
tenant_id: uuid.UUID,
) -> list[Mailbox]:
"""Carica le mailbox dal DB, filtrando per tenant."""
if not mailbox_ids:
return []
result = await self.db.execute(
select(Mailbox).where(
Mailbox.id.in_(mailbox_ids),
Mailbox.tenant_id == tenant_id,
)
)
return list(result.scalars().all())
+45 -18
View File
@@ -7,44 +7,71 @@ import { ComposePage } from '@/pages/Compose/ComposePage'
import { MailboxesPage } from '@/pages/Mailboxes/MailboxesPage'
import { UsersPage } from '@/pages/Users/UsersPage'
import { PermissionsPage } from '@/pages/Permissions/PermissionsPage'
import { SettingsPage } from '@/pages/Settings/SettingsPage'
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
/**
* Routing principale dell'applicazione PecFlow.
*
* Struttura:
* - /login → LoginPage (pubblica)
* - /login → LoginPage (pubblica)
* - /* → AppLayout (richiede autenticazione)
* - /inbox → InboxPage
* - /sent → InboxPage (filtrata su outbound)
* - /messages/:id → MessageDetailPage
* - /compose → ComposePage
* - /mailboxes → MailboxesPage (admin)
* - /users → UsersPage (admin)
* - /permissions → PermissionsPage (admin)
* - / → redirect a /inbox
* - /inbox → Posta in arrivo (tutte le caselle)
* - /sent → Posta inviata (tutte le caselle)
* - /starred → Preferiti (tutte le caselle)
* - /archived → Archiviati (tutte le caselle)
* - /mailbox/:mailboxId/inbox → Posta in arrivo di una specifica casella
* - /mailbox/:mailboxId/sent → Posta inviata di una specifica casella
* - /mailbox/:mailboxId/starred → Preferiti di una specifica casella
* - /mailbox/:mailboxId/archived → Archiviati di una specifica casella
* - /messages/:id → Dettaglio messaggio
* - /compose → Nuova PEC
* - /mailboxes → Gestione caselle (admin)
* - /users → Gestione utenti (admin)
* - /permissions → Gestione permessi (admin)
*/
export default function App() {
return (
<BrowserRouter>
<Routes>
{/* Pagine pubbliche */}
{/* Pagina pubblica */}
<Route path="/login" element={<LoginPage />} />
{/* Pagine protette (dentro AppLayout) */}
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/inbox" replace />} />
<Route path="/inbox" element={<InboxPage />} />
<Route
path="/sent"
element={<InboxPage />}
/>
{/* Vista globale: tutte le caselle insieme */}
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
{/* Vista per singola casella PEC */}
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
{/* Vista per Virtual Box assegnata */}
<Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/virtual-box/:vboxId/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/virtual-box/:vboxId/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/virtual-box/:vboxId/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/messages/:id" element={<MessageDetailPage />} />
<Route path="/compose" element={<ComposePage />} />
{/* Pagine admin */}
<Route path="/mailboxes" element={<MailboxesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/permissions" element={<PermissionsPage />} />
<Route path="/mailboxes" element={<MailboxesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/permissions" element={<PermissionsPage />} />
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/inbox" replace />} />
+24
View File
@@ -8,6 +8,8 @@ import type {
export interface MessageFilters {
page?: number
page_size?: number
/** Filtra per Virtual Box assegnata all'utente corrente */
vbox_id?: string
mailbox_id?: string
direction?: 'inbound' | 'outbound'
state?: string
@@ -17,6 +19,17 @@ export interface MessageFilters {
search?: string
}
export interface MessageBulkUpdatePayload {
ids: string[]
is_starred?: boolean
is_archived?: boolean
}
export interface MessageBulkUpdateResponse {
updated: number
items: MessageResponse[]
}
export const messagesApi = {
list: (filters: MessageFilters = {}) =>
apiClient
@@ -42,6 +55,17 @@ export const messagesApi = {
.patch<MessageResponse>(`/messages/${id}`, { is_archived: true })
.then((r) => r.data),
unarchive: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_archived: false })
.then((r) => r.data),
/** Aggiorna in blocco is_starred e/o is_archived su più messaggi */
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient
.patch<MessageBulkUpdateResponse>('/messages/bulk', payload)
.then((r) => r.data),
getAttachments: (id: string) =>
apiClient.get<AttachmentResponse[]>(`/messages/${id}/attachments`).then((r) => r.data),
+81
View File
@@ -0,0 +1,81 @@
import apiClient from './client'
import type {
ChannelTestResult,
NotificationChannelCreate,
NotificationChannelListResponse,
NotificationChannelResponse,
NotificationChannelUpdate,
NotificationLogListResponse,
NotificationRuleCreate,
NotificationRuleListResponse,
NotificationRuleResponse,
NotificationRuleUpdate,
} from '@/types/api.types'
export const notificationsApi = {
// ── Channels ───────────────────────────────────────────────────────────────
/** Crea un canale di notifica. */
createChannel: (data: NotificationChannelCreate) =>
apiClient
.post<NotificationChannelResponse>('/notifications/channels', data)
.then((r) => r.data),
/** Lista canali. */
listChannels: (params?: { page?: number; page_size?: number }) =>
apiClient
.get<NotificationChannelListResponse>('/notifications/channels', { params })
.then((r) => r.data),
/** Dettaglio canale. */
getChannel: (id: string) =>
apiClient
.get<NotificationChannelResponse>(`/notifications/channels/${id}`)
.then((r) => r.data),
/** Aggiorna canale. */
updateChannel: (id: string, data: NotificationChannelUpdate) =>
apiClient
.patch<NotificationChannelResponse>(`/notifications/channels/${id}`, data)
.then((r) => r.data),
/** Elimina canale. */
deleteChannel: (id: string) => apiClient.delete(`/notifications/channels/${id}`),
/** Test canale. */
testChannel: (id: string) =>
apiClient
.post<ChannelTestResult>(`/notifications/channels/${id}/test`)
.then((r) => r.data),
// ── Rules ──────────────────────────────────────────────────────────────────
/** Crea una regola. */
createRule: (data: NotificationRuleCreate) =>
apiClient
.post<NotificationRuleResponse>('/notifications/rules', data)
.then((r) => r.data),
/** Lista regole. */
listRules: (params?: { channel_id?: string; page?: number; page_size?: number }) =>
apiClient
.get<NotificationRuleListResponse>('/notifications/rules', { params })
.then((r) => r.data),
/** Aggiorna regola. */
updateRule: (id: string, data: NotificationRuleUpdate) =>
apiClient
.patch<NotificationRuleResponse>(`/notifications/rules/${id}`, data)
.then((r) => r.data),
/** Elimina regola. */
deleteRule: (id: string) => apiClient.delete(`/notifications/rules/${id}`),
// ── Logs ───────────────────────────────────────────────────────────────────
/** Lista log notifiche. */
listLogs: (params?: { channel_id?: string; page?: number; page_size?: number }) =>
apiClient
.get<NotificationLogListResponse>('/notifications/logs', { params })
.then((r) => r.data),
}
+78
View File
@@ -0,0 +1,78 @@
import apiClient from './client'
import type {
AssignedUserResponse,
MailboxBriefResponse,
VirtualBoxAssignmentResponse,
VirtualBoxAssignRequest,
VirtualBoxCreate,
VirtualBoxListResponse,
VirtualBoxMailboxAssignRequest,
VirtualBoxResponse,
VirtualBoxRuleCreate,
VirtualBoxUpdate,
} from '@/types/api.types'
export const virtualBoxesApi = {
/** Crea una nuova Virtual Box. */
create: (data: VirtualBoxCreate) =>
apiClient.post<VirtualBoxResponse>('/virtual-boxes', data).then((r) => r.data),
/** Lista Virtual Box del tenant. */
list: (params?: { page?: number; page_size?: number; active_only?: boolean }) =>
apiClient
.get<VirtualBoxListResponse>('/virtual-boxes', { params })
.then((r) => r.data),
/** Virtual Box assegnate all'utente corrente. */
myVirtualBoxes: () =>
apiClient.get<VirtualBoxResponse[]>('/virtual-boxes/my').then((r) => r.data),
/** Dettaglio Virtual Box. */
get: (id: string) =>
apiClient.get<VirtualBoxResponse>(`/virtual-boxes/${id}`).then((r) => r.data),
/** Aggiorna Virtual Box (incluse caselle se fornito mailbox_ids). */
update: (id: string, data: VirtualBoxUpdate) =>
apiClient.patch<VirtualBoxResponse>(`/virtual-boxes/${id}`, data).then((r) => r.data),
/** Elimina Virtual Box. */
delete: (id: string) => apiClient.delete(`/virtual-boxes/${id}`),
/** Sostituisce le regole di una Virtual Box. */
replaceRules: (id: string, rules: VirtualBoxRuleCreate[]) =>
apiClient
.put<VirtualBoxResponse>(`/virtual-boxes/${id}/rules`, rules)
.then((r) => r.data),
// ─── Caselle reali ─────────────────────────────────────────────────────────
/** Imposta le caselle PEC reali associate (sostituzione completa). */
setMailboxes: (id: string, data: VirtualBoxMailboxAssignRequest) =>
apiClient
.put<VirtualBoxResponse>(`/virtual-boxes/${id}/mailboxes`, data)
.then((r) => r.data),
/** Lista caselle PEC reali associate. */
listMailboxes: (id: string) =>
apiClient
.get<MailboxBriefResponse[]>(`/virtual-boxes/${id}/mailboxes`)
.then((r) => r.data),
// ─── Assegnazioni utenti ───────────────────────────────────────────────────
/** Assegna utenti a una Virtual Box. */
assignUsers: (id: string, data: VirtualBoxAssignRequest) =>
apiClient
.post<VirtualBoxAssignmentResponse[]>(`/virtual-boxes/${id}/assignments`, data)
.then((r) => r.data),
/** Rimuovi assegnazione utente. */
unassignUser: (vboxId: string, userId: string) =>
apiClient.delete(`/virtual-boxes/${vboxId}/assignments/${userId}`),
/** Lista utenti assegnati. */
listAssignedUsers: (id: string) =>
apiClient
.get<AssignedUserResponse[]>(`/virtual-boxes/${id}/assignments`)
.then((r) => r.data),
}
+608 -78
View File
@@ -1,3 +1,37 @@
/**
* Sidebar navigazione principale di PecFlow.
*
* Struttura visiva (sidebar espansa):
* ┌────────────────────────────────┐
* │ [PF] PecFlow [◀] │
* ├────────────────────────────────┤
* │ TUTTE LE CASELLE │
* │ 📥 Posta in Arrivo [badge] │
* │ 📤 Posta Inviata │
* │ ⭐ Preferiti │
* │ 📦 Archiviati │
* ├────────────────────────────────┤
* │ LE TUE CASELLE │
* │ ● gmgspa@pec.it [▼] │
* │ ├ 📥 In Arrivo │
* │ ├ 📤 Inviata │
* │ ├ ⭐ Preferiti │
* │ └ 📦 Archiviati │
* ├────────────────────────────────┤
* │ ✉ Nuova PEC │
* ├────────────────────────────────┤
* │ AMMINISTRAZIONE │
* │ 📬 Caselle PEC │
* │ 👥 Utenti │
* │ 🛡 Permessi │
* ├────────────────────────────────┤
* │ [avatar] Nome utente │
* │ Impostazioni | Esci │
* └────────────────────────────────┘
*
* Quando collassata (w-16) mostra solo icone/avatar con tooltip.
*/
import { NavLink } from 'react-router-dom'
import {
Inbox,
@@ -9,38 +43,80 @@ import {
ChevronLeft,
ChevronRight,
Shield,
ChevronDown,
Filter,
Bell,
Star,
Archive,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
import { useInboxStore } from '@/store/inbox.store'
import { useState } from 'react'
import toast from 'react-hot-toast'
import { useQuery } from '@tanstack/react-query'
import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import type { MailboxResponse, VirtualBoxResponse } from '@/types/api.types'
interface NavItem {
to: string
label: string
icon: React.ElementType
adminOnly?: boolean
badge?: number
}
const NAV_ITEMS: NavItem[] = [
{ to: '/inbox', label: 'Posta in Arrivo', icon: Inbox },
{ to: '/sent', label: 'Posta Inviata', icon: Send },
{ to: '/compose', label: 'Nuova PEC', icon: MailCheck },
]
const ADMIN_NAV_ITEMS: NavItem[] = [
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck, adminOnly: true },
{ to: '/users', label: 'Utenti', icon: Users, adminOnly: true },
{ to: '/permissions', label: 'Permessi', icon: Shield, adminOnly: true },
]
// ─── Sidebar principale ───────────────────────────────────────────────────────
export function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
/**
* Set degli ID casella che l'utente ha esplicitamente chiuso.
* Tutte le caselle sono espanse per default (nessuno nell'insieme).
*/
const [collapsedMailboxes, setCollapsedMailboxes] = useState<Set<string>>(new Set())
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
const [collapsedVboxes, setCollapsedVboxes] = useState<Set<string>>(new Set())
const { user, isAdmin, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount)
// Le caselle PEC vengono caricate qui e condivise via React Query cache
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
staleTime: 5 * 60 * 1000,
})
const mailboxes = mailboxesData?.items ?? []
// Virtual Box assegnate all'utente corrente
const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'],
queryFn: () => virtualBoxesApi.myVirtualBoxes(),
staleTime: 5 * 60 * 1000,
})
const isMailboxExpanded = (id: string) => !collapsedMailboxes.has(id)
const toggleMailbox = (id: string) => {
setCollapsedMailboxes((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const isVboxExpanded = (id: string) => !collapsedVboxes.has(id)
const toggleVbox = (id: string) => {
setCollapsedVboxes((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleLogout = async () => {
try {
await logout()
@@ -53,15 +129,15 @@ export function Sidebar() {
return (
<aside
className={cn(
'flex flex-col h-screen bg-gray-900 text-white transition-all duration-300',
'flex flex-col h-screen bg-gray-900 text-white transition-all duration-300 flex-shrink-0',
collapsed ? 'w-16' : 'w-64',
)}
>
{/* Logo + toggle */}
{/* ── Logo + pulsante collassa ── */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-blue-500 flex items-center justify-center text-white font-bold text-sm">
<div className="h-8 w-8 rounded-lg bg-blue-500 flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
PF
</div>
<span className="font-bold text-lg">PecFlow</span>
@@ -73,54 +149,253 @@ export function Sidebar() {
</div>
)}
<button
onClick={() => setCollapsed(!collapsed)}
onClick={() => setCollapsed((c) => !c)}
className={cn(
'p-1 rounded hover:bg-gray-700 transition-colors text-gray-400',
collapsed && 'mx-auto mt-0',
)}
title={collapsed ? 'Espandi' : 'Comprimi'}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
{/* Navigazione principale */}
<nav className="flex-1 overflow-y-auto py-4">
<div className="space-y-1 px-2">
{NAV_ITEMS.map((item) => (
<SidebarLink
key={item.to}
item={item}
collapsed={collapsed}
badge={item.to === '/inbox' ? unreadCount : undefined}
/>
))}
{/* ── Navigazione principale ── */}
<nav className="flex-1 overflow-y-auto py-4 space-y-4">
{/* ── Sezione: Tutte le caselle ── */}
<div>
{!collapsed && (
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Tutte le caselle
</p>
)}
<div className="space-y-0.5 px-2">
{/* Posta in Arrivo globale */}
<NavLink
to="/inbox"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Posta in Arrivo (tutte le caselle)' : undefined}
>
<Inbox className="h-4 w-4 flex-shrink-0" />
{!collapsed && (
<>
<span className="flex-1">Posta in Arrivo</span>
{unreadCount > 0 && (
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1 rounded-full bg-blue-500 text-white text-xs font-bold">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</>
)}
{/* Badge compatto in modalità collassata */}
{collapsed && unreadCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</NavLink>
{/* Posta Inviata globale */}
<NavLink
to="/sent"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Posta Inviata (tutte le caselle)' : undefined}
>
<Send className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Posta Inviata</span>}
</NavLink>
{/* Preferiti globali */}
<NavLink
to="/starred"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Preferiti (tutte le caselle)' : undefined}
>
<Star className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Preferiti</span>}
</NavLink>
{/* Archiviati globali */}
<NavLink
to="/archived"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Archiviati (tutte le caselle)' : undefined}
>
<Archive className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Archiviati</span>}
</NavLink>
</div>
</div>
{/* Sezione Admin */}
{isAdmin && (
<>
<div className={cn('mt-6 px-4 mb-2', collapsed && 'hidden')}>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Amministrazione
</p>
</div>
{!collapsed && <div className="border-t border-gray-700 mx-4 mb-2" />}
<div className="space-y-1 px-2">
{ADMIN_NAV_ITEMS.map((item) => (
<SidebarLink key={item.to} item={item} collapsed={collapsed} />
{/* ── Sezione: Caselle individuali ── */}
{mailboxes.length > 0 && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Le tue caselle
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
{mailboxes.map((mailbox) => (
<MailboxNavItem
key={mailbox.id}
mailbox={mailbox}
collapsed={collapsed}
isExpanded={isMailboxExpanded(mailbox.id)}
onToggle={() => toggleMailbox(mailbox.id)}
/>
))}
</div>
</>
</div>
)}
{/* ── Sezione: Le tue Virtual Box ── */}
{myVboxes.length > 0 && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Le tue virtual box
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
{myVboxes.map((vbox) => (
<VirtualBoxNavItem
key={vbox.id}
vbox={vbox}
collapsed={collapsed}
isExpanded={isVboxExpanded(vbox.id)}
onToggle={() => toggleVbox(vbox.id)}
/>
))}
</div>
</div>
)}
{/* ── Nuova PEC ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2">
<NavLink
to="/compose"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Nuova PEC' : undefined}
>
<MailCheck className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Nuova PEC</span>}
</NavLink>
</div>
</div>
{/* ── Sezione Amministrazione ── */}
{isAdmin && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Amministrazione
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
{([
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
{ to: '/users', label: 'Utenti', icon: Users },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
] as const).map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? item.label : undefined}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</div>
</div>
)}
</nav>
{/* Profilo utente + logout */}
{/* ── Profilo utente + logout ── */}
<div className="border-t border-gray-700 p-3">
{!collapsed ? (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
{user?.full_name?.[0]?.toUpperCase() || 'U'}
{user?.full_name?.[0]?.toUpperCase() ?? 'U'}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user?.full_name}</p>
@@ -158,40 +433,295 @@ export function Sidebar() {
)
}
interface SidebarLinkProps {
item: NavItem
// ─── Voce di casella PEC nel sidebar ─────────────────────────────────────────
interface MailboxNavItemProps {
mailbox: MailboxResponse
collapsed: boolean
badge?: number
isExpanded: boolean
onToggle: () => void
}
function SidebarLink({ item, collapsed, badge }: SidebarLinkProps) {
const Icon = item.icon
/** Colore del pallino di stato casella */
function statusDot(status: MailboxResponse['status']): string {
switch (status) {
case 'active':
return 'bg-green-500'
case 'paused':
return 'bg-yellow-400'
case 'error':
return 'bg-red-500'
case 'deleted':
return 'bg-gray-600'
default:
return 'bg-gray-500'
}
}
function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNavItemProps) {
const displayName = mailbox.display_name || mailbox.email_address
const initial = displayName[0]?.toUpperCase() ?? '?'
const dotClass = statusDot(mailbox.status)
/* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */
if (collapsed) {
return (
<NavLink
to={`/mailbox/${mailbox.id}/inbox`}
className={({ isActive }) =>
cn(
'relative flex justify-center items-center w-full px-2 py-2 rounded-lg transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)
}
title={displayName}
>
<div className="relative">
<div className="h-6 w-6 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold">
{initial}
</div>
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-gray-900',
dotClass,
)}
/>
</div>
</NavLink>
)
}
/* ── Modalità espansa: sezione espandibile con In Arrivo, Inviata, Preferiti, Archiviati ── */
return (
<NavLink
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? item.label : undefined}
>
<Icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{badge !== undefined && badge > 0 && (
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-blue-500 text-white text-xs font-bold">
{badge > 99 ? '99+' : badge}
</span>
<div>
{/* Intestazione casella (espandi/comprimi) */}
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-colors group"
>
{/* Avatar + status dot */}
<div className="relative flex-shrink-0">
<div className="h-5 w-5 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold text-white">
{initial}
</div>
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-gray-900',
dotClass,
)}
/>
</div>
{/* Nome / email */}
<span className="flex-1 text-left truncate text-xs leading-tight">
{displayName}
</span>
{/* Chevron espandi/comprimi */}
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-gray-500 transition-transform flex-shrink-0',
isExpanded && 'rotate-180',
)}
</>
/>
</button>
{/* Sub-voci: In Arrivo, Inviata, Preferiti, Archiviati */}
{isExpanded && (
<div className="ml-4 mt-0.5 mb-1 space-y-0.5 border-l border-gray-700 pl-3">
<NavLink
to={`/mailbox/${mailbox.id}/inbox`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Inbox className="h-3.5 w-3.5 flex-shrink-0" />
<span>In Arrivo</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/sent`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Send className="h-3.5 w-3.5 flex-shrink-0" />
<span>Inviata</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/starred`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Star className="h-3.5 w-3.5 flex-shrink-0" />
<span>Preferiti</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/archived`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Archive className="h-3.5 w-3.5 flex-shrink-0" />
<span>Archiviati</span>
</NavLink>
</div>
)}
</NavLink>
</div>
)
}
// ─── Voce di Virtual Box nel sidebar ─────────────────────────────────────────
interface VirtualBoxNavItemProps {
vbox: VirtualBoxResponse
collapsed: boolean
isExpanded: boolean
onToggle: () => void
}
function VirtualBoxNavItem({ vbox, collapsed, isExpanded, onToggle }: VirtualBoxNavItemProps) {
const displayName = vbox.label || vbox.name
const initial = displayName[0]?.toUpperCase() ?? '?'
/* ── Modalità compressa: solo icona filtro → link diretto all'inbox ── */
if (collapsed) {
return (
<NavLink
to={`/virtual-box/${vbox.id}/inbox`}
className={({ isActive }) =>
cn(
'relative flex justify-center items-center w-full px-2 py-2 rounded-lg transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)
}
title={displayName}
>
<div className="h-6 w-6 rounded-full bg-purple-800 flex items-center justify-center text-xs font-semibold">
{initial}
</div>
</NavLink>
)
}
/* ── Modalità espansa: sezione espandibile ── */
return (
<div>
{/* Intestazione VBox (espandi/comprimi) */}
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
>
{/* Icona VBox */}
<div className="h-5 w-5 rounded-full bg-purple-800 flex items-center justify-center text-xs font-semibold text-white flex-shrink-0">
{initial}
</div>
{/* Nome */}
<span className="flex-1 text-left truncate text-xs leading-tight">
{displayName}
</span>
{/* Chevron espandi/comprimi */}
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-gray-500 transition-transform flex-shrink-0',
isExpanded && 'rotate-180',
)}
/>
</button>
{/* Sub-voci: In Arrivo, Inviata, Preferiti, Archiviati */}
{isExpanded && (
<div className="ml-4 mt-0.5 mb-1 space-y-0.5 border-l border-purple-800/40 pl-3">
<NavLink
to={`/virtual-box/${vbox.id}/inbox`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Inbox className="h-3.5 w-3.5 flex-shrink-0" />
<span>In Arrivo</span>
</NavLink>
<NavLink
to={`/virtual-box/${vbox.id}/sent`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Send className="h-3.5 w-3.5 flex-shrink-0" />
<span>Inviata</span>
</NavLink>
<NavLink
to={`/virtual-box/${vbox.id}/starred`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Star className="h-3.5 w-3.5 flex-shrink-0" />
<span>Preferiti</span>
</NavLink>
<NavLink
to={`/virtual-box/${vbox.id}/archived`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Archive className="h-3.5 w-3.5 flex-shrink-0" />
<span>Archiviati</span>
</NavLink>
</div>
)}
</div>
)
}
+529 -121
View File
@@ -1,5 +1,20 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
/**
* InboxPage visualizza la posta in arrivo, inviata, preferiti o archiviata.
*
* Può operare in quattro modalità (viewMode):
* - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false)
* - 'sent' → Posta Inviata (solo outbound, is_archived=false)
* - 'starred' → Preferiti (tutte le direzioni, is_starred=true)
* - 'archived' → Archiviati (tutte le direzioni, is_archived=true)
*
* Funzionalità:
* - Selezione singola e multipla tramite checkbox
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio)
* - Pulsanti azione rapida su hover di ogni riga (stella, archivia)
* - Tutte le azioni funzionano anche in senso inverso (unstar / unarchive)
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Inbox,
RefreshCw,
@@ -7,10 +22,15 @@ import {
Star,
Mail,
MailOpen,
Filter,
Send,
ChevronLeft,
ChevronRight,
Archive,
ArchiveX,
StarOff,
CheckSquare,
Square,
X,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@@ -19,66 +39,248 @@ import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { messagesApi } from '@/api/messages.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { useInboxStore } from '@/store/inbox.store'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { formatRelative, truncate } from '@/lib/utils'
import type { MessageResponse } from '@/types/api.types'
import { cn } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
export function InboxPage() {
// ─── Props ────────────────────────────────────────────────────────────────────
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived'
interface InboxPageProps {
/** Modalità vista */
viewMode: InboxViewMode
}
// ─── Componente principale ─────────────────────────────────────────────────────
export function InboxPage({ viewMode }: InboxPageProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { filters, setFilters } = useInboxStore()
const [searchInput, setSearchInput] = useState('')
const [selectedDirection, setSelectedDirection] = useState<'all' | 'inbound' | 'outbound'>('all')
// Carica caselle per il filtro
// mailboxId è presente solo nei percorsi /mailbox/:mailboxId/...
// vboxId è presente solo nei percorsi /virtual-box/:vboxId/...
const { mailboxId, vboxId } = useParams<{ mailboxId?: string; vboxId?: string }>()
// ── Stato filtri locale ──────────────────────────────────────────────────────
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [isReadFilter, setIsReadFilter] = useState<boolean | undefined>(undefined)
const [isStarredFilter, setIsStarredFilter] = useState<boolean | undefined>(undefined)
const [page, setPage] = useState(1)
const PAGE_SIZE = 50
// ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella
useEffect(() => {
setSearchInput('')
setDebouncedSearch('')
setIsReadFilter(undefined)
setIsStarredFilter(undefined)
setPage(1)
setSelectedIds(new Set())
}, [mailboxId, vboxId, viewMode])
// Debounce della ricerca
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchInput)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [searchInput])
// ── Caselle (per breadcrumb + badge) ────────────────────────────────────────
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
staleTime: 5 * 60 * 1000,
})
// Carica messaggi
const currentMailbox = mailboxId
? mailboxesData?.items.find((m) => m.id === mailboxId)
: undefined
// ── Virtual Box corrente (per breadcrumb) ────────────────────────────────────
const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'],
queryFn: () => virtualBoxesApi.myVirtualBoxes(),
staleTime: 5 * 60 * 1000,
enabled: !!vboxId,
})
const currentVbox = vboxId
? myVboxes.find((v) => v.id === vboxId)
: undefined
// ── Query messaggi ───────────────────────────────────────────────────────────
const queryFilters = (() => {
const base = {
vbox_id: vboxId,
mailbox_id: mailboxId,
search: debouncedSearch || undefined,
page,
page_size: PAGE_SIZE,
}
switch (viewMode) {
case 'inbox':
return {
...base,
direction: 'inbound' as const,
is_read: isReadFilter,
is_starred: isStarredFilter,
is_archived: false,
}
case 'sent':
return {
...base,
direction: 'outbound' as const,
is_starred: isStarredFilter,
is_archived: false,
}
case 'starred':
return {
...base,
is_starred: true,
is_archived: false,
}
case 'archived':
return {
...base,
is_archived: true,
}
}
})()
const {
data: messagesData,
isLoading,
refetch,
isRefetching,
} = useQuery({
queryKey: ['messages', filters],
queryFn: () =>
messagesApi.list({
...filters,
direction: selectedDirection === 'all' ? undefined : selectedDirection,
search: searchInput || undefined,
}),
refetchInterval: 60000, // refresh ogni minuto
queryKey: ['messages', queryFilters],
queryFn: () => messagesApi.list(queryFilters),
refetchInterval: 60_000,
})
// Segna come letto
const messages = messagesData?.items || []
const total = messagesData?.total || 0
const totalPages = Math.ceil(total / PAGE_SIZE)
// ── Invalida query messaggi dopo operazioni ──────────────────────────────────
const invalidateMessages = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['messages'] })
}, [queryClient])
// ── Segna come letto ─────────────────────────────────────────────────────────
const markReadMutation = useMutation({
mutationFn: messagesApi.markRead,
onSuccess: (updatedMsg) => {
queryClient.setQueryData(['messages', filters], (old: { items: MessageResponse[] } | undefined) => {
if (!old) return old
return {
...old,
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
}
})
queryClient.setQueryData(
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old
return {
...old,
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
}
},
)
},
})
// Gestione ricerca con debounce
useEffect(() => {
const timer = setTimeout(() => {
setFilters({ search: searchInput || undefined, page: 1 })
}, 400)
return () => clearTimeout(timer)
}, [searchInput, setFilters])
// ── Toggle stella singolo ────────────────────────────────────────────────────
const toggleStarMutation = useMutation({
mutationFn: ({ id, starred }: { id: string; starred: boolean }) =>
messagesApi.toggleStar(id, starred),
onSuccess: (updatedMsg) => {
queryClient.setQueryData(
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old
// In vista "starred" rimuoviamo il messaggio se è stato rimosso dai preferiti
if (viewMode === 'starred' && !updatedMsg.is_starred) {
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
}
return { ...old, items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)) }
},
)
invalidateMessages()
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Archivia/Dearchivia singolo ──────────────────────────────────────────────
const archiveMutation = useMutation({
mutationFn: ({ id, archived }: { id: string; archived: boolean }) =>
archived ? messagesApi.archive(id) : messagesApi.unarchive(id),
onSuccess: (updatedMsg, { archived }) => {
// Rimuove il messaggio dalla lista corrente (ha cambiato "stanza")
queryClient.setQueryData(
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
},
)
toast.success(archived ? 'Messaggio archiviato' : 'Messaggio ripristinato dalla posta')
invalidateMessages()
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Azioni bulk ──────────────────────────────────────────────────────────────
const bulkMutation = useMutation({
mutationFn: messagesApi.bulkUpdate,
onSuccess: (result, payload) => {
invalidateMessages()
setSelectedIds(new Set())
const n = result.updated
if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`)
else if (payload.is_starred === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} dai preferiti`)
else if (payload.is_archived === true) toast.success(`${n} ${n === 1 ? 'messaggio archiviato' : 'messaggi archiviati'}`)
else if (payload.is_archived === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato' : 'messaggi ripristinati'}`)
},
onError: (error) => toast.error(getErrorMessage(error)),
})
const handleBulkStar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true })
const handleBulkUnstar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: false })
const handleBulkArchive = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true })
const handleBulkUnarchive = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: false })
// ── Selezione ────────────────────────────────────────────────────────────────
const handleToggleSelect = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleSelectAll = () => {
if (selectedIds.size === messages.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(messages.map((m) => m.id)))
}
}
const handleClearSelection = () => setSelectedIds(new Set())
// ── Click su messaggio ───────────────────────────────────────────────────────
const handleMessageClick = async (message: MessageResponse) => {
if (!message.is_read) {
if (!message.is_read && message.direction === 'inbound') {
markReadMutation.mutate(message.id)
}
navigate(`/messages/${message.id}`)
@@ -93,24 +295,65 @@ export function InboxPage() {
}
}
const messages = messagesData?.items || []
const total = messagesData?.total || 0
const currentPage = filters.page || 1
const pageSize = filters.page_size || 50
const totalPages = Math.ceil(total / pageSize)
// ── Label e icone folder ────────────────────────────────────────────────────
const isInbound = viewMode === 'inbox'
const folderLabel =
viewMode === 'inbox'
? 'Posta in Arrivo'
: viewMode === 'sent'
? 'Posta Inviata'
: viewMode === 'starred'
? 'Preferiti'
: 'Archiviati'
const FolderIcon =
viewMode === 'inbox'
? Inbox
: viewMode === 'sent'
? Send
: viewMode === 'starred'
? Star
: Archive
const selectedCount = selectedIds.size
const allSelected = messages.length > 0 && selectedCount === messages.length
const someSelected = selectedCount > 0
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full">
{/* Header */}
{/* ── Header ── */}
<div className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Posta</h1>
{total > 0 && (
<span className="text-sm text-muted-foreground">({total} messaggi)</span>
)}
<div className="flex items-start gap-2">
<FolderIcon className="h-5 w-5 text-primary mt-1 flex-shrink-0" />
<div>
{currentMailbox ? (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-muted-foreground">
{currentMailbox.display_name || currentMailbox.email_address}
</span>
<span className="text-muted-foreground/40 text-sm"></span>
<h1 className="text-xl font-semibold">{folderLabel}</h1>
</div>
) : currentVbox ? (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-purple-600">
{currentVbox.label || currentVbox.name}
</span>
<span className="text-muted-foreground/40 text-sm"></span>
<h1 className="text-xl font-semibold">{folderLabel}</h1>
</div>
) : (
<h1 className="text-xl font-semibold">{folderLabel}</h1>
)}
{total > 0 && (
<p className="text-xs text-muted-foreground mt-0.5">
{total} {total === 1 ? 'messaggio' : 'messaggi'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
@@ -128,111 +371,201 @@ export function InboxPage() {
</div>
</div>
{/* Filtri */}
{/* ── Filtri ── */}
<div className="flex items-center gap-3 flex-wrap">
{/* Barra di ricerca */}
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per oggetto, mittente..."
placeholder={
isInbound ? 'Cerca per oggetto, mittente…' : 'Cerca per oggetto, destinatario…'
}
className="pl-9 h-9"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
{/* Filtro direzione */}
<div className="flex gap-1 p-1 rounded-lg bg-muted">
{(['all', 'inbound', 'outbound'] as const).map((d) => (
<button
key={d}
onClick={() => {
setSelectedDirection(d)
setFilters({ direction: d === 'all' ? undefined : d, page: 1 })
}}
className={cn(
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
selectedDirection === d
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{d === 'all' ? 'Tutti' : d === 'inbound' ? '📥 In arrivo' : '📤 Inviati'}
</button>
))}
</div>
{/* Filtro casella */}
{mailboxesData?.items && mailboxesData.items.length > 1 && (
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={filters.mailbox_id || ''}
onChange={(e) => setFilters({ mailbox_id: e.target.value || undefined, page: 1 })}
>
<option value="">Tutte le caselle</option>
{mailboxesData.items.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.display_name || mb.email_address}
</option>
))}
</select>
)}
{/* Filtro letti/non letti */}
<div className="flex gap-1">
{viewMode === 'inbox' && (
<Button
variant={filters.is_read === false ? 'default' : 'outline'}
variant={isReadFilter === false ? 'default' : 'outline'}
size="sm"
onClick={() =>
setFilters({ is_read: filters.is_read === false ? undefined : false })
}
onClick={() => setIsReadFilter(isReadFilter === false ? undefined : false)}
className="h-9 text-xs"
>
<Mail className="h-3.5 w-3.5 mr-1" />
Non letti
</Button>
)}
{(viewMode === 'inbox' || viewMode === 'sent') && (
<Button
variant={filters.is_starred === true ? 'default' : 'outline'}
variant={isStarredFilter === true ? 'default' : 'outline'}
size="sm"
onClick={() =>
setFilters({ is_starred: filters.is_starred === true ? undefined : true })
}
onClick={() => setIsStarredFilter(isStarredFilter === true ? undefined : true)}
className="h-9 text-xs"
>
<Star className="h-3.5 w-3.5 mr-1" />
Preferiti
</Button>
</div>
)}
</div>
</div>
{/* Lista messaggi */}
{/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */}
{someSelected && (
<div className="border-b bg-blue-50 dark:bg-blue-950/30 px-6 py-2.5 flex items-center gap-3 flex-wrap">
{/* Contatore + deseleziona */}
<div className="flex items-center gap-2">
<button
onClick={handleClearSelection}
className="p-0.5 rounded hover:bg-blue-100 dark:hover:bg-blue-900 transition-colors"
title="Deseleziona tutto"
>
<X className="h-4 w-4 text-blue-600" />
</button>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{selectedCount} {selectedCount === 1 ? 'selezionato' : 'selezionati'}
</span>
</div>
<div className="h-4 w-px bg-blue-200 dark:bg-blue-700" />
{/* Azioni: variano in base alla vista */}
{viewMode !== 'starred' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkStar}
isLoading={bulkMutation.isPending}
>
<Star className="h-3.5 w-3.5 mr-1 text-yellow-500" />
Aggiungi ai preferiti
</Button>
)}
{viewMode === 'starred' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUnstar}
isLoading={bulkMutation.isPending}
>
<StarOff className="h-3.5 w-3.5 mr-1" />
Rimuovi dai preferiti
</Button>
)}
{(viewMode === 'inbox' || viewMode === 'sent') && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUnstar}
isLoading={bulkMutation.isPending}
>
<StarOff className="h-3.5 w-3.5 mr-1" />
Rimuovi dai preferiti
</Button>
)}
{viewMode !== 'archived' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkArchive}
isLoading={bulkMutation.isPending}
>
<Archive className="h-3.5 w-3.5 mr-1" />
Archivia
</Button>
)}
{viewMode === 'archived' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUnarchive}
isLoading={bulkMutation.isPending}
>
<ArchiveX className="h-3.5 w-3.5 mr-1" />
Ripristina dalla posta
</Button>
)}
</div>
)}
{/* ── Lista messaggi ── */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Caricamento messaggi...</p>
<p className="text-sm text-muted-foreground">Caricamento messaggi</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Inbox className="h-12 w-12 text-muted-foreground/30 mb-3" />
<FolderIcon className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Nessun messaggio trovato</p>
<p className="text-sm text-muted-foreground/70 mt-1">
{searchInput ? 'Prova a modificare i filtri di ricerca' : 'La casella è vuota'}
{debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined
? 'Prova a modificare i filtri di ricerca'
: viewMode === 'inbox'
? 'La posta in arrivo è vuota'
: viewMode === 'sent'
? 'Nessun messaggio inviato'
: viewMode === 'starred'
? 'Nessun messaggio nei preferiti'
: 'Nessun messaggio archiviato'}
</p>
</div>
) : (
<div className="divide-y">
{/* Riga "seleziona tutto" */}
{messages.length > 0 && (
<div className="flex items-center gap-3 px-6 py-2 bg-muted/20 border-b">
<button
onClick={handleSelectAll}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
<span>
{allSelected
? 'Deseleziona tutti'
: `Seleziona tutti (${messages.length})`}
</span>
</button>
</div>
)}
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
viewMode={viewMode}
isSelected={selectedIds.has(message.id)}
onSelect={(e) => handleToggleSelect(message.id, e)}
onClick={() => handleMessageClick(message)}
onToggleStar={(e) => {
e.stopPropagation()
toggleStarMutation.mutate({ id: message.id, starred: !message.is_starred })
}}
onToggleArchive={(e) => {
e.stopPropagation()
archiveMutation.mutate({ id: message.id, archived: !message.is_archived })
}}
mailboxName={
mailboxesData?.items.find((m) => m.id === message.mailbox_id)
?.email_address
!mailboxId
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
: undefined
}
/>
))}
@@ -240,26 +573,26 @@ export function InboxPage() {
)}
</div>
{/* Paginazione */}
{/* ── Paginazione ── */}
{totalPages > 1 && (
<div className="border-t px-6 py-3 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Pagina {currentPage} di {totalPages} ({total} messaggi)
Pagina {page} di {totalPages} ({total} messaggi)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setFilters({ page: currentPage - 1 })}
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setFilters({ page: currentPage + 1 })}
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -274,23 +607,59 @@ export function InboxPage() {
interface MessageRowProps {
message: MessageResponse
viewMode: InboxViewMode
isSelected: boolean
onSelect: (e: React.MouseEvent) => void
onClick: () => void
onToggleStar: (e: React.MouseEvent) => void
onToggleArchive: (e: React.MouseEvent) => void
/** Presente solo nella vista globale mostra la casella di appartenenza */
mailboxName?: string
}
function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
function MessageRow({
message,
viewMode,
isSelected,
onSelect,
onClick,
onToggleStar,
onToggleArchive,
mailboxName,
}: MessageRowProps) {
const [hovered, setHovered] = useState(false)
const isUnread = !message.is_read && message.direction === 'inbound'
return (
<div
className={cn(
'flex items-start gap-3 px-6 py-4 cursor-pointer hover:bg-muted/50 transition-colors',
isUnread && 'bg-blue-50/50',
'flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors group',
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
isSelected && 'bg-blue-100/60 dark:bg-blue-900/30',
)}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Icona direzione */}
<div className="mt-1 flex-shrink-0">
{/* ── Checkbox selezione ── */}
<div
className="flex-shrink-0 w-6 flex items-center justify-center"
onClick={onSelect}
>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square
className={cn(
'h-4 w-4 transition-opacity',
hovered ? 'opacity-100 text-muted-foreground' : 'opacity-0',
)}
/>
)}
</div>
{/* ── Icona direzione ── */}
<div className="flex-shrink-0">
{message.direction === 'inbound' ? (
isUnread ? (
<Mail className="h-5 w-5 text-blue-600" />
@@ -302,7 +671,7 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
)}
</div>
{/* Contenuto */}
{/* ── Contenuto ── */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
@@ -313,15 +682,17 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
)}
>
{message.direction === 'inbound'
? message.from_address || 'Mittente sconosciuto'
? (message.from_address || 'Mittente sconosciuto')
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
</span>
{mailboxName && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{mailboxName}
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded flex-shrink-0">
{mailboxName}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<PecStateBadge state={message.state} />
<span className="text-xs text-muted-foreground">
@@ -351,9 +722,46 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
)}
</div>
{/* Indicatori */}
<div className="flex flex-col items-center gap-1 flex-shrink-0">
{message.is_starred && <Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />}
{/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Pulsante stella (rapido, su hover o se stellata) */}
<button
onClick={onToggleStar}
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
message.is_starred
? 'opacity-100'
: hovered
? 'opacity-100'
: 'opacity-0 pointer-events-none',
)}
>
<Star
className={cn(
'h-4 w-4',
message.is_starred ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground',
)}
/>
</button>
{/* Pulsante archivia/ripristina (rapido, su hover) */}
<button
onClick={onToggleArchive}
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'archived' ? (
<ArchiveX className="h-4 w-4 text-muted-foreground" />
) : (
<Archive className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* Indicatore allegati */}
{message.has_attachments && (
<span className="text-xs text-muted-foreground">📎</span>
)}
@@ -4,9 +4,9 @@ import {
ArrowLeft,
Star,
Archive,
ArchiveX,
Download,
Reply,
Forward,
Paperclip,
Mail,
Send,
@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/Button'
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { messagesApi } from '@/api/messages.api'
import { formatDate, formatBytes, MAILBOX_STATUS_LABELS } from '@/lib/utils'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
export function MessageDetailPage() {
@@ -54,6 +54,8 @@ export function MessageDetailPage() {
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
// Invalida le query messaggi per aggiornare le viste Preferiti
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti')
},
onError: (error) => toast.error(getErrorMessage(error)),
@@ -62,9 +64,21 @@ export function MessageDetailPage() {
// Archivia
const archiveMutation = useMutation({
mutationFn: () => messagesApi.archive(id!),
onSuccess: () => {
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success('Messaggio archiviato')
navigate('/inbox')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// Ripristina dall'archivio
const unarchiveMutation = useMutation({
mutationFn: () => messagesApi.unarchive(id!),
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success('Messaggio ripristinato dalla posta')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
@@ -103,29 +117,46 @@ export function MessageDetailPage() {
</div>
<div className="flex items-center gap-2">
{/* Stella */}
{/* Stella / Preferito */}
<Button
variant="ghost"
size="icon"
onClick={() => toggleStarMutation.mutate(!message.is_starred)}
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
isLoading={toggleStarMutation.isPending}
>
<Star
className={`h-5 w-5 ${message.is_starred ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground'}`}
/>
</Button>
{/* Archivia */}
{/* Archivia (se non ancora archiviato) */}
{!message.is_archived && (
<Button
variant="ghost"
size="icon"
onClick={() => archiveMutation.mutate()}
title="Archivia"
isLoading={archiveMutation.isPending}
>
<Archive className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* Ripristina dall'archivio (se archiviato) */}
{message.is_archived && (
<Button
variant="outline"
size="sm"
onClick={() => unarchiveMutation.mutate()}
title="Ripristina dalla posta"
isLoading={unarchiveMutation.isPending}
>
<ArchiveX className="h-4 w-4 mr-1" />
Ripristina
</Button>
)}
{/* Rispondi (solo per messaggi inbound PEC certificata) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && (
<Button
@@ -144,6 +175,26 @@ export function MessageDetailPage() {
</div>
</div>
{/* Banner "Archiviato" */}
{message.is_archived && (
<div className="bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-amber-700">
<Archive className="h-4 w-4" />
<span>Questo messaggio si trova nell'archivio.</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs border-amber-300 hover:bg-amber-100 text-amber-700"
onClick={() => unarchiveMutation.mutate()}
isLoading={unarchiveMutation.isPending}
>
<ArchiveX className="h-3.5 w-3.5 mr-1" />
Ripristina nella posta
</Button>
</div>
)}
{/* Contenuto */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8 space-y-6">
@@ -0,0 +1,954 @@
/**
* Pagina Notifiche Multi-canale gestione canali e regole di notifica.
*
* Struttura:
* - Tab "Canali" → lista canali con tipo, stato, circuit breaker, pulsante test
* - Tab "Regole" → lista regole evento→canale con filtri opzionali
* - Tab "Log" → log degli invii recenti
*
* Canali supportati: Webhook, Email SMTP, Telegram, WhatsApp.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Bell,
Plus,
Edit,
Trash2,
Webhook,
Mail,
MessageCircle,
Phone,
CheckCircle,
XCircle,
FlaskConical,
AlertTriangle,
Zap,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { notificationsApi } from '@/api/notifications.api'
import { getErrorMessage } from '@/api/client'
import { formatDate, formatRelative } from '@/lib/utils'
import { cn } from '@/lib/utils'
import type {
NotificationChannelCreate,
NotificationChannelResponse,
NotificationChannelType,
NotificationEventType,
NotificationRuleCreate,
NotificationRuleResponse,
} from '@/types/api.types'
// ─── Costanti ─────────────────────────────────────────────────────────────────
const CHANNEL_TYPE_LABELS: Record<NotificationChannelType, string> = {
webhook: 'Webhook',
email: 'Email SMTP',
telegram: 'Telegram',
whatsapp: 'WhatsApp',
}
const CHANNEL_TYPE_ICONS: Record<NotificationChannelType, React.ElementType> = {
webhook: Webhook,
email: Mail,
telegram: MessageCircle,
whatsapp: Phone,
}
const CHANNEL_TYPE_COLORS: Record<NotificationChannelType, string> = {
webhook: 'bg-purple-100 text-purple-800',
email: 'bg-blue-100 text-blue-800',
telegram: 'bg-sky-100 text-sky-800',
whatsapp: 'bg-green-100 text-green-800',
}
const EVENT_TYPE_LABELS: Record<NotificationEventType, string> = {
new_message: 'Nuovo messaggio',
state_changed: 'Cambio stato',
anomaly: 'Anomalia',
send_failed: 'Invio fallito',
send_delivered: 'Invio consegnato',
mailbox_error: 'Errore casella',
}
const STATUS_COLORS: Record<string, string> = {
sent: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
failed: 'bg-red-100 text-red-800',
skipped: 'bg-gray-100 text-gray-600',
}
const STATUS_LABELS: Record<string, string> = {
sent: 'Inviato',
pending: 'In attesa',
failed: 'Fallito',
skipped: 'Saltato',
}
// ─── Tab type ─────────────────────────────────────────────────────────────────
type Tab = 'channels' | 'rules' | 'logs'
// ─── Pagina principale ────────────────────────────────────────────────────────
export function NotificationsPage() {
const [activeTab, setActiveTab] = useState<Tab>('channels')
const [showCreateChannel, setShowCreateChannel] = useState(false)
const [editingChannel, setEditingChannel] = useState<NotificationChannelResponse | null>(null)
const [showCreateRule, setShowCreateRule] = useState(false)
const queryClient = useQueryClient()
const tabs: { id: Tab; label: string }[] = [
{ id: 'channels', label: 'Canali' },
{ id: 'rules', label: 'Regole' },
{ id: 'logs', label: 'Log invii' },
]
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Notifiche Multi-canale</h1>
</div>
{activeTab === 'channels' && (
<Button onClick={() => setShowCreateChannel(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuovo canale
</Button>
)}
{activeTab === 'rules' && (
<Button onClick={() => setShowCreateRule(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuova regola
</Button>
)}
</div>
{/* Tab bar */}
<div className="border-b bg-background px-6">
<div className="flex gap-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Contenuto */}
<div className="flex-1 overflow-auto p-6">
{activeTab === 'channels' && (
<ChannelsTab
onEdit={(c) => setEditingChannel(c)}
onCreated={() => queryClient.invalidateQueries({ queryKey: ['notif-channels'] })}
/>
)}
{activeTab === 'rules' && <RulesTab />}
{activeTab === 'logs' && <LogsTab />}
</div>
{/* Dialogs */}
{(showCreateChannel || editingChannel) && (
<ChannelFormDialog
open
editingChannel={editingChannel}
onClose={() => {
setShowCreateChannel(false)
setEditingChannel(null)
}}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['notif-channels'] })
setShowCreateChannel(false)
setEditingChannel(null)
}}
/>
)}
{showCreateRule && (
<RuleFormDialog
open
onClose={() => setShowCreateRule(false)}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['notif-rules'] })
setShowCreateRule(false)
}}
/>
)}
</div>
)
}
// ─── Tab Canali ───────────────────────────────────────────────────────────────
interface ChannelsTabProps {
onEdit: (c: NotificationChannelResponse) => void
onCreated: () => void
}
function ChannelsTab({ onEdit }: ChannelsTabProps) {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => notificationsApi.deleteChannel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notif-channels'] })
toast.success('Canale eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const testMutation = useMutation({
mutationFn: (id: string) => notificationsApi.testChannel(id),
onSuccess: (result) => {
if (result.success) {
toast.success(`${result.message}`)
} else {
toast.error(`${result.message}`)
}
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleActiveMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
notificationsApi.updateChannel(id, { is_active: active }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notif-channels'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const channels = data?.items ?? []
if (isLoading)
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
if (channels.length === 0)
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Bell className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessun canale configurato</p>
<p className="text-sm text-muted-foreground mt-1">
Aggiungi un canale Webhook, Email, Telegram o WhatsApp per ricevere notifiche.
</p>
</div>
)
return (
<div className="space-y-3">
{channels.map((channel) => {
const Icon = CHANNEL_TYPE_ICONS[channel.channel_type as NotificationChannelType] ?? Bell
const isCircuitOpen =
channel.circuit_open_until && new Date(channel.circuit_open_until) > new Date()
return (
<div
key={channel.id}
className={cn(
'rounded-lg border bg-card p-4 flex items-start gap-4',
!channel.is_active && 'opacity-60',
)}
>
{/* Icona tipo */}
<div
className={cn(
'p-2.5 rounded-lg flex-shrink-0',
CHANNEL_TYPE_COLORS[channel.channel_type as NotificationChannelType],
)}
>
<Icon className="h-5 w-5" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{channel.name}</span>
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
CHANNEL_TYPE_COLORS[channel.channel_type as NotificationChannelType],
)}
>
{CHANNEL_TYPE_LABELS[channel.channel_type as NotificationChannelType] ??
channel.channel_type}
</span>
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
channel.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
)}
>
{channel.is_active ? 'Attivo' : 'Inattivo'}
</span>
</div>
{/* Config pubblica */}
{channel.config && Object.keys(channel.config).length > 0 && (
<div className="mt-1 flex gap-3 flex-wrap">
{Object.entries(channel.config).map(([k, v]) => (
<span key={k} className="text-xs text-muted-foreground">
<span className="font-medium">{k}:</span>{' '}
<span className="font-mono">{String(v)}</span>
</span>
))}
</div>
)}
{/* Circuit breaker warning */}
{isCircuitOpen && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-orange-700 bg-orange-50 rounded px-2 py-1 w-fit">
<AlertTriangle className="h-3.5 w-3.5" />
Circuit breaker aperto fino a {formatDate(channel.circuit_open_until)}
</div>
)}
{/* Failures */}
{channel.consecutive_failures > 0 && (
<p className="text-xs text-red-600 mt-1">
{channel.consecutive_failures} errori consecutivi
</p>
)}
</div>
{/* Azioni */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => testMutation.mutate(channel.id)}
isLoading={testMutation.isPending}
title="Testa canale"
>
<FlaskConical className="h-3.5 w-3.5 mr-1" />
Test
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Modifica"
onClick={() => onEdit(channel)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={channel.is_active ? 'Disattiva' : 'Attiva'}
onClick={() =>
toggleActiveMutation.mutate({ id: channel.id, active: !channel.is_active })
}
>
{channel.is_active ? (
<XCircle className="h-4 w-4 text-yellow-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Elimina"
onClick={() => {
if (confirm(`Eliminare il canale "${channel.name}"?`)) {
deleteMutation.mutate(channel.id)
}
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
)
})}
</div>
)
}
// ─── Tab Regole ───────────────────────────────────────────────────────────────
function RulesTab() {
const queryClient = useQueryClient()
const { data: channelsData } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const { data, isLoading } = useQuery({
queryKey: ['notif-rules'],
queryFn: () => notificationsApi.listRules({ page_size: 100 }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => notificationsApi.deleteRule(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notif-rules'] })
toast.success('Regola eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleActiveMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
notificationsApi.updateRule(id, { is_active: active }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notif-rules'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const channels = channelsData?.items ?? []
const rules = data?.items ?? []
const channelName = (id: string) =>
channels.find((c) => c.id === id)?.name ?? id.slice(0, 8) + '…'
if (isLoading)
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
if (rules.length === 0)
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessuna regola</p>
<p className="text-sm text-muted-foreground mt-1">
Le regole collegano gli eventi PecFlow ai canali di notifica.
</p>
</div>
)
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Regola</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Evento</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Canale</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Stato</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th>
</tr>
</thead>
<tbody className="divide-y">
{rules.map((rule) => (
<tr
key={rule.id}
className={cn('hover:bg-muted/30 transition-colors', !rule.is_active && 'opacity-60')}
>
<td className="px-4 py-3 font-medium">{rule.name}</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1 rounded-full bg-violet-100 text-violet-800 px-2 py-0.5 text-xs font-medium">
<Zap className="h-3 w-3" />
{EVENT_TYPE_LABELS[rule.event_type as NotificationEventType] ?? rule.event_type}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{channelName(rule.channel_id)}</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
rule.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
)}
>
{rule.is_active ? 'Attiva' : 'Inattiva'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={rule.is_active ? 'Disattiva' : 'Attiva'}
onClick={() =>
toggleActiveMutation.mutate({ id: rule.id, active: !rule.is_active })
}
>
{rule.is_active ? (
<XCircle className="h-4 w-4 text-yellow-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Elimina"
onClick={() => {
if (confirm(`Eliminare la regola "${rule.name}"?`)) {
deleteMutation.mutate(rule.id)
}
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Tab Log ──────────────────────────────────────────────────────────────────
function LogsTab() {
const { data: channelsData } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const { data, isLoading } = useQuery({
queryKey: ['notif-logs'],
queryFn: () => notificationsApi.listLogs({ page_size: 50 }),
})
const channels = channelsData?.items ?? []
const logs = data?.items ?? []
const channelName = (id: string) =>
channels.find((c) => c.id === id)?.name ?? id.slice(0, 8) + '…'
if (isLoading)
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
if (logs.length === 0)
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Bell className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessun log</p>
<p className="text-sm text-muted-foreground mt-1">
I log appariranno qui quando verranno inviate notifiche.
</p>
</div>
)
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Canale</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Evento</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Stato</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Tentativi</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Data</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Errore</th>
</tr>
</thead>
<tbody className="divide-y">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">{channelName(log.channel_id)}</td>
<td className="px-4 py-3 text-muted-foreground">
{EVENT_TYPE_LABELS[log.event_type as NotificationEventType] ?? log.event_type}
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
STATUS_COLORS[log.status] ?? 'bg-gray-100 text-gray-600',
)}
>
{STATUS_LABELS[log.status] ?? log.status}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
{log.attempt_count}/{log.max_attempts}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{formatRelative(log.created_at)}
</td>
<td className="px-4 py-3 text-xs text-red-600 max-w-xs truncate">
{log.last_error || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Dialog Canale ────────────────────────────────────────────────────────────
interface ChannelFormDialogProps {
open: boolean
editingChannel: NotificationChannelResponse | null
onClose: () => void
onSaved: () => void
}
interface ChannelFormValues {
name: string
channel_type: NotificationChannelType
// Webhook
webhook_url: string
webhook_secret: string
// Email
email_host: string
email_port: string
email_user: string
email_password: string
email_from: string
email_to: string
// Telegram
telegram_bot_token: string
telegram_chat_id: string
// WhatsApp
whatsapp_phone_number_id: string
whatsapp_access_token: string
whatsapp_to_number: string
}
function ChannelFormDialog({ open, editingChannel, onClose, onSaved }: ChannelFormDialogProps) {
const { register, watch, handleSubmit, formState: { errors } } = useForm<ChannelFormValues>({
defaultValues: {
name: editingChannel?.name ?? '',
channel_type: (editingChannel?.channel_type as NotificationChannelType) ?? 'webhook',
webhook_url: (editingChannel?.config?.url as string) ?? '',
email_host: (editingChannel?.config?.host as string) ?? '',
email_port: (editingChannel?.config?.port as string) ?? '465',
email_from: (editingChannel?.config?.from_email as string) ?? '',
email_to: (editingChannel?.config?.to_email as string) ?? '',
telegram_chat_id: (editingChannel?.config?.chat_id as string) ?? '',
whatsapp_phone_number_id: (editingChannel?.config?.phone_number_id as string) ?? '',
whatsapp_to_number: (editingChannel?.config?.to_number as string) ?? '',
},
})
const channelType = watch('channel_type')
const createMutation = useMutation({
mutationFn: (data: NotificationChannelCreate) => notificationsApi.createChannel(data),
onSuccess: () => { toast.success('Canale creato'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
notificationsApi.updateChannel(id, data),
onSuccess: () => { toast.success('Canale aggiornato'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (values: ChannelFormValues) => {
let config: Record<string, unknown> = {}
let config_secret: Record<string, unknown> | null = null
if (values.channel_type === 'webhook') {
config = { url: values.webhook_url }
if (values.webhook_secret) config_secret = { secret: values.webhook_secret }
} else if (values.channel_type === 'email') {
config = {
host: values.email_host,
port: parseInt(values.email_port),
from_email: values.email_from,
to_email: values.email_to,
}
if (values.email_user) config_secret = { username: values.email_user, password: values.email_password }
} else if (values.channel_type === 'telegram') {
config = { chat_id: values.telegram_chat_id }
if (values.telegram_bot_token) config_secret = { bot_token: values.telegram_bot_token }
} else if (values.channel_type === 'whatsapp') {
config = {
phone_number_id: values.whatsapp_phone_number_id,
to_number: values.whatsapp_to_number,
}
if (values.whatsapp_access_token) config_secret = { access_token: values.whatsapp_access_token }
}
if (editingChannel) {
updateMutation.mutate({
id: editingChannel.id,
data: { name: values.name, config, config_secret },
})
} else {
createMutation.mutate({
name: values.name,
channel_type: values.channel_type,
config,
config_secret,
})
}
}
const isPending = createMutation.isPending || updateMutation.isPending
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingChannel ? 'Modifica canale' : 'Nuovo canale di notifica'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input {...register('name', { required: 'Obbligatorio' })} placeholder="es. Webhook produzione" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
{!editingChannel && (
<div className="space-y-2">
<Label>Tipo canale *</Label>
<select
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
{...register('channel_type')}
>
{(Object.entries(CHANNEL_TYPE_LABELS) as [NotificationChannelType, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
)}
{/* Config per tipo */}
{channelType === 'webhook' && (
<>
<div className="space-y-2">
<Label>URL Webhook *</Label>
<Input
{...register('webhook_url', { required: 'Obbligatorio' })}
placeholder="https://example.com/webhook"
/>
</div>
<div className="space-y-2">
<Label>Segreto HMAC (opzionale)</Label>
<Input
{...register('webhook_secret')}
type="password"
placeholder="Secret per firma HMAC-SHA256"
/>
</div>
</>
)}
{channelType === 'email' && (
<>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2 space-y-2">
<Label>Host SMTP *</Label>
<Input {...register('email_host', { required: true })} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<Label>Porta</Label>
<Input {...register('email_port')} placeholder="465" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Username SMTP</Label>
<Input {...register('email_user')} />
</div>
<div className="space-y-2">
<Label>Password SMTP</Label>
<Input {...register('email_password')} type="password" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Da *</Label>
<Input {...register('email_from', { required: true })} placeholder="noreply@..." />
</div>
<div className="space-y-2">
<Label>A *</Label>
<Input {...register('email_to', { required: true })} placeholder="destinatario@..." />
</div>
</div>
</>
)}
{channelType === 'telegram' && (
<>
<div className="space-y-2">
<Label>Bot Token *</Label>
<Input
{...register('telegram_bot_token', { required: true })}
type="password"
placeholder="123456:ABC-DEF…"
/>
</div>
<div className="space-y-2">
<Label>Chat ID *</Label>
<Input
{...register('telegram_chat_id', { required: true })}
placeholder="-100123456789"
/>
</div>
</>
)}
{channelType === 'whatsapp' && (
<>
<div className="space-y-2">
<Label>Phone Number ID *</Label>
<Input {...register('whatsapp_phone_number_id', { required: true })} />
</div>
<div className="space-y-2">
<Label>Access Token *</Label>
<Input {...register('whatsapp_access_token', { required: true })} type="password" />
</div>
<div className="space-y-2">
<Label>Numero destinatario *</Label>
<Input
{...register('whatsapp_to_number', { required: true })}
placeholder="+39..."
/>
</div>
</>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={isPending}>
{editingChannel ? 'Salva' : 'Crea canale'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Dialog Regola ────────────────────────────────────────────────────────────
interface RuleFormDialogProps {
open: boolean
onClose: () => void
onSaved: () => void
}
interface RuleFormValues {
name: string
channel_id: string
event_type: NotificationEventType
}
function RuleFormDialog({ open, onClose, onSaved }: RuleFormDialogProps) {
const { register, handleSubmit, formState: { errors } } = useForm<RuleFormValues>({
defaultValues: { name: '', channel_id: '', event_type: 'new_message' },
})
const { data: channelsData } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const createMutation = useMutation({
mutationFn: (data: NotificationRuleCreate) => notificationsApi.createRule(data),
onSuccess: () => { toast.success('Regola creata'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const channels = channelsData?.items ?? []
const onSubmit = (values: RuleFormValues) => {
createMutation.mutate({
name: values.name,
channel_id: values.channel_id,
event_type: values.event_type,
})
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Nuova regola di notifica</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome regola *</Label>
<Input {...register('name', { required: 'Obbligatorio' })} placeholder="es. Avviso nuovo messaggio" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label>Canale *</Label>
<select
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
{...register('channel_id', { required: 'Obbligatorio' })}
>
<option value="">Seleziona canale</option>
{channels.map((c) => (
<option key={c.id} value={c.id}>
{CHANNEL_TYPE_LABELS[c.channel_type as NotificationChannelType]} {c.name}
</option>
))}
</select>
{errors.channel_id && (
<p className="text-xs text-destructive">{errors.channel_id.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Evento *</Label>
<select
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
{...register('event_type', { required: true })}
>
{(Object.entries(EVENT_TYPE_LABELS) as [NotificationEventType, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={createMutation.isPending}>Crea regola</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,198 @@
/**
* SettingsPage impostazioni profilo dell'utente corrente.
*
* Sezioni:
* - Informazioni profilo (nome visualizzato, email, ruolo)
* - Modifica nome
* - Cambio password
*/
import { useState } from 'react'
import { Settings, User, Lock, Save } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { useAuthStore } from '@/store/auth.store'
import { usersApi } from '@/api/users.api'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Card } from '@/components/ui/Card'
import toast from 'react-hot-toast'
// ─── Etichetta ruolo ─────────────────────────────────────────────────────────
function roleLabel(role: string): string {
switch (role) {
case 'super_admin':
return 'Super Amministratore'
case 'admin':
return 'Amministratore'
case 'operator':
return 'Operatore'
default:
return role
}
}
// ─── Pagina ──────────────────────────────────────────────────────────────────
export function SettingsPage() {
const { user } = useAuth()
const loadUser = useAuthStore((s) => s.loadUser)
/* ── Stato modifica nome ── */
const [fullName, setFullName] = useState(user?.full_name ?? '')
const [savingName, setSavingName] = useState(false)
/* ── Stato cambio password ── */
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [savingPwd, setSavingPwd] = useState(false)
/* ── Salva nome ── */
const handleSaveName = async () => {
if (!user) return
if (!fullName.trim()) {
toast.error('Il nome non può essere vuoto')
return
}
setSavingName(true)
try {
await usersApi.update(user.id, { full_name: fullName.trim() })
await loadUser()
toast.success('Nome aggiornato con successo')
} catch {
toast.error('Errore durante il salvataggio del nome')
} finally {
setSavingName(false)
}
}
/* ── Cambia password ── */
const handleChangePassword = async () => {
if (!user) return
if (newPassword.length < 8) {
toast.error('La password deve essere di almeno 8 caratteri')
return
}
if (newPassword !== confirmPassword) {
toast.error('Le password non coincidono')
return
}
setSavingPwd(true)
try {
await usersApi.resetPassword(user.id, newPassword)
setNewPassword('')
setConfirmPassword('')
toast.success('Password aggiornata con successo')
} catch {
toast.error('Errore durante il cambio della password')
} finally {
setSavingPwd(false)
}
}
if (!user) return null
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
{/* ── Intestazione ── */}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-600 flex items-center justify-center">
<Settings className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Impostazioni</h1>
<p className="text-sm text-gray-500">Gestisci il tuo profilo e le credenziali di accesso</p>
</div>
</div>
{/* ── Card: Informazioni account ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Informazioni account
</h2>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Email</span>
<p className="mt-0.5 font-medium text-gray-800">{user.email}</p>
</div>
<div>
<span className="text-gray-500">Ruolo</span>
<p className="mt-0.5 font-medium text-gray-800">{roleLabel(user.role)}</p>
</div>
</div>
<hr className="border-gray-100" />
{/* Modifica nome */}
<div className="space-y-2">
<Label htmlFor="full_name">Nome visualizzato</Label>
<div className="flex gap-2">
<Input
id="full_name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Il tuo nome"
className="flex-1"
/>
<Button
onClick={handleSaveName}
disabled={savingName || fullName.trim() === (user.full_name ?? '')}
size="sm"
>
<Save className="h-4 w-4 mr-1.5" />
{savingName ? 'Salvataggio…' : 'Salva'}
</Button>
</div>
</div>
</Card>
{/* ── Card: Cambio password ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<Lock className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Cambio password
</h2>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="new_password">Nuova password</Label>
<Input
id="new_password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Minimo 8 caratteri"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm_password">Conferma password</Label>
<Input
id="confirm_password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Ripeti la nuova password"
/>
</div>
<Button
onClick={handleChangePassword}
disabled={savingPwd || !newPassword || !confirmPassword}
>
<Lock className="h-4 w-4 mr-1.5" />
{savingPwd ? 'Aggiornamento…' : 'Aggiorna password'}
</Button>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,803 @@
/**
* Pagina Virtual Box gestione filtri nominati assegnabili agli utenti.
*
* Struttura:
* - Tabella delle VBox con nome, label, n° regole, n° utenti, stato, caselle PEC
* - Dialog creazione/modifica con builder di regole e selezione caselle reali
* - Dialog assegnazione utenti
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Filter,
Inbox,
Plus,
Edit,
Trash2,
Users,
ChevronDown,
ChevronUp,
CheckCircle,
XCircle,
Tag,
} from 'lucide-react'
import { useForm, useFieldArray } from 'react-hook-form'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { usersApi } from '@/api/users.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import type {
AssignedUserResponse,
VirtualBoxCreate,
VirtualBoxResponse,
VirtualBoxRuleCreate,
VBoxField,
VBoxOperator,
} from '@/types/api.types'
// ─── Label/costanti ───────────────────────────────────────────────────────────
const FIELD_LABELS: Record<VBoxField, string> = {
mailbox_id: 'Casella PEC (ID)',
imap_folder: 'Cartella IMAP',
subject: 'Oggetto',
from_address: 'Mittente',
to_address: 'Destinatario',
}
const OPERATOR_LABELS: Record<VBoxOperator, string> = {
contains: 'contiene',
equals: 'uguale a',
starts_with: 'inizia per',
ends_with: 'finisce per',
regex: 'regex',
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function VirtualBoxesPage() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [editingVbox, setEditingVbox] = useState<VirtualBoxResponse | null>(null)
const [assigningVbox, setAssigningVbox] = useState<VirtualBoxResponse | null>(null)
const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set())
const { data, isLoading } = useQuery({
queryKey: ['virtual-boxes'],
queryFn: () => virtualBoxesApi.list({ page_size: 100 }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => virtualBoxesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
toast.success('Virtual Box eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleActive = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
virtualBoxesApi.update(id, { is_active: active }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const vboxes = data?.items ?? []
const toggleRules = (id: string) => {
setExpandedRules((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Virtual Box</h1>
<span className="text-sm text-muted-foreground">
({vboxes.filter((v) => v.is_active).length} attive su {vboxes.length})
</span>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuova Virtual Box
</Button>
</div>
{/* Descrizione */}
<div className="px-6 py-3 bg-blue-50 border-b text-sm text-blue-800">
<strong>Cos'è una Virtual Box?</strong> Un filtro nominato (oggetto, mittente, cartella,
data…) assegnabile a uno o più utenti e collegato a una o più{' '}
<strong>caselle PEC reali</strong>. Le regole nella stessa VBox si combinano in{' '}
<strong>AND</strong>; più VBox assegnate allo stesso utente si uniscono in{' '}
<strong>OR</strong>. Il filtro si applica automaticamente a inbox e ricerca.
</div>
{/* Contenuto */}
<div className="flex-1 overflow-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : vboxes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Filter className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessuna Virtual Box</p>
<p className="text-sm text-muted-foreground mt-1">
Crea la prima Virtual Box per filtrare i messaggi visibili agli utenti.
</p>
<Button className="mt-4" onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" />
Crea Virtual Box
</Button>
</div>
) : (
<div className="space-y-3">
{vboxes.map((vbox) => (
<VBoxCard
key={vbox.id}
vbox={vbox}
rulesExpanded={expandedRules.has(vbox.id)}
onToggleRules={() => toggleRules(vbox.id)}
onEdit={() => setEditingVbox(vbox)}
onAssign={() => setAssigningVbox(vbox)}
onDelete={() => {
if (confirm(`Eliminare la Virtual Box "${vbox.name}"?`)) {
deleteMutation.mutate(vbox.id)
}
}}
onToggleActive={() =>
toggleActive.mutate({ id: vbox.id, active: !vbox.is_active })
}
/>
))}
</div>
)}
</div>
{/* Dialogs */}
{(showCreate || editingVbox) && (
<VBoxFormDialog
open
editingVbox={editingVbox}
onClose={() => {
setShowCreate(false)
setEditingVbox(null)
}}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
setShowCreate(false)
setEditingVbox(null)
}}
/>
)}
{assigningVbox && (
<AssignUsersDialog
vbox={assigningVbox}
onClose={() => setAssigningVbox(null)}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
setAssigningVbox(null)
}}
/>
)}
</div>
)
}
// ─── Card singola VBox ────────────────────────────────────────────────────────
interface VBoxCardProps {
vbox: VirtualBoxResponse
rulesExpanded: boolean
onToggleRules: () => void
onEdit: () => void
onAssign: () => void
onDelete: () => void
onToggleActive: () => void
}
function VBoxCard({
vbox,
rulesExpanded,
onToggleRules,
onEdit,
onAssign,
onDelete,
onToggleActive,
}: VBoxCardProps) {
return (
<div
className={cn(
'rounded-lg border bg-card transition-colors',
!vbox.is_active && 'opacity-60',
)}
>
{/* Intestazione */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{vbox.name}</span>
{vbox.label && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 text-blue-800 px-2 py-0.5 text-xs">
<Tag className="h-3 w-3" />
{vbox.label}
</span>
)}
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
vbox.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
)}
>
{vbox.is_active ? 'Attiva' : 'Inattiva'}
</span>
</div>
{vbox.description && (
<p className="text-xs text-muted-foreground mt-0.5">{vbox.description}</p>
)}
{/* Caselle PEC associate */}
{vbox.mailboxes.length > 0 ? (
<div className="flex flex-wrap gap-1 mt-1.5">
{vbox.mailboxes.map((mb) => (
<span
key={mb.id}
className="inline-flex items-center gap-1 rounded-full bg-indigo-100 text-indigo-800 px-2 py-0.5 text-xs"
>
<Inbox className="h-3 w-3" />
{mb.display_name ?? mb.email_address}
</span>
))}
</div>
) : (
<p className="text-xs text-amber-600 mt-1">
⚠ Nessuna casella PEC associata
</p>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-xs text-muted-foreground shrink-0">
<span className="flex items-center gap-1">
<Filter className="h-3.5 w-3.5" />
{vbox.rules.length} {vbox.rules.length === 1 ? 'regola' : 'regole'}
</span>
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{vbox.assignment_count} {vbox.assignment_count === 1 ? 'utente' : 'utenti'}
</span>
<span>{formatDate(vbox.updated_at)}</span>
</div>
{/* Azioni */}
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Assegna utenti" onClick={onAssign}>
<Users className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Modifica" onClick={onEdit}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={vbox.is_active ? 'Disattiva' : 'Attiva'}
onClick={onToggleActive}
>
{vbox.is_active ? (
<XCircle className="h-4 w-4 text-yellow-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Elimina"
onClick={onDelete}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
{vbox.rules.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={rulesExpanded ? 'Nascondi regole' : 'Mostra regole'}
onClick={onToggleRules}
>
{rulesExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
{/* Regole espanse */}
{rulesExpanded && vbox.rules.length > 0 && (
<div className="border-t px-4 py-3">
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
Regole (combinate in AND)
</p>
<div className="space-y-1.5">
{vbox.rules.map((rule, i) => (
<div
key={rule.id}
className="flex items-center gap-2 text-sm bg-muted/40 rounded px-3 py-1.5"
>
<span className="text-xs text-muted-foreground w-4">{i + 1}.</span>
<span className="font-medium">{FIELD_LABELS[rule.field as VBoxField] ?? rule.field}</span>
<span className="text-muted-foreground">
{OPERATOR_LABELS[rule.operator as VBoxOperator] ?? rule.operator}
</span>
<span className="font-mono bg-background rounded px-1.5 py-0.5 text-xs border">
{rule.value}
</span>
{(rule.date_from || rule.date_to) && (
<span className="text-muted-foreground text-xs ml-2">
{rule.date_from && `dal ${rule.date_from}`}
{rule.date_from && rule.date_to && ' '}
{rule.date_to && `al ${rule.date_to}`}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
// ─── Dialog crea/modifica VBox ────────────────────────────────────────────────
interface VBoxFormValues {
name: string
description: string
label: string
mailbox_ids: string[]
rules: Array<{
field: VBoxField
operator: VBoxOperator
value: string
date_from: string
date_to: string
}>
}
interface VBoxFormDialogProps {
open: boolean
editingVbox: VirtualBoxResponse | null
onClose: () => void
onSaved: () => void
}
function VBoxFormDialog({ open, editingVbox, onClose, onSaved }: VBoxFormDialogProps) {
const { register, control, handleSubmit, watch, setValue, formState: { errors } } =
useForm<VBoxFormValues>({
defaultValues: editingVbox
? {
name: editingVbox.name,
description: editingVbox.description ?? '',
label: editingVbox.label ?? '',
mailbox_ids: editingVbox.mailboxes.map((m) => m.id),
rules: editingVbox.rules.map((r) => ({
field: r.field as VBoxField,
operator: r.operator as VBoxOperator,
value: r.value,
date_from: r.date_from ?? '',
date_to: r.date_to ?? '',
})),
}
: {
name: '',
description: '',
label: '',
mailbox_ids: [],
rules: [],
},
})
const { fields, append, remove } = useFieldArray({ control, name: 'rules' })
const selectedMailboxIds = watch('mailbox_ids')
// Carica le mailbox disponibili
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes-brief'],
queryFn: () => mailboxesApi.list(1, 100),
})
const availableMailboxes = mailboxesData?.items ?? []
const toggleMailbox = (id: string) => {
const current = selectedMailboxIds ?? []
if (current.includes(id)) {
setValue('mailbox_ids', current.filter((x) => x !== id))
} else {
setValue('mailbox_ids', [...current, id])
}
}
const createMutation = useMutation({
mutationFn: (data: VirtualBoxCreate) => virtualBoxesApi.create(data),
onSuccess: () => { toast.success('Virtual Box creata'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, rules }: { id: string; rules: VirtualBoxRuleCreate[] }) =>
virtualBoxesApi.replaceRules(id, rules),
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMetaMutation = useMutation({
mutationFn: ({ id, data, rules }: { id: string; data: any; rules: VirtualBoxRuleCreate[] }) =>
virtualBoxesApi.update(id, data),
onSuccess: async (_, vars) => {
await updateMutation.mutateAsync({ id: vars.id, rules: vars.rules })
toast.success('Virtual Box aggiornata')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (data: VBoxFormValues) => {
const rules: VirtualBoxRuleCreate[] = data.rules.map((r) => ({
field: r.field,
operator: r.operator,
value: r.value,
date_from: r.date_from || null,
date_to: r.date_to || null,
}))
if (editingVbox) {
updateMetaMutation.mutate({
id: editingVbox.id,
data: {
name: data.name,
description: data.description || null,
label: data.label || null,
mailbox_ids: data.mailbox_ids,
},
rules,
})
} else {
createMutation.mutate({
name: data.name,
description: data.description || null,
label: data.label || null,
rules,
mailbox_ids: data.mailbox_ids,
})
}
}
const isPending = createMutation.isPending || updateMetaMutation.isPending
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingVbox ? 'Modifica Virtual Box' : 'Nuova Virtual Box'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5 mt-4">
{/* Metadati */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 col-span-2">
<Label>Nome *</Label>
<Input
{...register('name', { required: 'Obbligatorio' })}
placeholder="es. Multe da info@comune.it"
/>
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label>Etichetta</Label>
<Input
{...register('label')}
placeholder="es. Comune, Fornitori…"
/>
</div>
<div className="space-y-2">
<Label>Descrizione</Label>
<Input
{...register('description')}
placeholder="Descrizione opzionale…"
/>
</div>
</div>
{/* Caselle PEC reali */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Inbox className="h-4 w-4 text-primary" />
<Label>Caselle PEC reali *</Label>
</div>
{availableMailboxes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4 border-2 border-dashed rounded-lg">
Nessuna casella PEC disponibile. Configura prima una casella reale.
</p>
) : (
<div className="grid grid-cols-1 gap-2 max-h-44 overflow-y-auto border rounded-lg p-3">
{availableMailboxes.map((mb) => {
const checked = (selectedMailboxIds ?? []).includes(mb.id)
return (
<label
key={mb.id}
className={cn(
'flex items-center gap-3 p-2.5 rounded-md border cursor-pointer transition-colors',
checked
? 'border-primary bg-primary/5'
: 'border-transparent hover:bg-muted/50',
)}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleMailbox(mb.id)}
className="h-4 w-4 accent-primary"
/>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{mb.email_address}</p>
{mb.display_name && (
<p className="text-xs text-muted-foreground truncate">{mb.display_name}</p>
)}
</div>
</label>
)
})}
</div>
)}
{(selectedMailboxIds ?? []).length === 0 && availableMailboxes.length > 0 && (
<p className="text-xs text-amber-600">
⚠ Seleziona almeno una casella PEC reale da associare.
</p>
)}
</div>
{/* Regole */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Regole di filtro (combinate in AND)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ field: 'subject', operator: 'contains', value: '', date_from: '', date_to: '' })
}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Aggiungi regola
</Button>
</div>
{fields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4 border-2 border-dashed rounded-lg">
Nessuna regola. La VBox mostrerà tutti i messaggi delle caselle selezionate.
</p>
) : (
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2 p-3 border rounded-lg bg-muted/30">
<div className="grid grid-cols-3 gap-2 flex-1">
{/* Campo */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Campo</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
{...register(`rules.${index}.field`)}
>
{(Object.entries(FIELD_LABELS) as [VBoxField, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
{/* Operatore */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Operatore</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
{...register(`rules.${index}.operator`)}
>
{(Object.entries(OPERATOR_LABELS) as [VBoxOperator, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
{/* Valore */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Valore</label>
<Input
{...register(`rules.${index}.value`, { required: true })}
placeholder="es. info@comune.it"
className="h-9"
/>
</div>
{/* Date range */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Dal (opz.)</label>
<Input type="date" {...register(`rules.${index}.date_from`)} className="h-9" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Al (opz.)</label>
<Input type="date" {...register(`rules.${index}.date_to`)} className="h-9" />
</div>
</div>
<button
type="button"
onClick={() => remove(index)}
className="mt-5 p-1.5 rounded hover:bg-destructive/10 text-destructive"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={isPending}>
{editingVbox ? 'Salva' : 'Crea Virtual Box'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Dialog assegnazione utenti ───────────────────────────────────────────────
interface AssignUsersDialogProps {
vbox: VirtualBoxResponse
onClose: () => void
onSaved: () => void
}
function AssignUsersDialog({ vbox, onClose, onSaved }: AssignUsersDialogProps) {
const queryClient = useQueryClient()
const { data: usersData } = useQuery({
queryKey: ['users'],
queryFn: () => usersApi.list(1, 100),
})
const { data: assignedData, isLoading: loadingAssigned } = useQuery({
queryKey: ['vbox-assignments', vbox.id],
queryFn: () => virtualBoxesApi.listAssignedUsers(vbox.id),
})
const assignedIds = new Set((assignedData ?? []).map((a: AssignedUserResponse) => a.user_id))
const users = (usersData?.items ?? []).filter(
(u) => !u.is_active === false && !['super_admin', 'admin'].includes(u.role),
)
const assignMutation = useMutation({
mutationFn: (userId: string) =>
virtualBoxesApi.assignUsers(vbox.id, { user_ids: [userId] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vbox-assignments', vbox.id] })
toast.success('Utente assegnato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const unassignMutation = useMutation({
mutationFn: (userId: string) => virtualBoxesApi.unassignUser(vbox.id, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vbox-assignments', vbox.id] })
toast.success('Assegnazione rimossa')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Assegna utenti {vbox.name}</DialogTitle>
</DialogHeader>
{/* Riepilogo caselle */}
{vbox.mailboxes.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
<span className="text-xs text-muted-foreground mr-1">Caselle:</span>
{vbox.mailboxes.map((mb) => (
<span
key={mb.id}
className="inline-flex items-center gap-1 rounded-full bg-indigo-100 text-indigo-800 px-2 py-0.5 text-xs"
>
<Inbox className="h-3 w-3" />
{mb.display_name ?? mb.email_address}
</span>
))}
</div>
)}
<div className="mt-4 space-y-2 max-h-80 overflow-y-auto">
{loadingAssigned ? (
<div className="flex justify-center py-6">
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : users.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Nessun operatore disponibile
</p>
) : (
users.map((user) => {
const isAssigned = assignedIds.has(user.id)
return (
<div
key={user.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium">{user.full_name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
<Button
variant={isAssigned ? 'destructive' : 'outline'}
size="sm"
onClick={() => {
if (isAssigned) unassignMutation.mutate(user.id)
else assignMutation.mutate(user.id)
}}
isLoading={assignMutation.isPending || unassignMutation.isPending}
>
{isAssigned ? 'Rimuovi' : 'Assegna'}
</Button>
</div>
)
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Chiudi</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+197
View File
@@ -283,6 +283,203 @@ export interface UserMailboxPermissionResponse {
can_manage: boolean
}
// ─── Virtual Box ──────────────────────────────────────────────────────────────
export type VBoxField = 'mailbox_id' | 'imap_folder' | 'subject' | 'from_address' | 'to_address'
export type VBoxOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex'
export interface VirtualBoxRuleCreate {
field: VBoxField
operator: VBoxOperator
value: string
date_from?: string | null
date_to?: string | null
}
export interface VirtualBoxRuleResponse {
id: string
virtual_box_id: string
field: string
operator: string
value: string
date_from: string | null
date_to: string | null
created_at: string
}
export interface MailboxBriefResponse {
id: string
email_address: string
display_name: string | null
}
export interface VirtualBoxCreate {
name: string
description?: string | null
label?: string | null
rules?: VirtualBoxRuleCreate[]
mailbox_ids?: string[]
}
export interface VirtualBoxUpdate {
name?: string
description?: string | null
label?: string | null
is_active?: boolean
mailbox_ids?: string[] | null
}
export interface VirtualBoxMailboxAssignRequest {
mailbox_ids: string[]
}
export interface VirtualBoxResponse {
id: string
tenant_id: string
name: string
description: string | null
label: string | null
is_active: boolean
created_by: string | null
created_at: string
updated_at: string
rules: VirtualBoxRuleResponse[]
assignment_count: number
mailboxes: MailboxBriefResponse[]
}
export interface VirtualBoxListResponse {
items: VirtualBoxResponse[]
total: number
page: number
page_size: number
}
export interface VirtualBoxAssignRequest {
user_ids: string[]
}
export interface VirtualBoxAssignmentResponse {
id: string
virtual_box_id: string
user_id: string
assigned_by: string | null
assigned_at: string
}
export interface AssignedUserResponse {
user_id: string
user_email: string
user_full_name: string
assigned_at: string
}
// ─── Notifications ────────────────────────────────────────────────────────────
export type NotificationChannelType = 'webhook' | 'email' | 'telegram' | 'whatsapp'
export type NotificationEventType =
| 'new_message'
| 'state_changed'
| 'anomaly'
| 'send_failed'
| 'send_delivered'
| 'mailbox_error'
export type NotificationStatus = 'pending' | 'sent' | 'failed' | 'skipped'
export interface NotificationChannelCreate {
name: string
channel_type: NotificationChannelType
config?: Record<string, unknown> | null
config_secret?: Record<string, unknown> | null
}
export interface NotificationChannelUpdate {
name?: string
is_active?: boolean
config?: Record<string, unknown> | null
config_secret?: Record<string, unknown> | null
}
export interface NotificationChannelResponse {
id: string
tenant_id: string
name: string
channel_type: NotificationChannelType
is_active: boolean
config: Record<string, unknown> | null
consecutive_failures: number
circuit_open_until: string | null
created_by: string | null
created_at: string
updated_at: string
}
export interface NotificationChannelListResponse {
items: NotificationChannelResponse[]
total: number
page: number
page_size: number
}
export interface ChannelTestResult {
success: boolean
message: string
http_status: number | null
}
export interface NotificationRuleCreate {
channel_id: string
name: string
event_type: NotificationEventType
filter?: Record<string, unknown> | null
}
export interface NotificationRuleUpdate {
name?: string
event_type?: NotificationEventType
filter?: Record<string, unknown> | null
is_active?: boolean
}
export interface NotificationRuleResponse {
id: string
tenant_id: string
channel_id: string
name: string
event_type: string
filter: Record<string, unknown> | null
is_active: boolean
created_at: string
}
export interface NotificationRuleListResponse {
items: NotificationRuleResponse[]
total: number
}
export interface NotificationLogResponse {
id: string
tenant_id: string
channel_id: string
rule_id: string | null
event_type: string
status: NotificationStatus
attempt_count: number
max_attempts: number
next_retry_at: string | null
last_error: string | null
http_status: number | null
sent_at: string | null
created_at: string
}
export interface NotificationLogListResponse {
items: NotificationLogResponse[]
total: number
page: number
page_size: number
}
// ─── WebSocket events ─────────────────────────────────────────────────────────
export type WsEventType =
+50 -11
View File
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import AsyncSessionLocal
from app.imap.reconnect import ExponentialBackoff
from app.imap.sync import sync_new_messages
from app.imap.sync import sync_new_messages, sync_sent_messages
from app.models import Mailbox
logger = logging.getLogger(__name__)
@@ -144,17 +144,30 @@ class IMAPConnection:
backoff.reset()
await self._reset_error_state(mailbox, db)
# Sync iniziale: porta il DB aggiornato fino all'ultimo UID disponibile
logger.info(f"[{mailbox.email_address}] Sync iniziale...")
# Sync iniziale INBOX: porta il DB aggiornato fino all'ultimo UID disponibile
logger.info(f"[{mailbox.email_address}] Sync iniziale INBOX...")
try:
n = await sync_new_messages(self._client, mailbox, db, self.redis)
if n > 0:
logger.info(
f"[{mailbox.email_address}] Sync iniziale completata: {n} messaggi nuovi"
f"[{mailbox.email_address}] Sync iniziale INBOX completata: {n} messaggi nuovi"
)
except Exception as e:
logger.error(
f"[{mailbox.email_address}] Errore sync iniziale: {e}", exc_info=True
f"[{mailbox.email_address}] Errore sync iniziale INBOX: {e}", exc_info=True
)
# Sync iniziale Sent: sincronizza la posta inviata storica
logger.info(f"[{mailbox.email_address}] Sync iniziale Sent...")
try:
ns = await sync_sent_messages(self._client, mailbox, db, self.redis)
if ns > 0:
logger.info(
f"[{mailbox.email_address}] Sync iniziale Sent completata: {ns} messaggi nuovi"
)
except Exception as e:
logger.error(
f"[{mailbox.email_address}] Errore sync iniziale Sent: {e}", exc_info=True
)
# Avvia IDLE o polling
@@ -259,18 +272,32 @@ class IMAPConnection:
if line
)
# Ricarica mailbox dal DB prima delle sync
await db.refresh(mailbox)
if has_new:
logger.debug(
f"[{mailbox.email_address}] EXISTS ricevuto, sync..."
f"[{mailbox.email_address}] EXISTS ricevuto, sync INBOX..."
)
# Ricarica mailbox dal DB per avere last_sync_uid aggiornato
await db.refresh(mailbox)
n = await sync_new_messages(client, mailbox, db, self.redis)
if n > 0:
logger.info(
f"[{mailbox.email_address}] {n} nuovi messaggi sincronizzati"
f"[{mailbox.email_address}] {n} nuovi messaggi INBOX sincronizzati"
)
# Sync Sent ad ogni ciclo IDLE (heartbeat ~28 min o su EXISTS)
try:
ns = await sync_sent_messages(client, mailbox, db, self.redis)
if ns > 0:
logger.info(
f"[{mailbox.email_address}] {ns} nuovi messaggi Sent sincronizzati"
)
except Exception as e:
logger.warning(
f"[{mailbox.email_address}] Errore sync Sent in IDLE loop: {e}"
)
# sync_sent_messages garantisce il ritorno in INBOX anche in caso di errore
except asyncio.CancelledError:
try:
await client.idle_done()
@@ -311,12 +338,24 @@ class IMAPConnection:
except Exception:
raise ConnectionError("Connessione IMAP persa durante NOOP")
# Ricarica mailbox e controlla nuovi UID
# Ricarica mailbox e controlla nuovi UID INBOX
await db.refresh(mailbox)
n = await sync_new_messages(client, mailbox, db, self.redis)
if n > 0:
logger.info(
f"[{mailbox.email_address}] Polling: {n} nuovi messaggi"
f"[{mailbox.email_address}] Polling INBOX: {n} nuovi messaggi"
)
# Sync Sent ad ogni ciclo di polling
try:
ns = await sync_sent_messages(client, mailbox, db, self.redis)
if ns > 0:
logger.info(
f"[{mailbox.email_address}] Polling Sent: {ns} nuovi messaggi"
)
except Exception as e:
logger.warning(
f"[{mailbox.email_address}] Errore sync Sent in polling loop: {e}"
)
except asyncio.CancelledError:
+212 -23
View File
@@ -1,8 +1,8 @@
"""
Logica di sincronizzazione messaggi IMAP Fase 3 aggiornata.
Logica di sincronizzazione messaggi IMAP Fase 3 aggiornata + Sent folder.
Responsabilità:
1. Fetch della lista UID > last_sync_uid
1. Fetch della lista UID > last_sync_uid (INBOX e Sent)
2. Download envelope + raw EML per ogni UID
3. Parsing completo EML tramite app.parsers (Fase 3):
- Classificazione tipo PEC (X-Ricevuta / X-TipoRicevuta)
@@ -13,8 +13,11 @@ Responsabilità:
6. Upload allegati su MinIO + inserimento in tabella attachments
7. State machine messaggi outbound (sent→accepted→delivered/anomaly)
tramite X-Riferimento-Message-ID
8. Aggiornamento last_sync_uid e last_sync_at sulla mailbox
8. Aggiornamento last_sync_uid / sent_last_sync_uid sulla mailbox
9. Pubblicazione evento Redis per notifica WebSocket
Nota: ogni cartella IMAP ha un namespace UID separato, quindi la chiave
di idempotenza è (mailbox_id, imap_uid, imap_folder).
"""
import email
@@ -40,6 +43,17 @@ from app.storage.minio_client import upload_attachment, upload_eml
logger = logging.getLogger(__name__)
settings = get_settings()
# Nomi comuni della cartella Sent nei provider PEC italiani (in ordine di priorità)
SENT_FOLDER_CANDIDATES = [
"Sent",
"Sent Items",
"Sent Messages",
"Inviati",
"INBOX.Sent",
"INBOX.Inviati",
"INBOX.Sent Items",
]
# ─── Helper legacy (mantenuti per backward compatibility con i test) ──────────
@@ -122,7 +136,30 @@ def _parse_eml(raw_bytes: bytes) -> dict:
}
# ─── Core sync function ───────────────────────────────────────────────────────
# ─── Sent folder discovery ────────────────────────────────────────────────────
async def _select_sent_folder(
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
email_address: str,
) -> str | None:
"""
Prova i nomi comuni della cartella Sent finché uno funziona (SELECT OK).
Se trovata, il client è già selezionato su quella cartella.
Restituisce il nome della cartella trovata, o None.
"""
for candidate in SENT_FOLDER_CANDIDATES:
try:
status, _ = await imap_client.select(candidate)
if status == "OK":
logger.debug(f"[{email_address}] Cartella Sent trovata: {candidate!r}")
return candidate
except Exception:
continue
logger.info(f"[{email_address}] Nessuna cartella Sent trovata (tentativi: {SENT_FOLDER_CANDIDATES})")
return None
# ─── Core sync functions ──────────────────────────────────────────────────────
async def sync_new_messages(
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
@@ -131,7 +168,9 @@ async def sync_new_messages(
redis_client: aioredis.Redis,
) -> int:
"""
Sincronizza i messaggi nuovi (UID > last_sync_uid) per la mailbox data.
Sincronizza i messaggi nuovi (UID > last_sync_uid) dalla cartella INBOX.
ATTENZIONE: il client IMAP deve essere già selezionato su INBOX.
Returns:
Numero di nuovi messaggi sincronizzati.
@@ -143,12 +182,12 @@ async def sync_new_messages(
try:
status, search_data = await imap_client.search("UID", search_range)
except Exception as e:
logger.warning(f"[{mailbox.email_address}] SEARCH fallito: {e}")
logger.warning(f"[{mailbox.email_address}] SEARCH INBOX fallito: {e}")
return 0
if status != "OK":
logger.warning(
f"[{mailbox.email_address}] SEARCH status={status} data={search_data}"
f"[{mailbox.email_address}] SEARCH INBOX status={status} data={search_data}"
)
return 0
@@ -162,7 +201,7 @@ async def sync_new_messages(
seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle]
logger.info(
f"[{mailbox.email_address}] Trovati {len(seq_numbers)} messaggi nuovi da sincronizzare"
f"[{mailbox.email_address}] Trovati {len(seq_numbers)} messaggi nuovi in INBOX"
)
synced_count = 0
@@ -177,13 +216,16 @@ async def sync_new_messages(
mailbox=mailbox,
db=db,
redis_client=redis_client,
imap_folder="INBOX",
direction="inbound",
state="received",
)
if synced and uid and uid > max_uid_synced:
synced_count += 1
max_uid_synced = uid
except Exception as e:
logger.error(
f"[{mailbox.email_address}] Errore fetch seq {seq}: {e}",
f"[{mailbox.email_address}] Errore fetch INBOX seq {seq}: {e}",
exc_info=True,
)
@@ -197,6 +239,114 @@ async def sync_new_messages(
return synced_count
async def sync_sent_messages(
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
mailbox: Mailbox,
db: AsyncSession,
redis_client: aioredis.Redis,
) -> int:
"""
Sincronizza i messaggi nuovi dalla cartella Sent (posta inviata) del server IMAP.
Salva i messaggi come direction='outbound', state='sent'.
Dopo la sync ri-seleziona INBOX per ripristinare il client allo stato normale.
Returns:
Numero di nuovi messaggi inviati sincronizzati.
"""
# Trova e seleziona la cartella Sent
sent_folder = await _select_sent_folder(imap_client, mailbox.email_address)
if not sent_folder:
# Assicurati di tornare in INBOX
try:
await imap_client.select("INBOX")
except Exception:
pass
return 0
# Siamo ora selezionati nella cartella Sent
last_uid = mailbox.sent_last_sync_uid or 0
search_range = f"{last_uid + 1}:*"
try:
status, search_data = await imap_client.search("UID", search_range)
except Exception as e:
logger.warning(f"[{mailbox.email_address}] SEARCH Sent fallito: {e}")
try:
await imap_client.select("INBOX")
except Exception:
pass
return 0
if status != "OK":
try:
await imap_client.select("INBOX")
except Exception:
pass
return 0
raw_seqs = b" ".join(
d if isinstance(d, bytes) else d.encode() for d in search_data
).decode("ascii", errors="ignore").split()
seq_numbers = [s for s in raw_seqs if s.isdigit()]
if not seq_numbers:
logger.debug(f"[{mailbox.email_address}] Nessun messaggio nuovo in {sent_folder!r}")
try:
await imap_client.select("INBOX")
except Exception:
pass
return 0
seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle]
logger.info(
f"[{mailbox.email_address}] Trovati {len(seq_numbers)} messaggi nuovi in {sent_folder!r}"
)
synced_count = 0
max_uid_synced = last_uid
for seq in seq_numbers:
try:
uid, synced = await _fetch_and_save_message_by_seq(
imap_client=imap_client,
seq=seq,
last_uid=last_uid,
mailbox=mailbox,
db=db,
redis_client=redis_client,
imap_folder=sent_folder,
direction="outbound",
state="sent",
)
if synced and uid and uid > max_uid_synced:
synced_count += 1
max_uid_synced = uid
except Exception as e:
logger.error(
f"[{mailbox.email_address}] Errore fetch {sent_folder!r} seq {seq}: {e}",
exc_info=True,
)
# Aggiorna sent_last_sync_uid
if max_uid_synced > last_uid:
mailbox.sent_last_sync_uid = max_uid_synced
await db.flush()
await db.commit()
logger.info(
f"[{mailbox.email_address}] Sync Sent completata: {synced_count} messaggi nuovi"
)
# Ri-seleziona INBOX per tornare allo stato normale
try:
await imap_client.select("INBOX")
except Exception as e:
logger.warning(f"[{mailbox.email_address}] Re-SELECT INBOX dopo Sent sync: {e}")
return synced_count
async def _fetch_and_save_message_by_seq(
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
seq: str,
@@ -204,10 +354,18 @@ async def _fetch_and_save_message_by_seq(
mailbox: Mailbox,
db: AsyncSession,
redis_client: aioredis.Redis,
imap_folder: str = "INBOX",
direction: str = "inbound",
state: str = "received",
) -> tuple[int | None, bool]:
"""
Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID).
Args:
imap_folder: cartella IMAP corrente (per idempotenza e salvataggio)
direction: 'inbound' per INBOX, 'outbound' per Sent
state: 'received' per INBOX, 'sent' per Sent
Returns:
(uid, saved): UID del messaggio e True se salvato, False altrimenti.
"""
@@ -267,6 +425,9 @@ async def _fetch_and_save_message_by_seq(
mailbox=mailbox,
db=db,
redis_client=redis_client,
imap_folder=imap_folder,
direction=direction,
state=state,
)
@@ -279,11 +440,13 @@ async def _fetch_and_save_message(
) -> bool:
"""
Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot).
Sincronizza solo dalla cartella INBOX.
"""
existing = await db.execute(
select(Message.id).where(
Message.mailbox_id == mailbox.id,
Message.imap_uid == uid,
Message.imap_folder == "INBOX",
)
)
if existing.scalar_one_or_none():
@@ -319,6 +482,9 @@ async def _fetch_and_save_message(
mailbox=mailbox,
db=db,
redis_client=redis_client,
imap_folder="INBOX",
direction="inbound",
state="received",
)
@@ -331,26 +497,37 @@ async def _save_message(
mailbox: Mailbox,
db: AsyncSession,
redis_client: aioredis.Redis,
imap_folder: str = "INBOX",
direction: str = "inbound",
state: str = "received",
) -> bool:
"""
Salva un messaggio EML in DB e su MinIO.
Args:
imap_folder: cartella IMAP di provenienza ('INBOX', 'Sent', ecc.)
direction: 'inbound' per posta in arrivo, 'outbound' per posta inviata
state: stato iniziale del messaggio ('received' per inbound, 'sent' per outbound)
Fase 3 aggiornato per:
- Idempotenza basata su (mailbox_id, imap_uid, imap_folder) le UID sono
per-cartella in IMAP, quindi lo stesso UID può esistere in INBOX e Sent
- Parser completo (body, allegati, EML-in-EML)
- Classificazione precisa tipo PEC (tutti i provider)
- Salvataggio allegati su MinIO + tabella attachments
- State machine outbound: aggiorna stato messaggio originale alla ricezione ricevuta
- State machine outbound: solo per messaggi inbound (ricevute PEC)
- Collegamento parent_message_id via X-Riferimento-Message-ID
"""
# ── Idempotenza ───────────────────────────────────────────────────────────
# ── Idempotenza: chiave composta (mailbox_id + imap_uid + imap_folder) ────
existing = await db.execute(
select(Message.id).where(
Message.mailbox_id == mailbox.id,
Message.imap_uid == uid,
Message.imap_folder == imap_folder,
)
)
if existing.scalar_one_or_none():
logger.debug(f"[{mailbox.email_address}] UID {uid} già in DB, skip")
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
return False
# ── Parsing completo EML ──────────────────────────────────────────────────
@@ -361,9 +538,10 @@ async def _save_message(
received_at = datetime.now(UTC)
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
parent_message_id: uuid.UUID | None = None
if pec_class.is_receipt and pec_class.riferimento_message_id:
if direction == "inbound" and pec_class.is_receipt and pec_class.riferimento_message_id:
parent_message_id = await _apply_outbound_state_machine(
riferimento_message_id=pec_class.riferimento_message_id,
pec_type=pec_class.pec_type,
@@ -383,30 +561,36 @@ async def _save_message(
except Exception as e:
logger.error(f"[{mailbox.email_address}] Upload EML MinIO UID {uid}: {e}")
# ── Determina received_at / sent_at per messaggi outbound ─────────────────
# Per posta inviata: il campo "date" dell'EML è la data di invio
msg_received_at = received_at if direction == "inbound" else None
msg_sent_at = parsed.date if direction == "outbound" else parsed.date
# ── Salva messaggio in DB ─────────────────────────────────────────────────
message = Message(
id=uuid.uuid4(),
tenant_id=mailbox.tenant_id,
mailbox_id=mailbox.id,
imap_uid=uid,
imap_folder="INBOX",
direction="inbound",
state="received",
imap_folder=imap_folder,
direction=direction,
state=state,
pec_type=pec_class.pec_type,
subject=parsed.subject,
from_address=parsed.from_address,
to_addresses=parsed.to_addresses if parsed.to_addresses else None,
cc_addresses=parsed.cc_addresses if parsed.cc_addresses else None,
message_id_header=parsed.message_id,
sent_at=parsed.date,
received_at=received_at,
sent_at=msg_sent_at,
received_at=msg_received_at,
size_bytes=size_bytes,
body_text=parsed.body_text,
body_html=parsed.body_html,
has_attachments=parsed.has_attachments,
parent_message_id=parent_message_id,
raw_eml_path=eml_path,
is_read=False,
# Messaggi outbound (Sent) sono già stati letti dal mittente
is_read=(direction == "outbound"),
)
db.add(message)
await db.flush() # ottieni message.id prima di salvare gli allegati
@@ -429,6 +613,7 @@ async def _save_message(
"subject": message.subject or "",
"from_address": message.from_address or "",
"pec_type": message.pec_type,
"direction": direction,
"is_receipt": pec_class.is_receipt,
"received_at": received_at.isoformat(),
}
@@ -437,10 +622,9 @@ async def _save_message(
logger.warning(f"[{mailbox.email_address}] Redis publish UID {uid}: {e}")
logger.info(
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} "
f"pec_type={pec_class.pec_type!r} "
f"subject={message.subject!r} "
f"allegati={len(parsed.attachments)}"
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} folder={imap_folder!r} "
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
)
return True
@@ -506,6 +690,11 @@ async def _save_attachments(
db: sessione DB
"""
for att in attachments:
# Salta i file di sistema PEC (daticert.xml, postacert.eml, smime.p7s, ecc.)
# L'EML grezzo è già conservato su MinIO tramite upload_eml
if att.is_pec_system:
continue
storage_path: str | None = None
try:
+1
View File
@@ -72,6 +72,7 @@ class Mailbox(Base):
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sent_last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+16 -2
View File
@@ -296,8 +296,8 @@ def _extract_eml_in_eml(
"""
Estrae il messaggio EML annidato in un part message/rfc822.
Nella struttura PEC, questo è tipicamente il messaggio originale
allegato alle ricevute di consegna/accettazione.
Per postacert.eml (busta PEC in arrivo): ricorre dentro per estrarre
gli allegati utente e il corpo del messaggio originale del mittente.
"""
try:
payload = part.get_payload()
@@ -330,6 +330,20 @@ def _extract_eml_in_eml(
)
result.attachments.append(att)
# Per postacert.eml: ricorre dentro per trovare allegati utente e corpo originale
if is_system and eff_filename.lower() == "postacert.eml":
inner_parsed = parse_eml(inner_bytes)
# Allegati non-sistema del messaggio originale del mittente
for inner_att in inner_parsed.attachments:
if not inner_att.is_pec_system:
result.attachments.append(inner_att)
# Corpo del messaggio originale (più utile del testo della busta PEC)
if inner_parsed.body_html:
result.body_html = inner_parsed.body_html
result.body_text = inner_parsed.body_text
elif inner_parsed.body_text:
result.body_text = inner_parsed.body_text
except Exception as exc:
logger.warning(f"Errore estrazione EML-in-EML: {exc}")