diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index 864d6bc..4935cdc 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -6,6 +6,8 @@ Ho docker installato, compose v2 (docker cmpose senza trattino) Non fare commit sul repository GitHub, ci penso io +Non effettuare test da Browser, ci penso io + Queste le caselle PEC e i loro parametri IMAP/SMTP che puoi usare per test, non effettuare invii per adesso Casella: gmgspa@pec.it diff --git a/backend/alembic/versions/0002_add_sent_sync_uid.py b/backend/alembic/versions/0002_add_sent_sync_uid.py new file mode 100644 index 0000000..d4f323b --- /dev/null +++ b/backend/alembic/versions/0002_add_sent_sync_uid.py @@ -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 + """) diff --git a/backend/alembic/versions/0003_virtual_boxes_notifications.py b/backend/alembic/versions/0003_virtual_boxes_notifications.py new file mode 100644 index 0000000..0071ada --- /dev/null +++ b/backend/alembic/versions/0003_virtual_boxes_notifications.py @@ -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") diff --git a/backend/alembic/versions/0004_vbox_mailbox_association.py b/backend/alembic/versions/0004_vbox_mailbox_association.py new file mode 100644 index 0000000..87b79b5 --- /dev/null +++ b/backend/alembic/versions/0004_vbox_mailbox_association.py @@ -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") diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 90398e1..f1d5687 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -31,6 +31,8 @@ from app.dependencies import CurrentUser, DB from app.models.message import Attachment, Message from app.schemas.message import ( AttachmentResponse, + MessageBulkUpdateRequest, + MessageBulkUpdateResponse, MessageListResponse, MessageResponse, MessageUpdateRequest, @@ -42,6 +44,49 @@ settings = get_settings() # ─── Helpers ────────────────────────────────────────────────────────────────── +def _apply_vbox_rule(q, field: str, operator: str, value: str): + """ + Applica una singola regola di Virtual Box alla query SQLAlchemy. + + field : subject | from_address | to_address | imap_folder + operator : contains | equals | starts_with | ends_with | regex + """ + if field == "subject": + col = Message.subject + elif field == "from_address": + col = Message.from_address + elif field == "to_address": + # to_addresses è ARRAY(Text) – converte in stringa per il confronto + arr_text = func.array_to_string(Message.to_addresses, ",") + if operator == "contains": + return q.where(arr_text.ilike(f"%{value}%")) + elif operator == "equals": + return q.where(arr_text.ilike(value)) + elif operator == "starts_with": + return q.where(arr_text.ilike(f"{value}%")) + elif operator == "ends_with": + return q.where(arr_text.ilike(f"%{value}")) + elif operator == "regex": + return q.where(arr_text.op("~*")(value)) + return q + elif field == "imap_folder": + col = Message.imap_folder + else: + return q # campo non supportato – ignorato + + if operator == "contains": + return q.where(col.ilike(f"%{value}%")) + elif operator == "equals": + return q.where(func.lower(col) == value.lower()) + elif operator == "starts_with": + return q.where(col.ilike(f"{value}%")) + elif operator == "ends_with": + return q.where(col.ilike(f"%{value}")) + elif operator == "regex": + return q.where(col.op("~*")(value)) + return q + + async def _get_visible_mailbox_ids( user, db: AsyncSession ) -> Optional[list[uuid.UUID]]: @@ -89,6 +134,7 @@ async def list_messages( current_user: CurrentUser, db: DB, # Filtri + vbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per Virtual Box assegnata"), mailbox_id: Optional[uuid.UUID] = Query(None), direction: Optional[str] = Query(None, pattern="^(inbound|outbound)$"), state: Optional[str] = Query(None), @@ -106,17 +152,61 @@ async def list_messages( - `is_archived=False` (default) esclude i messaggi archiviati. - `search` cerca su subject, from_address, to_addresses. + - `vbox_id` filtra per Virtual Box assegnata all'utente corrente. """ - # Determinare le caselle visibili + # Determinare le caselle visibili (normale check permessi) visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db) + # ── Filtro Virtual Box ──────────────────────────────────────────────────── + vbox_rules: list = [] + if vbox_id is not None: + from sqlalchemy.orm import selectinload + from app.models.virtual_box import VirtualBox, VirtualBoxAssignment + + vbox_result = await db.execute( + select(VirtualBox) + .where( + VirtualBox.id == vbox_id, + VirtualBox.tenant_id == current_user.tenant_id, + VirtualBox.is_active == True, + ) + .options( + selectinload(VirtualBox.rules), + selectinload(VirtualBox.mailboxes), + ) + ) + vbox = vbox_result.scalar_one_or_none() + if not vbox: + raise NotFoundError("Virtual Box") + + # Non-admin: verifica che l'utente sia assegnato alla VBox + if not current_user.is_admin: + assign_result = await db.execute( + select(VirtualBoxAssignment).where( + VirtualBoxAssignment.virtual_box_id == vbox_id, + VirtualBoxAssignment.user_id == current_user.id, + ) + ) + if not assign_result.scalar_one_or_none(): + raise ForbiddenError("Virtual Box non accessibile") + + # L'assegnazione alla VBox garantisce accesso alle sue caselle: + # sovrascrive il filtro permessi normali per questa query. + if vbox.mailboxes: + visible_mailbox_ids = [m.id for m in vbox.mailboxes] + # Se la VBox non ha caselle esplicitamente associate, + # si mantiene il filtro permessi normale (visible_mailbox_ids invariato). + + vbox_rules = vbox.rules or [] + # ───────────────────────────────────────────────────────────────────────── + # Query base q = select(Message).where( Message.tenant_id == current_user.tenant_id, Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio) ) - # Filtro caselle visibili per non-admin + # Filtro caselle visibili per non-admin (o dopo override VBox) if visible_mailbox_ids is not None: if not visible_mailbox_ids: # Nessuna casella accessibile → lista vuota @@ -158,6 +248,10 @@ async def list_messages( ) ) + # Applica le regole della Virtual Box (AND tra le regole) + for rule in vbox_rules: + q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value) + # Conteggio totale count_q = select(func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar_one() @@ -183,6 +277,59 @@ async def list_messages( ) +@router.patch("/bulk", response_model=MessageBulkUpdateResponse) +async def bulk_update_messages( + data: MessageBulkUpdateRequest, + current_user: CurrentUser, + db: DB, +) -> MessageBulkUpdateResponse: + """ + Aggiorna in blocco i flag operativi (is_starred, is_archived) di più messaggi. + + Restituisce il numero di messaggi aggiornati e la lista aggiornata. + I messaggi non trovati o non accessibili vengono silenziosamente ignorati. + """ + if not data.ids: + return MessageBulkUpdateResponse(updated=0, items=[]) + + # Carica tutti i messaggi del tenant + result = await db.execute( + select(Message).where( + Message.id.in_(data.ids), + Message.tenant_id == current_user.tenant_id, + ) + ) + messages = list(result.scalars().all()) + + # Filtra per permessi se non admin + if not current_user.is_admin: + from app.services.permission_service import PermissionService + perm_svc = PermissionService(db) + visible = await perm_svc.get_visible_mailboxes(current_user) + visible_set = set(visible) if visible else set() + messages = [m for m in messages if m.mailbox_id in visible_set] + + now = datetime.now(timezone.utc) + for message in messages: + if data.is_starred is not None: + message.is_starred = data.is_starred + if data.is_archived is not None: + message.is_archived = data.is_archived + if data.is_archived and not message.archived_at: + message.archived_at = now + elif not data.is_archived: + message.archived_at = None + + await db.commit() + for message in messages: + await db.refresh(message) + + return MessageBulkUpdateResponse( + updated=len(messages), + items=[MessageResponse.model_validate(m) for m in messages], + ) + + @router.get("/{message_id}", response_model=MessageResponse) async def get_message( message_id: uuid.UUID, diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py new file mode 100644 index 0000000..459f7d5 --- /dev/null +++ b/backend/app/api/v1/notifications.py @@ -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, + ) diff --git a/backend/app/api/v1/virtual_boxes.py b/backend/app/api/v1/virtual_boxes.py new file mode 100644 index 0000000..db81449 --- /dev/null +++ b/backend/app/api/v1/virtual_boxes.py @@ -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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index cc60ecf..8c373cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from slowapi.util import get_remote_address -from app.api.v1 import auth, mailboxes, messages, permissions, send, tenants, users, ws +from app.api.v1 import auth, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws from app.config import get_settings from app.core.logging import get_logger, setup_logging from app.database import engine @@ -91,6 +91,8 @@ app.include_router(mailboxes.router, prefix=API_PREFIX) app.include_router(messages.router, prefix=API_PREFIX) app.include_router(send.router, prefix=API_PREFIX) app.include_router(ws.router, prefix=API_PREFIX) +app.include_router(virtual_boxes.router, prefix=API_PREFIX) +app.include_router(notifications.router, prefix=API_PREFIX) # ─── Health check ───────────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2918c02..5e2c236 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,3 +7,5 @@ from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip from app.models.audit_log import AuditLog # noqa: F401 from app.models.label import Label, MessageLabel # noqa: F401 from app.models.permission import MailboxPermission # noqa: F401 +from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401 +from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401 diff --git a/backend/app/models/mailbox.py b/backend/app/models/mailbox.py index 5f871db..e05cf32 100644 --- a/backend/app/models/mailbox.py +++ b/backend/app/models/mailbox.py @@ -61,6 +61,7 @@ class Mailbox(Base): status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active") last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + sent_last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True) sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True) sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..97fc29b --- /dev/null +++ b/backend/app/models/notification.py @@ -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"" + + +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"" + + +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"" diff --git a/backend/app/models/virtual_box.py b/backend/app/models/virtual_box.py new file mode 100644 index 0000000..b035a93 --- /dev/null +++ b/backend/app/models/virtual_box.py @@ -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"" + + +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"" + + +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"" diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index f86ea4d..d5e288c 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -81,3 +81,14 @@ class MessageUpdateRequest(BaseModel): is_read: Optional[bool] = None is_starred: Optional[bool] = None is_archived: Optional[bool] = None + + +class MessageBulkUpdateRequest(BaseModel): + ids: list[uuid.UUID] + is_starred: Optional[bool] = None + is_archived: Optional[bool] = None + + +class MessageBulkUpdateResponse(BaseModel): + updated: int + items: list[MessageResponse] diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..312bc22 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/schemas/virtual_box.py b/backend/app/schemas/virtual_box.py new file mode 100644 index 0000000..7e63d58 --- /dev/null +++ b/backend/app/schemas/virtual_box.py @@ -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", + ) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..2f9c9ba --- /dev/null +++ b/backend/app/services/notification_service.py @@ -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 diff --git a/backend/app/services/virtual_box_service.py b/backend/app/services/virtual_box_service.py new file mode 100644 index 0000000..0a314c2 --- /dev/null +++ b/backend/app/services/virtual_box_service.py @@ -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()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1824698..26d5800 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,44 +7,71 @@ import { ComposePage } from '@/pages/Compose/ComposePage' import { MailboxesPage } from '@/pages/Mailboxes/MailboxesPage' import { UsersPage } from '@/pages/Users/UsersPage' import { PermissionsPage } from '@/pages/Permissions/PermissionsPage' +import { SettingsPage } from '@/pages/Settings/SettingsPage' +import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage' +import { NotificationsPage } from '@/pages/Notifications/NotificationsPage' /** * Routing principale dell'applicazione PecFlow. * * Struttura: - * - /login → LoginPage (pubblica) + * - /login → LoginPage (pubblica) * - /* → AppLayout (richiede autenticazione) - * - /inbox → InboxPage - * - /sent → InboxPage (filtrata su outbound) - * - /messages/:id → MessageDetailPage - * - /compose → ComposePage - * - /mailboxes → MailboxesPage (admin) - * - /users → UsersPage (admin) - * - /permissions → PermissionsPage (admin) - * - / → redirect a /inbox + * - /inbox → Posta in arrivo (tutte le caselle) + * - /sent → Posta inviata (tutte le caselle) + * - /starred → Preferiti (tutte le caselle) + * - /archived → Archiviati (tutte le caselle) + * - /mailbox/:mailboxId/inbox → Posta in arrivo di una specifica casella + * - /mailbox/:mailboxId/sent → Posta inviata di una specifica casella + * - /mailbox/:mailboxId/starred → Preferiti di una specifica casella + * - /mailbox/:mailboxId/archived → Archiviati di una specifica casella + * - /messages/:id → Dettaglio messaggio + * - /compose → Nuova PEC + * - /mailboxes → Gestione caselle (admin) + * - /users → Gestione utenti (admin) + * - /permissions → Gestione permessi (admin) */ export default function App() { return ( - {/* Pagine pubbliche */} + {/* Pagina pubblica */} } /> {/* Pagine protette (dentro AppLayout) */} }> } /> - } /> - } - /> + + {/* Vista globale: tutte le caselle insieme */} + } /> + } /> + } /> + } /> + + {/* Vista per singola casella PEC */} + } /> + } /> + } /> + } /> + + {/* Vista per Virtual Box assegnata */} + } /> + } /> + } /> + } /> + } /> } /> {/* Pagine admin */} - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + + {/* Profilo utente */} + } /> {/* Fallback */} } /> diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index cd57b52..da978e7 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -8,6 +8,8 @@ import type { export interface MessageFilters { page?: number page_size?: number + /** Filtra per Virtual Box assegnata all'utente corrente */ + vbox_id?: string mailbox_id?: string direction?: 'inbound' | 'outbound' state?: string @@ -17,6 +19,17 @@ export interface MessageFilters { search?: string } +export interface MessageBulkUpdatePayload { + ids: string[] + is_starred?: boolean + is_archived?: boolean +} + +export interface MessageBulkUpdateResponse { + updated: number + items: MessageResponse[] +} + export const messagesApi = { list: (filters: MessageFilters = {}) => apiClient @@ -42,6 +55,17 @@ export const messagesApi = { .patch(`/messages/${id}`, { is_archived: true }) .then((r) => r.data), + unarchive: (id: string) => + apiClient + .patch(`/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('/messages/bulk', payload) + .then((r) => r.data), + getAttachments: (id: string) => apiClient.get(`/messages/${id}/attachments`).then((r) => r.data), diff --git a/frontend/src/api/notifications.api.ts b/frontend/src/api/notifications.api.ts new file mode 100644 index 0000000..9552c30 --- /dev/null +++ b/frontend/src/api/notifications.api.ts @@ -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('/notifications/channels', data) + .then((r) => r.data), + + /** Lista canali. */ + listChannels: (params?: { page?: number; page_size?: number }) => + apiClient + .get('/notifications/channels', { params }) + .then((r) => r.data), + + /** Dettaglio canale. */ + getChannel: (id: string) => + apiClient + .get(`/notifications/channels/${id}`) + .then((r) => r.data), + + /** Aggiorna canale. */ + updateChannel: (id: string, data: NotificationChannelUpdate) => + apiClient + .patch(`/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(`/notifications/channels/${id}/test`) + .then((r) => r.data), + + // ── Rules ────────────────────────────────────────────────────────────────── + + /** Crea una regola. */ + createRule: (data: NotificationRuleCreate) => + apiClient + .post('/notifications/rules', data) + .then((r) => r.data), + + /** Lista regole. */ + listRules: (params?: { channel_id?: string; page?: number; page_size?: number }) => + apiClient + .get('/notifications/rules', { params }) + .then((r) => r.data), + + /** Aggiorna regola. */ + updateRule: (id: string, data: NotificationRuleUpdate) => + apiClient + .patch(`/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('/notifications/logs', { params }) + .then((r) => r.data), +} diff --git a/frontend/src/api/virtual_boxes.api.ts b/frontend/src/api/virtual_boxes.api.ts new file mode 100644 index 0000000..994ec1c --- /dev/null +++ b/frontend/src/api/virtual_boxes.api.ts @@ -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('/virtual-boxes', data).then((r) => r.data), + + /** Lista Virtual Box del tenant. */ + list: (params?: { page?: number; page_size?: number; active_only?: boolean }) => + apiClient + .get('/virtual-boxes', { params }) + .then((r) => r.data), + + /** Virtual Box assegnate all'utente corrente. */ + myVirtualBoxes: () => + apiClient.get('/virtual-boxes/my').then((r) => r.data), + + /** Dettaglio Virtual Box. */ + get: (id: string) => + apiClient.get(`/virtual-boxes/${id}`).then((r) => r.data), + + /** Aggiorna Virtual Box (incluse caselle se fornito mailbox_ids). */ + update: (id: string, data: VirtualBoxUpdate) => + apiClient.patch(`/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(`/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(`/virtual-boxes/${id}/mailboxes`, data) + .then((r) => r.data), + + /** Lista caselle PEC reali associate. */ + listMailboxes: (id: string) => + apiClient + .get(`/virtual-boxes/${id}/mailboxes`) + .then((r) => r.data), + + // ─── Assegnazioni utenti ─────────────────────────────────────────────────── + + /** Assegna utenti a una Virtual Box. */ + assignUsers: (id: string, data: VirtualBoxAssignRequest) => + apiClient + .post(`/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(`/virtual-boxes/${id}/assignments`) + .then((r) => r.data), +} diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 5319fee..d3e7ead 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -1,3 +1,37 @@ +/** + * Sidebar – navigazione principale di PecFlow. + * + * Struttura visiva (sidebar espansa): + * ┌────────────────────────────────┐ + * │ [PF] PecFlow [◀] │ + * ├────────────────────────────────┤ + * │ TUTTE LE CASELLE │ + * │ 📥 Posta in Arrivo [badge] │ + * │ 📤 Posta Inviata │ + * │ ⭐ Preferiti │ + * │ 📦 Archiviati │ + * ├────────────────────────────────┤ + * │ LE TUE CASELLE │ + * │ ● gmgspa@pec.it [▼] │ + * │ ├ 📥 In Arrivo │ + * │ ├ 📤 Inviata │ + * │ ├ ⭐ Preferiti │ + * │ └ 📦 Archiviati │ + * ├────────────────────────────────┤ + * │ ✉ Nuova PEC │ + * ├────────────────────────────────┤ + * │ AMMINISTRAZIONE │ + * │ 📬 Caselle PEC │ + * │ 👥 Utenti │ + * │ 🛡 Permessi │ + * ├────────────────────────────────┤ + * │ [avatar] Nome utente │ + * │ Impostazioni | Esci │ + * └────────────────────────────────┘ + * + * Quando collassata (w-16) mostra solo icone/avatar con tooltip. + */ + import { NavLink } from 'react-router-dom' import { Inbox, @@ -9,38 +43,80 @@ import { ChevronLeft, ChevronRight, Shield, + ChevronDown, + Filter, + Bell, + Star, + Archive, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' import { useInboxStore } from '@/store/inbox.store' import { useState } from 'react' import toast from 'react-hot-toast' +import { useQuery } from '@tanstack/react-query' +import { mailboxesApi } from '@/api/mailboxes.api' +import { virtualBoxesApi } from '@/api/virtual_boxes.api' +import type { MailboxResponse, VirtualBoxResponse } from '@/types/api.types' -interface NavItem { - to: string - label: string - icon: React.ElementType - adminOnly?: boolean - badge?: number -} - -const NAV_ITEMS: NavItem[] = [ - { to: '/inbox', label: 'Posta in Arrivo', icon: Inbox }, - { to: '/sent', label: 'Posta Inviata', icon: Send }, - { to: '/compose', label: 'Nuova PEC', icon: MailCheck }, -] - -const ADMIN_NAV_ITEMS: NavItem[] = [ - { to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck, adminOnly: true }, - { to: '/users', label: 'Utenti', icon: Users, adminOnly: true }, - { to: '/permissions', label: 'Permessi', icon: Shield, adminOnly: true }, -] +// ─── Sidebar principale ─────────────────────────────────────────────────────── export function Sidebar() { const [collapsed, setCollapsed] = useState(false) + /** + * Set degli ID casella che l'utente ha esplicitamente chiuso. + * Tutte le caselle sono espanse per default (nessuno nell'insieme). + */ + const [collapsedMailboxes, setCollapsedMailboxes] = useState>(new Set()) + /** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */ + const [collapsedVboxes, setCollapsedVboxes] = useState>(new Set()) + const { user, isAdmin, logout } = useAuth() const unreadCount = useInboxStore((s) => s.unreadCount) + // Le caselle PEC vengono caricate qui e condivise via React Query cache + const { data: mailboxesData } = useQuery({ + queryKey: ['mailboxes'], + queryFn: () => mailboxesApi.list(), + staleTime: 5 * 60 * 1000, + }) + const mailboxes = mailboxesData?.items ?? [] + + // Virtual Box assegnate all'utente corrente + const { data: myVboxes = [] } = useQuery({ + queryKey: ['virtual-boxes', 'my'], + queryFn: () => virtualBoxesApi.myVirtualBoxes(), + staleTime: 5 * 60 * 1000, + }) + + const isMailboxExpanded = (id: string) => !collapsedMailboxes.has(id) + + const toggleMailbox = (id: string) => { + setCollapsedMailboxes((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const isVboxExpanded = (id: string) => !collapsedVboxes.has(id) + + const toggleVbox = (id: string) => { + setCollapsedVboxes((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + const handleLogout = async () => { try { await logout() @@ -53,15 +129,15 @@ export function Sidebar() { return (