mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
vbox funzionanti
This commit is contained in:
@@ -6,6 +6,8 @@ Ho docker installato, compose v2 (docker cmpose senza trattino)
|
|||||||
|
|
||||||
Non fare commit sul repository GitHub, ci penso io
|
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
|
Queste le caselle PEC e i loro parametri IMAP/SMTP che puoi usare per test, non effettuare invii per adesso
|
||||||
|
|
||||||
Casella: gmgspa@pec.it
|
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")
|
||||||
@@ -31,6 +31,8 @@ from app.dependencies import CurrentUser, DB
|
|||||||
from app.models.message import Attachment, Message
|
from app.models.message import Attachment, Message
|
||||||
from app.schemas.message import (
|
from app.schemas.message import (
|
||||||
AttachmentResponse,
|
AttachmentResponse,
|
||||||
|
MessageBulkUpdateRequest,
|
||||||
|
MessageBulkUpdateResponse,
|
||||||
MessageListResponse,
|
MessageListResponse,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
MessageUpdateRequest,
|
MessageUpdateRequest,
|
||||||
@@ -42,6 +44,49 @@ settings = get_settings()
|
|||||||
|
|
||||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
# ─── 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(
|
async def _get_visible_mailbox_ids(
|
||||||
user, db: AsyncSession
|
user, db: AsyncSession
|
||||||
) -> Optional[list[uuid.UUID]]:
|
) -> Optional[list[uuid.UUID]]:
|
||||||
@@ -89,6 +134,7 @@ async def list_messages(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DB,
|
db: DB,
|
||||||
# Filtri
|
# Filtri
|
||||||
|
vbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per Virtual Box assegnata"),
|
||||||
mailbox_id: Optional[uuid.UUID] = Query(None),
|
mailbox_id: Optional[uuid.UUID] = Query(None),
|
||||||
direction: Optional[str] = Query(None, pattern="^(inbound|outbound)$"),
|
direction: Optional[str] = Query(None, pattern="^(inbound|outbound)$"),
|
||||||
state: Optional[str] = Query(None),
|
state: Optional[str] = Query(None),
|
||||||
@@ -106,17 +152,61 @@ async def list_messages(
|
|||||||
|
|
||||||
- `is_archived=False` (default) esclude i messaggi archiviati.
|
- `is_archived=False` (default) esclude i messaggi archiviati.
|
||||||
- `search` cerca su subject, from_address, to_addresses.
|
- `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)
|
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
|
# Query base
|
||||||
q = select(Message).where(
|
q = select(Message).where(
|
||||||
Message.tenant_id == current_user.tenant_id,
|
Message.tenant_id == current_user.tenant_id,
|
||||||
Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio)
|
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 visible_mailbox_ids is not None:
|
||||||
if not visible_mailbox_ids:
|
if not visible_mailbox_ids:
|
||||||
# Nessuna casella accessibile → lista vuota
|
# 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
|
# Conteggio totale
|
||||||
count_q = select(func.count()).select_from(q.subquery())
|
count_q = select(func.count()).select_from(q.subquery())
|
||||||
total = (await db.execute(count_q)).scalar_one()
|
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)
|
@router.get("/{message_id}", response_model=MessageResponse)
|
||||||
async def get_message(
|
async def get_message(
|
||||||
message_id: uuid.UUID,
|
message_id: uuid.UUID,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from slowapi.middleware import SlowAPIMiddleware
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
from slowapi.util import get_remote_address
|
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.config import get_settings
|
||||||
from app.core.logging import get_logger, setup_logging
|
from app.core.logging import get_logger, setup_logging
|
||||||
from app.database import engine
|
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(messages.router, prefix=API_PREFIX)
|
||||||
app.include_router(send.router, prefix=API_PREFIX)
|
app.include_router(send.router, prefix=API_PREFIX)
|
||||||
app.include_router(ws.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 ─────────────────────────────────────────────────────────────
|
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip
|
|||||||
from app.models.audit_log import AuditLog # noqa: F401
|
from app.models.audit_log import AuditLog # noqa: F401
|
||||||
from app.models.label import Label, MessageLabel # noqa: F401
|
from app.models.label import Label, MessageLabel # noqa: F401
|
||||||
from app.models.permission import MailboxPermission # 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
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class Mailbox(Base):
|
|||||||
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
|
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_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, 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_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|||||||
@@ -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}>"
|
||||||
@@ -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}>"
|
||||||
@@ -81,3 +81,14 @@ class MessageUpdateRequest(BaseModel):
|
|||||||
is_read: Optional[bool] = None
|
is_read: Optional[bool] = None
|
||||||
is_starred: Optional[bool] = None
|
is_starred: Optional[bool] = None
|
||||||
is_archived: 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]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -7,44 +7,71 @@ import { ComposePage } from '@/pages/Compose/ComposePage'
|
|||||||
import { MailboxesPage } from '@/pages/Mailboxes/MailboxesPage'
|
import { MailboxesPage } from '@/pages/Mailboxes/MailboxesPage'
|
||||||
import { UsersPage } from '@/pages/Users/UsersPage'
|
import { UsersPage } from '@/pages/Users/UsersPage'
|
||||||
import { PermissionsPage } from '@/pages/Permissions/PermissionsPage'
|
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.
|
* Routing principale dell'applicazione PecFlow.
|
||||||
*
|
*
|
||||||
* Struttura:
|
* Struttura:
|
||||||
* - /login → LoginPage (pubblica)
|
* - /login → LoginPage (pubblica)
|
||||||
* - /* → AppLayout (richiede autenticazione)
|
* - /* → AppLayout (richiede autenticazione)
|
||||||
* - /inbox → InboxPage
|
* - /inbox → Posta in arrivo (tutte le caselle)
|
||||||
* - /sent → InboxPage (filtrata su outbound)
|
* - /sent → Posta inviata (tutte le caselle)
|
||||||
* - /messages/:id → MessageDetailPage
|
* - /starred → Preferiti (tutte le caselle)
|
||||||
* - /compose → ComposePage
|
* - /archived → Archiviati (tutte le caselle)
|
||||||
* - /mailboxes → MailboxesPage (admin)
|
* - /mailbox/:mailboxId/inbox → Posta in arrivo di una specifica casella
|
||||||
* - /users → UsersPage (admin)
|
* - /mailbox/:mailboxId/sent → Posta inviata di una specifica casella
|
||||||
* - /permissions → PermissionsPage (admin)
|
* - /mailbox/:mailboxId/starred → Preferiti di una specifica casella
|
||||||
* - / → redirect a /inbox
|
* - /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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Pagine pubbliche */}
|
{/* Pagina pubblica */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
{/* Pagine protette (dentro AppLayout) */}
|
{/* Pagine protette (dentro AppLayout) */}
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||||
<Route path="/inbox" element={<InboxPage />} />
|
|
||||||
<Route
|
{/* Vista globale: tutte le caselle insieme */}
|
||||||
path="/sent"
|
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||||
element={<InboxPage />}
|
<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="/messages/:id" element={<MessageDetailPage />} />
|
||||||
<Route path="/compose" element={<ComposePage />} />
|
<Route path="/compose" element={<ComposePage />} />
|
||||||
|
|
||||||
{/* Pagine admin */}
|
{/* Pagine admin */}
|
||||||
<Route path="/mailboxes" element={<MailboxesPage />} />
|
<Route path="/mailboxes" element={<MailboxesPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/permissions" element={<PermissionsPage />} />
|
<Route path="/permissions" element={<PermissionsPage />} />
|
||||||
|
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
|
||||||
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
|
|
||||||
|
{/* Profilo utente */}
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/inbox" replace />} />
|
<Route path="*" element={<Navigate to="/inbox" replace />} />
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
export interface MessageFilters {
|
export interface MessageFilters {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
|
/** Filtra per Virtual Box assegnata all'utente corrente */
|
||||||
|
vbox_id?: string
|
||||||
mailbox_id?: string
|
mailbox_id?: string
|
||||||
direction?: 'inbound' | 'outbound'
|
direction?: 'inbound' | 'outbound'
|
||||||
state?: string
|
state?: string
|
||||||
@@ -17,6 +19,17 @@ export interface MessageFilters {
|
|||||||
search?: string
|
search?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageBulkUpdatePayload {
|
||||||
|
ids: string[]
|
||||||
|
is_starred?: boolean
|
||||||
|
is_archived?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageBulkUpdateResponse {
|
||||||
|
updated: number
|
||||||
|
items: MessageResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
export const messagesApi = {
|
export const messagesApi = {
|
||||||
list: (filters: MessageFilters = {}) =>
|
list: (filters: MessageFilters = {}) =>
|
||||||
apiClient
|
apiClient
|
||||||
@@ -42,6 +55,17 @@ export const messagesApi = {
|
|||||||
.patch<MessageResponse>(`/messages/${id}`, { is_archived: true })
|
.patch<MessageResponse>(`/messages/${id}`, { is_archived: true })
|
||||||
.then((r) => r.data),
|
.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) =>
|
getAttachments: (id: string) =>
|
||||||
apiClient.get<AttachmentResponse[]>(`/messages/${id}/attachments`).then((r) => r.data),
|
apiClient.get<AttachmentResponse[]>(`/messages/${id}/attachments`).then((r) => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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 { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -9,38 +43,80 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Shield,
|
Shield,
|
||||||
|
ChevronDown,
|
||||||
|
Filter,
|
||||||
|
Bell,
|
||||||
|
Star,
|
||||||
|
Archive,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useInboxStore } from '@/store/inbox.store'
|
import { useInboxStore } from '@/store/inbox.store'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
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 {
|
// ─── Sidebar principale ───────────────────────────────────────────────────────
|
||||||
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 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
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 { user, isAdmin, logout } = useAuth()
|
||||||
const unreadCount = useInboxStore((s) => s.unreadCount)
|
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 () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await logout()
|
await logout()
|
||||||
@@ -53,15 +129,15 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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',
|
collapsed ? 'w-16' : 'w-64',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo + toggle */}
|
{/* ── Logo + pulsante collassa ── */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
PF
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-lg">PecFlow</span>
|
<span className="font-bold text-lg">PecFlow</span>
|
||||||
@@ -73,54 +149,253 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-1 rounded hover:bg-gray-700 transition-colors text-gray-400',
|
'p-1 rounded hover:bg-gray-700 transition-colors text-gray-400',
|
||||||
collapsed && 'mx-auto mt-0',
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigazione principale */}
|
{/* ── Navigazione principale ── */}
|
||||||
<nav className="flex-1 overflow-y-auto py-4">
|
<nav className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||||
<div className="space-y-1 px-2">
|
|
||||||
{NAV_ITEMS.map((item) => (
|
{/* ── Sezione: Tutte le caselle ── */}
|
||||||
<SidebarLink
|
<div>
|
||||||
key={item.to}
|
{!collapsed && (
|
||||||
item={item}
|
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
collapsed={collapsed}
|
Tutte le caselle
|
||||||
badge={item.to === '/inbox' ? unreadCount : undefined}
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sezione Admin */}
|
{/* ── Sezione: Caselle individuali ── */}
|
||||||
{isAdmin && (
|
{mailboxes.length > 0 && (
|
||||||
<>
|
<div>
|
||||||
<div className={cn('mt-6 px-4 mb-2', collapsed && 'hidden')}>
|
{!collapsed && (
|
||||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<>
|
||||||
Amministrazione
|
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||||
</p>
|
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
</div>
|
Le tue caselle
|
||||||
{!collapsed && <div className="border-t border-gray-700 mx-4 mb-2" />}
|
</p>
|
||||||
<div className="space-y-1 px-2">
|
</>
|
||||||
{ADMIN_NAV_ITEMS.map((item) => (
|
)}
|
||||||
<SidebarLink key={item.to} item={item} collapsed={collapsed} />
|
{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>
|
||||||
</>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Profilo utente + logout */}
|
{/* ── Profilo utente + logout ── */}
|
||||||
<div className="border-t border-gray-700 p-3">
|
<div className="border-t border-gray-700 p-3">
|
||||||
{!collapsed ? (
|
{!collapsed ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{user?.full_name}</p>
|
<p className="text-sm font-medium truncate">{user?.full_name}</p>
|
||||||
@@ -158,40 +433,295 @@ export function Sidebar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarLinkProps {
|
// ─── Voce di casella PEC nel sidebar ─────────────────────────────────────────
|
||||||
item: NavItem
|
|
||||||
|
interface MailboxNavItemProps {
|
||||||
|
mailbox: MailboxResponse
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
badge?: number
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarLink({ item, collapsed, badge }: SidebarLinkProps) {
|
/** Colore del pallino di stato casella */
|
||||||
const Icon = item.icon
|
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 (
|
return (
|
||||||
<NavLink
|
<div>
|
||||||
to={item.to}
|
{/* Intestazione casella (espandi/comprimi) */}
|
||||||
className={({ isActive }) =>
|
<button
|
||||||
cn(
|
onClick={onToggle}
|
||||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
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"
|
||||||
isActive
|
>
|
||||||
? 'bg-blue-600 text-white'
|
{/* Avatar + status dot */}
|
||||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
<div className="relative flex-shrink-0">
|
||||||
collapsed && 'justify-center px-2',
|
<div className="h-5 w-5 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold text-white">
|
||||||
)
|
{initial}
|
||||||
}
|
</div>
|
||||||
title={collapsed ? item.label : undefined}
|
<span
|
||||||
>
|
className={cn(
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-gray-900',
|
||||||
{!collapsed && (
|
dotClass,
|
||||||
<>
|
)}
|
||||||
<span className="flex-1">{item.label}</span>
|
/>
|
||||||
{badge !== undefined && badge > 0 && (
|
</div>
|
||||||
<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}
|
{/* Nome / email */}
|
||||||
</span>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -7,10 +22,15 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Mail,
|
Mail,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Filter,
|
|
||||||
Send,
|
Send,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Archive,
|
||||||
|
ArchiveX,
|
||||||
|
StarOff,
|
||||||
|
CheckSquare,
|
||||||
|
Square,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
@@ -19,66 +39,248 @@ import { Input } from '@/components/ui/Input'
|
|||||||
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
|
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||||
import { messagesApi } from '@/api/messages.api'
|
import { messagesApi } from '@/api/messages.api'
|
||||||
import { mailboxesApi } from '@/api/mailboxes.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 { formatRelative, truncate } from '@/lib/utils'
|
||||||
import type { MessageResponse } from '@/types/api.types'
|
import type { MessageResponse } from '@/types/api.types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { getErrorMessage } from '@/api/client'
|
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 navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
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({
|
const { data: mailboxesData } = useQuery({
|
||||||
queryKey: ['mailboxes'],
|
queryKey: ['mailboxes'],
|
||||||
queryFn: () => mailboxesApi.list(),
|
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 {
|
const {
|
||||||
data: messagesData,
|
data: messagesData,
|
||||||
isLoading,
|
isLoading,
|
||||||
refetch,
|
refetch,
|
||||||
isRefetching,
|
isRefetching,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['messages', filters],
|
queryKey: ['messages', queryFilters],
|
||||||
queryFn: () =>
|
queryFn: () => messagesApi.list(queryFilters),
|
||||||
messagesApi.list({
|
refetchInterval: 60_000,
|
||||||
...filters,
|
|
||||||
direction: selectedDirection === 'all' ? undefined : selectedDirection,
|
|
||||||
search: searchInput || undefined,
|
|
||||||
}),
|
|
||||||
refetchInterval: 60000, // refresh ogni minuto
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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({
|
const markReadMutation = useMutation({
|
||||||
mutationFn: messagesApi.markRead,
|
mutationFn: messagesApi.markRead,
|
||||||
onSuccess: (updatedMsg) => {
|
onSuccess: (updatedMsg) => {
|
||||||
queryClient.setQueryData(['messages', filters], (old: { items: MessageResponse[] } | undefined) => {
|
queryClient.setQueryData(
|
||||||
if (!old) return old
|
['messages', queryFilters],
|
||||||
return {
|
(old: { items: MessageResponse[]; total: number } | undefined) => {
|
||||||
...old,
|
if (!old) return old
|
||||||
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
|
return {
|
||||||
}
|
...old,
|
||||||
})
|
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Gestione ricerca con debounce
|
// ── Toggle stella singolo ────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
const toggleStarMutation = useMutation({
|
||||||
const timer = setTimeout(() => {
|
mutationFn: ({ id, starred }: { id: string; starred: boolean }) =>
|
||||||
setFilters({ search: searchInput || undefined, page: 1 })
|
messagesApi.toggleStar(id, starred),
|
||||||
}, 400)
|
onSuccess: (updatedMsg) => {
|
||||||
return () => clearTimeout(timer)
|
queryClient.setQueryData(
|
||||||
}, [searchInput, setFilters])
|
['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) => {
|
const handleMessageClick = async (message: MessageResponse) => {
|
||||||
if (!message.is_read) {
|
if (!message.is_read && message.direction === 'inbound') {
|
||||||
markReadMutation.mutate(message.id)
|
markReadMutation.mutate(message.id)
|
||||||
}
|
}
|
||||||
navigate(`/messages/${message.id}`)
|
navigate(`/messages/${message.id}`)
|
||||||
@@ -93,24 +295,65 @@ export function InboxPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = messagesData?.items || []
|
// ── Label e icone folder ────────────────────────────────────────────────────
|
||||||
const total = messagesData?.total || 0
|
const isInbound = viewMode === 'inbox'
|
||||||
const currentPage = filters.page || 1
|
const folderLabel =
|
||||||
const pageSize = filters.page_size || 50
|
viewMode === 'inbox'
|
||||||
const totalPages = Math.ceil(total / pageSize)
|
? '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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* ── Header ── */}
|
||||||
<div className="border-b bg-background px-6 py-4">
|
<div className="border-b bg-background px-6 py-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Inbox className="h-5 w-5 text-primary" />
|
<FolderIcon className="h-5 w-5 text-primary mt-1 flex-shrink-0" />
|
||||||
<h1 className="text-xl font-semibold">Posta</h1>
|
<div>
|
||||||
{total > 0 && (
|
{currentMailbox ? (
|
||||||
<span className="text-sm text-muted-foreground">({total} messaggi)</span>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -128,111 +371,201 @@ export function InboxPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtri */}
|
{/* ── Filtri ── */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{/* Barra di ricerca */}
|
|
||||||
<div className="relative flex-1 min-w-48">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cerca per oggetto, mittente..."
|
placeholder={
|
||||||
|
isInbound ? 'Cerca per oggetto, mittente…' : 'Cerca per oggetto, destinatario…'
|
||||||
|
}
|
||||||
className="pl-9 h-9"
|
className="pl-9 h-9"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtro direzione */}
|
{viewMode === 'inbox' && (
|
||||||
<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">
|
|
||||||
<Button
|
<Button
|
||||||
variant={filters.is_read === false ? 'default' : 'outline'}
|
variant={isReadFilter === false ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => setIsReadFilter(isReadFilter === false ? undefined : false)}
|
||||||
setFilters({ is_read: filters.is_read === false ? undefined : false })
|
|
||||||
}
|
|
||||||
className="h-9 text-xs"
|
className="h-9 text-xs"
|
||||||
>
|
>
|
||||||
<Mail className="h-3.5 w-3.5 mr-1" />
|
<Mail className="h-3.5 w-3.5 mr-1" />
|
||||||
Non letti
|
Non letti
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(viewMode === 'inbox' || viewMode === 'sent') && (
|
||||||
<Button
|
<Button
|
||||||
variant={filters.is_starred === true ? 'default' : 'outline'}
|
variant={isStarredFilter === true ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => setIsStarredFilter(isStarredFilter === true ? undefined : true)}
|
||||||
setFilters({ is_starred: filters.is_starred === true ? undefined : true })
|
|
||||||
}
|
|
||||||
className="h-9 text-xs"
|
className="h-9 text-xs"
|
||||||
>
|
>
|
||||||
<Star className="h-3.5 w-3.5 mr-1" />
|
<Star className="h-3.5 w-3.5 mr-1" />
|
||||||
Preferiti
|
Preferiti
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
<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-muted-foreground font-medium">Nessun messaggio trovato</p>
|
||||||
<p className="text-sm text-muted-foreground/70 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<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) => (
|
{messages.map((message) => (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
|
viewMode={viewMode}
|
||||||
|
isSelected={selectedIds.has(message.id)}
|
||||||
|
onSelect={(e) => handleToggleSelect(message.id, e)}
|
||||||
onClick={() => handleMessageClick(message)}
|
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={
|
mailboxName={
|
||||||
mailboxesData?.items.find((m) => m.id === message.mailbox_id)
|
!mailboxId
|
||||||
?.email_address
|
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -240,26 +573,26 @@ export function InboxPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Paginazione */}
|
{/* ── Paginazione ── */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="border-t px-6 py-3 flex items-center justify-between">
|
<div className="border-t px-6 py-3 flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Pagina {currentPage} di {totalPages} ({total} messaggi)
|
Pagina {page} di {totalPages} ({total} messaggi)
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={currentPage <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => setFilters({ page: currentPage - 1 })}
|
onClick={() => setPage((p) => p - 1)}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={currentPage >= totalPages}
|
disabled={page >= totalPages}
|
||||||
onClick={() => setFilters({ page: currentPage + 1 })}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -274,23 +607,59 @@ export function InboxPage() {
|
|||||||
|
|
||||||
interface MessageRowProps {
|
interface MessageRowProps {
|
||||||
message: MessageResponse
|
message: MessageResponse
|
||||||
|
viewMode: InboxViewMode
|
||||||
|
isSelected: boolean
|
||||||
|
onSelect: (e: React.MouseEvent) => void
|
||||||
onClick: () => 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
|
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'
|
const isUnread = !message.is_read && message.direction === 'inbound'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-3 px-6 py-4 cursor-pointer hover:bg-muted/50 transition-colors',
|
'flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors group',
|
||||||
isUnread && 'bg-blue-50/50',
|
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
|
||||||
|
isSelected && 'bg-blue-100/60 dark:bg-blue-900/30',
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
{/* Icona direzione */}
|
{/* ── Checkbox selezione ── */}
|
||||||
<div className="mt-1 flex-shrink-0">
|
<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' ? (
|
{message.direction === 'inbound' ? (
|
||||||
isUnread ? (
|
isUnread ? (
|
||||||
<Mail className="h-5 w-5 text-blue-600" />
|
<Mail className="h-5 w-5 text-blue-600" />
|
||||||
@@ -302,7 +671,7 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contenuto */}
|
{/* ── Contenuto ── */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
@@ -313,15 +682,17 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.direction === 'inbound'
|
{message.direction === 'inbound'
|
||||||
? message.from_address || 'Mittente sconosciuto'
|
? (message.from_address || 'Mittente sconosciuto')
|
||||||
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
|
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{mailboxName && (
|
{mailboxName && (
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded flex-shrink-0">
|
||||||
› {mailboxName}
|
{mailboxName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<PecStateBadge state={message.state} />
|
<PecStateBadge state={message.state} />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -351,9 +722,46 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Indicatori */}
|
{/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */}
|
||||||
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{message.is_starred && <Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />}
|
{/* 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 && (
|
{message.has_attachments && (
|
||||||
<span className="text-xs text-muted-foreground">📎</span>
|
<span className="text-xs text-muted-foreground">📎</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Star,
|
Star,
|
||||||
Archive,
|
Archive,
|
||||||
|
ArchiveX,
|
||||||
Download,
|
Download,
|
||||||
Reply,
|
Reply,
|
||||||
Forward,
|
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Mail,
|
Mail,
|
||||||
Send,
|
Send,
|
||||||
@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
|
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
|
||||||
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
|
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
|
||||||
import { messagesApi } from '@/api/messages.api'
|
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'
|
import { getErrorMessage } from '@/api/client'
|
||||||
|
|
||||||
export function MessageDetailPage() {
|
export function MessageDetailPage() {
|
||||||
@@ -54,6 +54,8 @@ export function MessageDetailPage() {
|
|||||||
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
|
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.setQueryData(['message', id], 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')
|
toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti')
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
@@ -62,9 +64,21 @@ export function MessageDetailPage() {
|
|||||||
// Archivia
|
// Archivia
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () => messagesApi.archive(id!),
|
mutationFn: () => messagesApi.archive(id!),
|
||||||
onSuccess: () => {
|
onSuccess: (updated) => {
|
||||||
|
queryClient.setQueryData(['message', id], updated)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||||
toast.success('Messaggio archiviato')
|
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)),
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
})
|
})
|
||||||
@@ -103,29 +117,46 @@ export function MessageDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Stella */}
|
{/* Stella / Preferito */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => toggleStarMutation.mutate(!message.is_starred)}
|
onClick={() => toggleStarMutation.mutate(!message.is_starred)}
|
||||||
|
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
|
||||||
|
isLoading={toggleStarMutation.isPending}
|
||||||
>
|
>
|
||||||
<Star
|
<Star
|
||||||
className={`h-5 w-5 ${message.is_starred ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground'}`}
|
className={`h-5 w-5 ${message.is_starred ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground'}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Archivia */}
|
{/* Archivia (se non ancora archiviato) */}
|
||||||
{!message.is_archived && (
|
{!message.is_archived && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => archiveMutation.mutate()}
|
onClick={() => archiveMutation.mutate()}
|
||||||
title="Archivia"
|
title="Archivia"
|
||||||
|
isLoading={archiveMutation.isPending}
|
||||||
>
|
>
|
||||||
<Archive className="h-5 w-5 text-muted-foreground" />
|
<Archive className="h-5 w-5 text-muted-foreground" />
|
||||||
</Button>
|
</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) */}
|
{/* Rispondi (solo per messaggi inbound PEC certificata) */}
|
||||||
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && (
|
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && (
|
||||||
<Button
|
<Button
|
||||||
@@ -144,6 +175,26 @@ export function MessageDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Contenuto */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-4xl mx-auto px-6 py-8 space-y-6">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -283,6 +283,203 @@ export interface UserMailboxPermissionResponse {
|
|||||||
can_manage: boolean
|
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 ─────────────────────────────────────────────────────────
|
// ─── WebSocket events ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type WsEventType =
|
export type WsEventType =
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.imap.reconnect import ExponentialBackoff
|
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
|
from app.models import Mailbox
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -144,17 +144,30 @@ class IMAPConnection:
|
|||||||
backoff.reset()
|
backoff.reset()
|
||||||
await self._reset_error_state(mailbox, db)
|
await self._reset_error_state(mailbox, db)
|
||||||
|
|
||||||
# Sync iniziale: porta il DB aggiornato fino all'ultimo UID disponibile
|
# Sync iniziale INBOX: porta il DB aggiornato fino all'ultimo UID disponibile
|
||||||
logger.info(f"[{mailbox.email_address}] Sync iniziale...")
|
logger.info(f"[{mailbox.email_address}] Sync iniziale INBOX...")
|
||||||
try:
|
try:
|
||||||
n = await sync_new_messages(self._client, mailbox, db, self.redis)
|
n = await sync_new_messages(self._client, mailbox, db, self.redis)
|
||||||
if n > 0:
|
if n > 0:
|
||||||
logger.info(
|
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:
|
except Exception as e:
|
||||||
logger.error(
|
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
|
# Avvia IDLE o polling
|
||||||
@@ -259,18 +272,32 @@ class IMAPConnection:
|
|||||||
if line
|
if line
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ricarica mailbox dal DB prima delle sync
|
||||||
|
await db.refresh(mailbox)
|
||||||
|
|
||||||
if has_new:
|
if has_new:
|
||||||
logger.debug(
|
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)
|
n = await sync_new_messages(client, mailbox, db, self.redis)
|
||||||
if n > 0:
|
if n > 0:
|
||||||
logger.info(
|
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:
|
except asyncio.CancelledError:
|
||||||
try:
|
try:
|
||||||
await client.idle_done()
|
await client.idle_done()
|
||||||
@@ -311,12 +338,24 @@ class IMAPConnection:
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise ConnectionError("Connessione IMAP persa durante NOOP")
|
raise ConnectionError("Connessione IMAP persa durante NOOP")
|
||||||
|
|
||||||
# Ricarica mailbox e controlla nuovi UID
|
# Ricarica mailbox e controlla nuovi UID INBOX
|
||||||
await db.refresh(mailbox)
|
await db.refresh(mailbox)
|
||||||
n = await sync_new_messages(client, mailbox, db, self.redis)
|
n = await sync_new_messages(client, mailbox, db, self.redis)
|
||||||
if n > 0:
|
if n > 0:
|
||||||
logger.info(
|
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:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
+212
-23
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Logica di sincronizzazione messaggi IMAP – Fase 3 aggiornata.
|
Logica di sincronizzazione messaggi IMAP – Fase 3 aggiornata + Sent folder.
|
||||||
|
|
||||||
Responsabilità:
|
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
|
2. Download envelope + raw EML per ogni UID
|
||||||
3. Parsing completo EML tramite app.parsers (Fase 3):
|
3. Parsing completo EML tramite app.parsers (Fase 3):
|
||||||
- Classificazione tipo PEC (X-Ricevuta / X-TipoRicevuta)
|
- Classificazione tipo PEC (X-Ricevuta / X-TipoRicevuta)
|
||||||
@@ -13,8 +13,11 @@ Responsabilità:
|
|||||||
6. Upload allegati su MinIO + inserimento in tabella attachments
|
6. Upload allegati su MinIO + inserimento in tabella attachments
|
||||||
7. State machine messaggi outbound (sent→accepted→delivered/anomaly)
|
7. State machine messaggi outbound (sent→accepted→delivered/anomaly)
|
||||||
tramite X-Riferimento-Message-ID
|
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
|
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
|
import email
|
||||||
@@ -40,6 +43,17 @@ from app.storage.minio_client import upload_attachment, upload_eml
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
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) ──────────
|
# ─── 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(
|
async def sync_new_messages(
|
||||||
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
||||||
@@ -131,7 +168,9 @@ async def sync_new_messages(
|
|||||||
redis_client: aioredis.Redis,
|
redis_client: aioredis.Redis,
|
||||||
) -> int:
|
) -> 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:
|
Returns:
|
||||||
Numero di nuovi messaggi sincronizzati.
|
Numero di nuovi messaggi sincronizzati.
|
||||||
@@ -143,12 +182,12 @@ async def sync_new_messages(
|
|||||||
try:
|
try:
|
||||||
status, search_data = await imap_client.search("UID", search_range)
|
status, search_data = await imap_client.search("UID", search_range)
|
||||||
except Exception as e:
|
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
|
return 0
|
||||||
|
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
logger.warning(
|
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
|
return 0
|
||||||
|
|
||||||
@@ -162,7 +201,7 @@ async def sync_new_messages(
|
|||||||
|
|
||||||
seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle]
|
seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle]
|
||||||
logger.info(
|
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
|
synced_count = 0
|
||||||
@@ -177,13 +216,16 @@ async def sync_new_messages(
|
|||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
db=db,
|
db=db,
|
||||||
redis_client=redis_client,
|
redis_client=redis_client,
|
||||||
|
imap_folder="INBOX",
|
||||||
|
direction="inbound",
|
||||||
|
state="received",
|
||||||
)
|
)
|
||||||
if synced and uid and uid > max_uid_synced:
|
if synced and uid and uid > max_uid_synced:
|
||||||
synced_count += 1
|
synced_count += 1
|
||||||
max_uid_synced = uid
|
max_uid_synced = uid
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[{mailbox.email_address}] Errore fetch seq {seq}: {e}",
|
f"[{mailbox.email_address}] Errore fetch INBOX seq {seq}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -197,6 +239,114 @@ async def sync_new_messages(
|
|||||||
return synced_count
|
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(
|
async def _fetch_and_save_message_by_seq(
|
||||||
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
||||||
seq: str,
|
seq: str,
|
||||||
@@ -204,10 +354,18 @@ async def _fetch_and_save_message_by_seq(
|
|||||||
mailbox: Mailbox,
|
mailbox: Mailbox,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
redis_client: aioredis.Redis,
|
redis_client: aioredis.Redis,
|
||||||
|
imap_folder: str = "INBOX",
|
||||||
|
direction: str = "inbound",
|
||||||
|
state: str = "received",
|
||||||
) -> tuple[int | None, bool]:
|
) -> tuple[int | None, bool]:
|
||||||
"""
|
"""
|
||||||
Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID).
|
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:
|
Returns:
|
||||||
(uid, saved): UID del messaggio e True se salvato, False altrimenti.
|
(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,
|
mailbox=mailbox,
|
||||||
db=db,
|
db=db,
|
||||||
redis_client=redis_client,
|
redis_client=redis_client,
|
||||||
|
imap_folder=imap_folder,
|
||||||
|
direction=direction,
|
||||||
|
state=state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,11 +440,13 @@ async def _fetch_and_save_message(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot).
|
Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot).
|
||||||
|
Sincronizza solo dalla cartella INBOX.
|
||||||
"""
|
"""
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
select(Message.id).where(
|
select(Message.id).where(
|
||||||
Message.mailbox_id == mailbox.id,
|
Message.mailbox_id == mailbox.id,
|
||||||
Message.imap_uid == uid,
|
Message.imap_uid == uid,
|
||||||
|
Message.imap_folder == "INBOX",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
@@ -319,6 +482,9 @@ async def _fetch_and_save_message(
|
|||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
db=db,
|
db=db,
|
||||||
redis_client=redis_client,
|
redis_client=redis_client,
|
||||||
|
imap_folder="INBOX",
|
||||||
|
direction="inbound",
|
||||||
|
state="received",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -331,26 +497,37 @@ async def _save_message(
|
|||||||
mailbox: Mailbox,
|
mailbox: Mailbox,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
redis_client: aioredis.Redis,
|
redis_client: aioredis.Redis,
|
||||||
|
imap_folder: str = "INBOX",
|
||||||
|
direction: str = "inbound",
|
||||||
|
state: str = "received",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Salva un messaggio EML in DB e su MinIO.
|
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:
|
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)
|
- Parser completo (body, allegati, EML-in-EML)
|
||||||
- Classificazione precisa tipo PEC (tutti i provider)
|
- Classificazione precisa tipo PEC (tutti i provider)
|
||||||
- Salvataggio allegati su MinIO + tabella attachments
|
- 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
|
- Collegamento parent_message_id via X-Riferimento-Message-ID
|
||||||
"""
|
"""
|
||||||
# ── Idempotenza ───────────────────────────────────────────────────────────
|
# ── Idempotenza: chiave composta (mailbox_id + imap_uid + imap_folder) ────
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
select(Message.id).where(
|
select(Message.id).where(
|
||||||
Message.mailbox_id == mailbox.id,
|
Message.mailbox_id == mailbox.id,
|
||||||
Message.imap_uid == uid,
|
Message.imap_uid == uid,
|
||||||
|
Message.imap_folder == imap_folder,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
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
|
return False
|
||||||
|
|
||||||
# ── Parsing completo EML ──────────────────────────────────────────────────
|
# ── Parsing completo EML ──────────────────────────────────────────────────
|
||||||
@@ -361,9 +538,10 @@ async def _save_message(
|
|||||||
received_at = datetime.now(UTC)
|
received_at = datetime.now(UTC)
|
||||||
|
|
||||||
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
|
# ── 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
|
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(
|
parent_message_id = await _apply_outbound_state_machine(
|
||||||
riferimento_message_id=pec_class.riferimento_message_id,
|
riferimento_message_id=pec_class.riferimento_message_id,
|
||||||
pec_type=pec_class.pec_type,
|
pec_type=pec_class.pec_type,
|
||||||
@@ -383,30 +561,36 @@ async def _save_message(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{mailbox.email_address}] Upload EML MinIO UID {uid}: {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 ─────────────────────────────────────────────────
|
# ── Salva messaggio in DB ─────────────────────────────────────────────────
|
||||||
message = Message(
|
message = Message(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
tenant_id=mailbox.tenant_id,
|
tenant_id=mailbox.tenant_id,
|
||||||
mailbox_id=mailbox.id,
|
mailbox_id=mailbox.id,
|
||||||
imap_uid=uid,
|
imap_uid=uid,
|
||||||
imap_folder="INBOX",
|
imap_folder=imap_folder,
|
||||||
direction="inbound",
|
direction=direction,
|
||||||
state="received",
|
state=state,
|
||||||
pec_type=pec_class.pec_type,
|
pec_type=pec_class.pec_type,
|
||||||
subject=parsed.subject,
|
subject=parsed.subject,
|
||||||
from_address=parsed.from_address,
|
from_address=parsed.from_address,
|
||||||
to_addresses=parsed.to_addresses if parsed.to_addresses else None,
|
to_addresses=parsed.to_addresses if parsed.to_addresses else None,
|
||||||
cc_addresses=parsed.cc_addresses if parsed.cc_addresses else None,
|
cc_addresses=parsed.cc_addresses if parsed.cc_addresses else None,
|
||||||
message_id_header=parsed.message_id,
|
message_id_header=parsed.message_id,
|
||||||
sent_at=parsed.date,
|
sent_at=msg_sent_at,
|
||||||
received_at=received_at,
|
received_at=msg_received_at,
|
||||||
size_bytes=size_bytes,
|
size_bytes=size_bytes,
|
||||||
body_text=parsed.body_text,
|
body_text=parsed.body_text,
|
||||||
body_html=parsed.body_html,
|
body_html=parsed.body_html,
|
||||||
has_attachments=parsed.has_attachments,
|
has_attachments=parsed.has_attachments,
|
||||||
parent_message_id=parent_message_id,
|
parent_message_id=parent_message_id,
|
||||||
raw_eml_path=eml_path,
|
raw_eml_path=eml_path,
|
||||||
is_read=False,
|
# Messaggi outbound (Sent) sono già stati letti dal mittente
|
||||||
|
is_read=(direction == "outbound"),
|
||||||
)
|
)
|
||||||
db.add(message)
|
db.add(message)
|
||||||
await db.flush() # ottieni message.id prima di salvare gli allegati
|
await db.flush() # ottieni message.id prima di salvare gli allegati
|
||||||
@@ -429,6 +613,7 @@ async def _save_message(
|
|||||||
"subject": message.subject or "",
|
"subject": message.subject or "",
|
||||||
"from_address": message.from_address or "",
|
"from_address": message.from_address or "",
|
||||||
"pec_type": message.pec_type,
|
"pec_type": message.pec_type,
|
||||||
|
"direction": direction,
|
||||||
"is_receipt": pec_class.is_receipt,
|
"is_receipt": pec_class.is_receipt,
|
||||||
"received_at": received_at.isoformat(),
|
"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.warning(f"[{mailbox.email_address}] Redis publish UID {uid}: {e}")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} "
|
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} folder={imap_folder!r} "
|
||||||
f"pec_type={pec_class.pec_type!r} "
|
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
|
||||||
f"subject={message.subject!r} "
|
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
|
||||||
f"allegati={len(parsed.attachments)}"
|
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -506,6 +690,11 @@ async def _save_attachments(
|
|||||||
db: sessione DB
|
db: sessione DB
|
||||||
"""
|
"""
|
||||||
for att in attachments:
|
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
|
storage_path: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class Mailbox(Base):
|
|||||||
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
|
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_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, 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_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|||||||
@@ -296,8 +296,8 @@ def _extract_eml_in_eml(
|
|||||||
"""
|
"""
|
||||||
Estrae il messaggio EML annidato in un part message/rfc822.
|
Estrae il messaggio EML annidato in un part message/rfc822.
|
||||||
|
|
||||||
Nella struttura PEC, questo è tipicamente il messaggio originale
|
Per postacert.eml (busta PEC in arrivo): ricorre dentro per estrarre
|
||||||
allegato alle ricevute di consegna/accettazione.
|
gli allegati utente e il corpo del messaggio originale del mittente.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
payload = part.get_payload()
|
payload = part.get_payload()
|
||||||
@@ -330,6 +330,20 @@ def _extract_eml_in_eml(
|
|||||||
)
|
)
|
||||||
result.attachments.append(att)
|
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:
|
except Exception as exc:
|
||||||
logger.warning(f"Errore estrazione EML-in-EML: {exc}")
|
logger.warning(f"Errore estrazione EML-in-EML: {exc}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user