diff --git a/backend/alembic/versions/0015_add_signatures.py b/backend/alembic/versions/0015_add_signatures.py new file mode 100644 index 0000000..0ededbe --- /dev/null +++ b/backend/alembic/versions/0015_add_signatures.py @@ -0,0 +1,107 @@ +""" +Migrazione 0015: tabelle signatures e signature_assignments. +Gestione firme automatiche per caselle PEC e Virtual Box. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0015" +down_revision = "0014" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ─── Tabella signatures ──────────────────────────────────────────────────── + op.create_table( + "signatures", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("body_html", sa.Text, nullable=True), + sa.Column("body_text", sa.Text, nullable=True), + sa.Column( + "created_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.UniqueConstraint("tenant_id", "name", name="uq_signature_name_tenant"), + ) + op.create_index("idx_signatures_tenant", "signatures", ["tenant_id"]) + + # ─── Tabella signature_assignments ───────────────────────────────────────── + op.create_table( + "signature_assignments", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "signature_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("signatures.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "mailbox_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("mailboxes.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column( + "virtual_box_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("virtual_boxes.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column("context", sa.String(20), nullable=False, server_default="both"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "(mailbox_id IS NOT NULL)::int + (virtual_box_id IS NOT NULL)::int = 1", + name="ck_sig_assignment_target", + ), + sa.UniqueConstraint("mailbox_id", "context", name="uq_sig_mailbox_context"), + sa.UniqueConstraint("virtual_box_id", "context", name="uq_sig_vbox_context"), + ) + op.create_index("idx_sig_assign_tenant", "signature_assignments", ["tenant_id"]) + op.create_index("idx_sig_assign_mailbox", "signature_assignments", ["mailbox_id"]) + op.create_index("idx_sig_assign_vbox", "signature_assignments", ["virtual_box_id"]) + + +def downgrade() -> None: + op.drop_index("idx_sig_assign_vbox", table_name="signature_assignments") + op.drop_index("idx_sig_assign_mailbox", table_name="signature_assignments") + op.drop_index("idx_sig_assign_tenant", table_name="signature_assignments") + op.drop_table("signature_assignments") + + op.drop_index("idx_signatures_tenant", table_name="signatures") + op.drop_table("signatures") diff --git a/backend/app/api/v1/audit_log.py b/backend/app/api/v1/audit_log.py index c3d9244..220c375 100644 --- a/backend/app/api/v1/audit_log.py +++ b/backend/app/api/v1/audit_log.py @@ -1,8 +1,9 @@ """ -Router Audit Log – consultazione degli eventi di sistema. +Router Audit Log – consultazione ed esportazione degli eventi di sistema. Endpoint: - GET /api/v1/audit-log – lista paginata con filtri (solo admin/super_admin) + GET /api/v1/audit-log – lista paginata con filtri (solo admin/super_admin) + GET /api/v1/audit-log/export – esportazione CSV o PDF (solo admin/super_admin) Permessi: - admin: vede solo gli eventi del proprio tenant @@ -14,6 +15,7 @@ from datetime import datetime from typing import Optional from fastapi import APIRouter, Query +from fastapi.responses import Response, StreamingResponse from app.dependencies import AdminUser, DB from app.schemas.audit_log import AuditLogListResponse @@ -22,6 +24,13 @@ from app.services.audit_service import AuditService router = APIRouter(prefix="/audit-log", tags=["Audit Log"]) +def _effective_tenant_id(current_user, tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]: + """Determina il tenant_id effettivo in base al ruolo dell'utente.""" + if current_user.is_super_admin: + return tenant_id # None = tutti i tenant + return current_user.tenant_id # sempre vincolato al proprio tenant + + @router.get("", response_model=AuditLogListResponse) async def list_audit_log( current_user: AdminUser, @@ -36,24 +45,10 @@ async def list_audit_log( resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"), tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"), ) -> AuditLogListResponse: - """ - Restituisce la lista paginata degli eventi di audit. - - - Admin: vede solo gli eventi del proprio tenant (tenant_id ignorato). - - Super Admin: vede tutti i tenant, filtrabile per tenant_id. - """ + """Restituisce la lista paginata degli eventi di audit.""" svc = AuditService(db) - - # Determina il tenant_id effettivo da applicare al filtro - if current_user.is_super_admin: - # Super admin: usa il tenant_id passato come filtro (None = tutti) - effective_tenant_id = tenant_id - else: - # Admin normale: sempre vincolato al proprio tenant - effective_tenant_id = current_user.tenant_id - return await svc.list( - tenant_id=effective_tenant_id, + tenant_id=_effective_tenant_id(current_user, tenant_id), page=page, page_size=page_size, action=action, @@ -63,3 +58,65 @@ async def list_audit_log( date_to=date_to, resource_type=resource_type, ) + + +@router.get("/export") +async def export_audit_log( + current_user: AdminUser, + db: DB, + format: str = Query("csv", pattern="^(csv|pdf)$", description="Formato: csv o pdf"), + action: Optional[str] = Query(None), + user_id: Optional[uuid.UUID] = Query(None), + outcome: Optional[str] = Query(None, pattern="^(success|failure)$"), + date_from: Optional[datetime] = Query(None), + date_to: Optional[datetime] = Query(None), + resource_type: Optional[str] = Query(None), + tenant_id: Optional[uuid.UUID] = Query(None), +) -> Response: + """ + Esporta i log di audit in formato CSV o PDF. + Applica gli stessi filtri dell'endpoint lista. + """ + svc = AuditService(db) + effective_tid = _effective_tenant_id(current_user, tenant_id) + + # Nome file con periodo + suffix = "" + if date_from: + suffix += f"_dal_{date_from.strftime('%Y%m%d')}" + if date_to: + suffix += f"_al_{date_to.strftime('%Y%m%d')}" + + if format == "csv": + csv_content = await svc.export_csv( + tenant_id=effective_tid, + action=action, + user_id=user_id, + outcome=outcome, + date_from=date_from, + date_to=date_to, + resource_type=resource_type, + ) + filename = f"audit_log{suffix}.csv" + return Response( + content=csv_content.encode("utf-8-sig"), # BOM per compatibilita' Excel + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + else: # pdf + pdf_bytes = await svc.export_pdf_bytes( + tenant_id=effective_tid, + action=action, + user_id=user_id, + outcome=outcome, + date_from=date_from, + date_to=date_to, + resource_type=resource_type, + ) + filename = f"audit_log{suffix}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 1ac2146..6a333db 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -33,6 +33,7 @@ from app.schemas.auth import ( TokenResponse, ) from app.schemas.user import UserResponse +from app.services.audit_service import get_real_ip from app.services.auth_service import AuthService settings = get_settings() @@ -51,7 +52,7 @@ async def login( body: LoginRequest, db: DB, ) -> TokenResponse: - ip = request.client.host if request.client else None + ip = get_real_ip(request) ua = request.headers.get("user-agent") service = AuthService(db) @@ -177,7 +178,7 @@ async def change_password( tenant_id=current_user.tenant_id, user_id=current_user.id, outcome="failure", - ip_address=request.client.host if request.client else None, + ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), payload={"reason": "wrong_current_password"}, ) @@ -189,6 +190,6 @@ async def change_password( "auth.password_changed", tenant_id=current_user.tenant_id, user_id=current_user.id, - ip_address=request.client.host if request.client else None, + ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), ) diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 36370dd..90fd74a 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -23,12 +23,13 @@ import uuid from datetime import datetime, timezone from typing import Optional -from fastapi import APIRouter, Query, status +from fastapi import APIRouter, Query, Request, status from fastapi.responses import StreamingResponse from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.services.audit_service import get_real_ip from app.services.search_service import SearchService from app.config import get_settings @@ -38,12 +39,14 @@ from app.dependencies import CurrentUser, DB from app.models.label import Label from app.models.message import Attachment, Message from app.schemas.message import ( + AttachmentMatchInfo, AttachmentResponse, MessageBulkUpdateRequest, MessageBulkUpdateResponse, MessageListResponse, MessageResponse, MessageUpdateRequest, + SearchMatchInfo, ) router = APIRouter(prefix="/messages", tags=["Messages"]) @@ -330,8 +333,45 @@ async def list_messages( result = await db.execute(q) items = list(result.scalars().all()) + # ── Popola search_match per i risultati di ricerca ──────────────────────── + if search and items: + term_lower = search.lower() + msg_ids = [m.id for m in items] + term_like = f"%{search}%" + + # Query batch: allegati con extracted_text che matcha il termine + att_result = await db.execute( + select(Attachment.id, Attachment.message_id, Attachment.filename) + .where( + Attachment.message_id.in_(msg_ids), + Attachment.extracted_text.ilike(term_like), + ) + ) + # Mappa message_id → lista di AttachmentMatchInfo che matchano + att_matches: dict[uuid.UUID, list[AttachmentMatchInfo]] = {} + for row in att_result.fetchall(): + att_id, msg_id, filename = row + att_matches.setdefault(msg_id, []).append( + AttachmentMatchInfo(id=att_id, filename=filename) + ) + + message_responses: list[MessageResponse] = [] + for m in items: + resp = MessageResponse.model_validate(m) + in_subject = bool(m.subject and term_lower in m.subject.lower()) + in_body = bool(m.body_text and term_lower in m.body_text.lower()) + in_attachments = att_matches.get(m.id, []) + resp.search_match = SearchMatchInfo( + in_subject=in_subject, + in_body=in_body, + in_attachments=in_attachments, + ) + message_responses.append(resp) + else: + message_responses = [MessageResponse.model_validate(m) for m in items] + return MessageListResponse( - items=[MessageResponse.model_validate(m) for m in items], + items=message_responses, total=total, page=page, page_size=page_size, @@ -340,6 +380,7 @@ async def list_messages( @router.patch("/bulk", response_model=MessageBulkUpdateResponse) async def bulk_update_messages( + request: Request, data: MessageBulkUpdateRequest, current_user: CurrentUser, db: DB, @@ -349,6 +390,8 @@ async def bulk_update_messages( Per is_pending_conservation=True o is_conserved=True richiede can_conserve. """ + from app.services.audit_service import log_audit + if not data.ids: return MessageBulkUpdateResponse(updated=0, items=[]) @@ -415,6 +458,29 @@ async def bulk_update_messages( elif not data.is_conserved: message.conserved_at = None + # Registra evento audit bulk + if messages: + changes: dict = {} + for field in ("is_read", "is_starred", "is_archived", "is_trashed", + "is_pending_conservation", "is_conserved"): + v = getattr(data, field, None) + if v is not None: + changes[field] = v + await log_audit( + db, + "message.bulk_updated", + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="message", + ip_address=get_real_ip(request), + user_agent=request.headers.get("user-agent"), + payload={ + "count": len(messages), + "message_ids": [str(m.id) for m in messages], + "changes": changes, + }, + ) + await db.commit() if messages: @@ -434,17 +500,37 @@ async def bulk_update_messages( @router.get("/{message_id}", response_model=MessageResponse) async def get_message( + request: Request, message_id: uuid.UUID, current_user: CurrentUser, db: DB, ) -> MessageResponse: """Carica un messaggio per ID.""" + from app.services.audit_service import log_audit message = await _resolve_message(message_id, current_user, db) + await log_audit( + db, + "message.opened", + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="message", + resource_id=message.id, + ip_address=get_real_ip(request), + user_agent=request.headers.get("user-agent"), + payload={ + "subject": message.subject, + "from_address": message.from_address, + "direction": message.direction, + "mailbox_id": str(message.mailbox_id), + }, + ) + await db.commit() return MessageResponse.model_validate(message) @router.patch("/{message_id}", response_model=MessageResponse) async def update_message( + request: Request, message_id: uuid.UUID, data: MessageUpdateRequest, current_user: CurrentUser, @@ -455,6 +541,8 @@ async def update_message( Per is_pending_conservation=True o is_conserved=True richiede can_conserve. """ + from app.services.audit_service import log_audit + message = await _resolve_message(message_id, current_user, db) # Verifica permesso conservazione se necessario @@ -464,6 +552,19 @@ async def update_message( await perm_svc.require_can_conserve(current_user, message.mailbox_id) now = datetime.now(timezone.utc) + ip = get_real_ip(request) + ua = request.headers.get("user-agent") + base_payload = {"subject": message.subject, "mailbox_id": str(message.mailbox_id)} + + # Mappa flag → coppia (action_true, action_false) + _FLAG_ACTIONS: dict[str, tuple[str, str]] = { + "is_read": ("message.read", "message.unread"), + "is_starred": ("message.starred", "message.unstarred"), + "is_archived": ("message.archived", "message.unarchived"), + "is_trashed": ("message.trashed", "message.restored"), + "is_pending_conservation": ("message.pending_conservation","message.conservation_cancelled"), + "is_conserved": ("message.conserved", "message.conservation_removed"), + } if data.is_read is not None: message.is_read = data.is_read @@ -494,6 +595,23 @@ async def update_message( elif not data.is_conserved: message.conserved_at = None + # Registra un evento di audit per ogni flag modificato + for field, (action_true, action_false) in _FLAG_ACTIONS.items(): + value = getattr(data, field, None) + if value is not None: + action = action_true if value else action_false + await log_audit( + db, + action, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="message", + resource_id=message_id, + ip_address=ip, + user_agent=ua, + payload=base_payload, + ) + await db.commit() refreshed = await db.execute( select(Message) @@ -524,12 +642,15 @@ async def list_attachments( @router.get("/{message_id}/attachments/{attachment_id}/download") async def download_attachment( + request: Request, message_id: uuid.UUID, attachment_id: uuid.UUID, current_user: CurrentUser, db: DB, ) -> StreamingResponse: """Scarica un allegato direttamente da MinIO.""" + from app.services.audit_service import log_audit + await _resolve_message(message_id, current_user, db) result = await db.execute( @@ -542,6 +663,23 @@ async def download_attachment( if not attachment: raise NotFoundError(f"Allegato {attachment_id} non trovato") + await log_audit( + db, + "message.attachment_downloaded", + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="attachment", + resource_id=attachment.id, + ip_address=get_real_ip(request), + user_agent=request.headers.get("user-agent"), + payload={ + "filename": attachment.filename, + "message_id": str(message_id), + "size_bytes": attachment.size_bytes, + }, + ) + await db.commit() + try: from miniopy_async import Minio @@ -580,6 +718,7 @@ async def download_attachment( @router.get("/{message_id}/download-package") async def download_package( + request: Request, message_id: uuid.UUID, current_user: CurrentUser, db: DB, @@ -588,6 +727,7 @@ async def download_package( import io import zipfile as _zipfile + from app.services.audit_service import log_audit from miniopy_async import Minio message = await _resolve_message(message_id, current_user, db) @@ -682,6 +822,23 @@ async def download_package( safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50] zip_filename = f"pec_{safe_subject}.zip" + await log_audit( + db, + "message.package_downloaded", + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="message", + resource_id=message.id, + ip_address=get_real_ip(request), + user_agent=request.headers.get("user-agent"), + payload={ + "subject": message.subject, + "zip_filename": zip_filename, + "zip_size_bytes": len(zip_bytes), + }, + ) + await db.commit() + return StreamingResponse( iter([zip_bytes]), media_type="application/zip", diff --git a/backend/app/api/v1/send.py b/backend/app/api/v1/send.py index e69ec6e..ab58626 100644 --- a/backend/app/api/v1/send.py +++ b/backend/app/api/v1/send.py @@ -13,11 +13,12 @@ import json import uuid from typing import Annotated -from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile, status from app.core.exceptions import ForbiddenError from app.dependencies import CurrentUser, DB from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest +from app.services.audit_service import get_real_ip from app.services.permission_service import PermissionService from app.services.send_service import SendService @@ -47,12 +48,33 @@ def _job_response(job) -> SendJobResponse: ), ) async def create_send_job( + request: Request, data: SendPecRequest, current_user: CurrentUser, db: DB, ) -> SendJobResponse: + from app.services.audit_service import log_audit + svc = _svc(db) job = await svc.create_send_job(current_user=current_user, data=data) + + await log_audit( + db, + "pec.sent", + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="send_job", + resource_id=job.id, + ip_address=get_real_ip(request), + user_agent=request.headers.get("user-agent"), + payload={ + "mailbox_id": str(job.mailbox_id), + "to_addresses": data.to_addresses, + "subject": data.subject, + "has_attachments": False, + }, + ) + await db.commit() await db.refresh(job) return _job_response(job) @@ -74,6 +96,7 @@ async def create_send_job( ), ) async def create_send_job_multipart( + request: Request, current_user: CurrentUser, db: DB, data: str = Form( @@ -85,6 +108,8 @@ async def create_send_job_multipart( description="File allegati (0 o più, max 20 MB ciascuno)", ), ) -> SendJobResponse: + from app.services.audit_service import log_audit + # ── Parse del JSON ──────────────────────────────────────────────────────── try: raw = json.loads(data) @@ -128,6 +153,25 @@ async def create_send_job_multipart( data=pec_data, attachments=files_data if files_data else None, ) + + await log_audit( + db, + "pec.sent", + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="send_job", + resource_id=job.id, + ip_address=get_real_ip(request), + user_agent=request.headers.get("user-agent"), + payload={ + "mailbox_id": str(job.mailbox_id), + "to_addresses": pec_data.to_addresses, + "subject": pec_data.subject, + "has_attachments": bool(files_data), + "attachment_count": len(files_data), + }, + ) + await db.commit() await db.refresh(job) return _job_response(job) @@ -205,10 +249,13 @@ async def get_send_job( summary="Annulla job di invio", ) async def cancel_send_job( + request: Request, job_id: uuid.UUID, current_user: CurrentUser, db: DB, ) -> None: + from app.services.audit_service import log_audit + svc = _svc(db) job = await svc.get_send_job(job_id, current_user.tenant_id) @@ -218,4 +265,21 @@ async def cancel_send_job( raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio") await svc.cancel_send_job(job_id, current_user.tenant_id) + + await log_audit( + db, + "pec.send_cancelled", + tenant_id=current_user.tenant_id, + user_id=current_user.id, + resource_type="send_job", + resource_id=job_id, + ip_address=get_real_ip(request), + user_agent=request.headers.get("user-agent"), + payload={ + "job_id": str(job_id), + "mailbox_id": str(job.mailbox_id), + "previous_status": job.status, + }, + ) + await db.commit() diff --git a/backend/app/api/v1/signatures.py b/backend/app/api/v1/signatures.py new file mode 100644 index 0000000..cc89cf0 --- /dev/null +++ b/backend/app/api/v1/signatures.py @@ -0,0 +1,174 @@ +""" +Router firme automatiche. + +Endpoint: + GET /signatures – lista firme del tenant + POST /signatures – crea firma (admin) + GET /signatures/{id} – dettaglio firma + PUT /signatures/{id} – aggiorna firma (admin) + DELETE /signatures/{id} – elimina firma (admin) + + GET /signatures/assignments – lista assegnazioni (con filtri opzionali) + POST /signatures/assignments – crea/sostituisce assegnazione (admin) + DELETE /signatures/assignments/{id} – rimuovi assegnazione (admin) +""" + +import uuid + +from fastapi import APIRouter, Query, status + +from app.dependencies import AdminUser, CurrentUser, DB +from app.schemas.signature import ( + SignatureAssignmentCreate, + SignatureAssignmentListResponse, + SignatureAssignmentResponse, + SignatureCreate, + SignatureListResponse, + SignatureResponse, + SignatureUpdate, +) +from app.services.signature_service import SignatureService + +router = APIRouter(tags=["Signatures"]) + + +# ─── Firme ──────────────────────────────────────────────────────────────────── + +@router.get("/signatures", response_model=SignatureListResponse) +async def list_signatures( + current_user: CurrentUser, + db: DB, + q: str | None = Query(None, description="Filtro per nome"), +) -> SignatureListResponse: + """Elenca le firme del tenant corrente.""" + svc = SignatureService(db) + items, total = await svc.list_signatures(current_user.tenant_id, q=q) + return SignatureListResponse( + items=[SignatureResponse.model_validate(s) for s in items], + total=total, + ) + + +@router.post("/signatures", response_model=SignatureResponse, status_code=status.HTTP_201_CREATED) +async def create_signature( + data: SignatureCreate, + current_user: AdminUser, + db: DB, +) -> SignatureResponse: + """Crea una nuova firma (solo admin).""" + svc = SignatureService(db) + sig = await svc.create_signature(current_user.tenant_id, data, created_by=current_user.id) + return SignatureResponse.model_validate(sig) + + +@router.get("/signatures/assignments", response_model=SignatureAssignmentListResponse) +async def list_assignments( + current_user: AdminUser, + db: DB, + mailbox_id: uuid.UUID | None = Query(None), + virtual_box_id: uuid.UUID | None = Query(None), +) -> SignatureAssignmentListResponse: + """Elenca le assegnazioni firma del tenant, con filtri opzionali.""" + svc = SignatureService(db) + items, total = await svc.list_assignments( + current_user.tenant_id, + mailbox_id=mailbox_id, + virtual_box_id=virtual_box_id, + ) + return SignatureAssignmentListResponse( + items=[SignatureAssignmentResponse.model_validate(i) for i in items], + total=total, + ) + + +@router.post( + "/signatures/assignments", + response_model=SignatureAssignmentResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_assignment( + data: SignatureAssignmentCreate, + current_user: AdminUser, + db: DB, +) -> SignatureAssignmentResponse: + """ + Crea (o sostituisce) l'assegnazione firma per una casella/vbox+contesto. + Se esiste gia' un'assegnazione per la stessa coppia, viene sovrascritta. + """ + svc = SignatureService(db) + result = await svc.create_assignment(current_user.tenant_id, data) + return SignatureAssignmentResponse.model_validate(result) + + +@router.delete( + "/signatures/assignments/{assignment_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_assignment( + assignment_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> None: + """Rimuove un'assegnazione firma (solo admin).""" + svc = SignatureService(db) + await svc.delete_assignment(current_user.tenant_id, assignment_id) + + +@router.get("/signatures/resolve", response_model=SignatureResponse | None) +async def resolve_signature( + current_user: CurrentUser, + db: DB, + context: str = Query("compose", description="Contesto: reply | compose"), + mailbox_id: uuid.UUID | None = Query(None), + virtual_box_id: uuid.UUID | None = Query(None), +) -> SignatureResponse | None: + """ + Restituisce la firma assegnata per una casella o virtual box nel contesto dato. + Usato dal ComposeModal per caricare automaticamente la firma. + """ + svc = SignatureService(db) + sig = await svc.resolve_signature( + current_user.tenant_id, + context=context, + mailbox_id=mailbox_id, + virtual_box_id=virtual_box_id, + ) + if sig is None: + return None + return SignatureResponse.model_validate(sig) + + +@router.get("/signatures/{signature_id}", response_model=SignatureResponse) +async def get_signature( + signature_id: uuid.UUID, + current_user: CurrentUser, + db: DB, +) -> SignatureResponse: + """Restituisce il dettaglio di una firma.""" + svc = SignatureService(db) + sig = await svc.get_signature(current_user.tenant_id, signature_id) + return SignatureResponse.model_validate(sig) + + +@router.put("/signatures/{signature_id}", response_model=SignatureResponse) +async def update_signature( + signature_id: uuid.UUID, + data: SignatureUpdate, + current_user: AdminUser, + db: DB, +) -> SignatureResponse: + """Aggiorna una firma esistente (solo admin).""" + svc = SignatureService(db) + sig = await svc.update_signature(current_user.tenant_id, signature_id, data) + return SignatureResponse.model_validate(sig) + + +@router.delete("/signatures/{signature_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_signature( + signature_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> None: + """Elimina una firma (solo admin).""" + svc = SignatureService(db) + await svc.delete_signature(current_user.tenant_id, signature_id) diff --git a/backend/app/main.py b/backend/app/main.py index 76a45e8..5dca76f 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 audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, tenants, templates, users, virtual_boxes, ws +from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws from app.api.v1 import settings as settings_router from app.config import get_settings from app.core.logging import get_logger, setup_logging @@ -102,6 +102,7 @@ app.include_router(templates.router, prefix=API_PREFIX) app.include_router(routing_rules.router, prefix=API_PREFIX) app.include_router(contacts.router, prefix=API_PREFIX) app.include_router(deadlines.router, prefix=API_PREFIX) +app.include_router(signatures.router, prefix=API_PREFIX) # ─── Health check ───────────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3018dd9..6cf02e6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,3 +13,4 @@ from app.models.tenant_settings import TenantSettings # noqa: F401 from app.models.template import MessageTemplate # noqa: F401 from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401 from app.models.pec_contact import PecContact # noqa: F401 +from app.models.signature import Signature, SignatureAssignment # noqa: F401 diff --git a/backend/app/models/signature.py b/backend/app/models/signature.py new file mode 100644 index 0000000..9cdc3e3 --- /dev/null +++ b/backend/app/models/signature.py @@ -0,0 +1,118 @@ +""" +Modelli Signature – firme automatiche per caselle PEC e Virtual Box. + +Struttura: + Signature → definisce il testo della firma (con editor rich text) + SignatureAssignment → collega una firma a una casella o virtual box per un contesto + (risposta, nuova composizione, o entrambi) +""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class Signature(Base): + """Firma riutilizzabile di un tenant.""" + + __tablename__ = "signatures" + + 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) + body_html: Mapped[str | None] = mapped_column(Text, nullable=True) + body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), 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() + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_signature_name_tenant"), + Index("idx_signatures_tenant", "tenant_id"), + ) + + def __repr__(self) -> str: + return f"" + + +class SignatureAssignment(Base): + """ + Assegna una firma a una casella PEC o a una Virtual Box per un determinato contesto. + + context: + reply – firma inserita automaticamente nelle risposte + compose – firma inserita automaticamente nelle nuove composizioni + both – firma inserita in entrambi i contesti + + Vincolo: esattamente uno tra mailbox_id e virtual_box_id deve essere valorizzato. + Vincolo unique: non puo' esistere piu' di un'assegnazione per la stessa + (casella/vbox, contesto) coppia. + """ + + __tablename__ = "signature_assignments" + + 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 + ) + signature_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("signatures.id", ondelete="CASCADE"), nullable=False + ) + mailbox_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=True + ) + virtual_box_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("virtual_boxes.id", ondelete="CASCADE"), nullable=True + ) + # "reply" | "compose" | "both" + context: Mapped[str] = mapped_column(String(20), nullable=False, default="both") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + # Almeno uno tra mailbox_id e virtual_box_id deve essere valorizzato + CheckConstraint( + "(mailbox_id IS NOT NULL)::int + (virtual_box_id IS NOT NULL)::int = 1", + name="ck_sig_assignment_target", + ), + # Non puo' esserci piu' di un'assegnazione per la stessa casella+contesto + UniqueConstraint("mailbox_id", "context", name="uq_sig_mailbox_context"), + # Non puo' esserci piu' di un'assegnazione per la stessa vbox+contesto + UniqueConstraint("virtual_box_id", "context", name="uq_sig_vbox_context"), + Index("idx_sig_assign_tenant", "tenant_id"), + Index("idx_sig_assign_mailbox", "mailbox_id"), + Index("idx_sig_assign_vbox", "virtual_box_id"), + ) + + def __repr__(self) -> str: + target = f"mailbox={self.mailbox_id}" if self.mailbox_id else f"vbox={self.virtual_box_id}" + return f"" diff --git a/backend/app/schemas/audit_log.py b/backend/app/schemas/audit_log.py index 875f732..51adf50 100644 --- a/backend/app/schemas/audit_log.py +++ b/backend/app/schemas/audit_log.py @@ -17,6 +17,9 @@ class AuditLogResponse(BaseModel): id: int tenant_id: Optional[uuid.UUID] = None user_id: Optional[uuid.UUID] = None + # Dati utente denormalizzati (arricchiti dalla JOIN nel service) + user_email: Optional[str] = None + user_full_name: Optional[str] = None action: str resource_type: Optional[str] = None resource_id: Optional[uuid.UUID] = None diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index f3b074a..03190e7 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -11,6 +11,27 @@ from pydantic import BaseModel, model_validator from app.schemas.label import LabelResponse +# ─── Search match info ──────────────────────────────────────────────────────── + +class AttachmentMatchInfo(BaseModel): + """Allegato in cui e' stato trovato il termine cercato.""" + id: uuid.UUID + filename: str + + model_config = {"from_attributes": True} + + +class SearchMatchInfo(BaseModel): + """Indica dove e' stato trovato il termine di ricerca in un messaggio.""" + in_subject: bool = False + in_body: bool = False + in_attachments: list[AttachmentMatchInfo] = [] + + model_config = {"from_attributes": True} + + +# ─── Attachment ─────────────────────────────────────────────────────────────── + class AttachmentResponse(BaseModel): id: uuid.UUID message_id: uuid.UUID @@ -59,6 +80,8 @@ class MessageResponse(BaseModel): created_at: datetime updated_at: datetime labels: list[LabelResponse] = [] + # Popolato solo nelle risposte di ricerca full-text + search_match: Optional[SearchMatchInfo] = None @model_validator(mode="before") @classmethod diff --git a/backend/app/schemas/signature.py b/backend/app/schemas/signature.py new file mode 100644 index 0000000..4dba2d4 --- /dev/null +++ b/backend/app/schemas/signature.py @@ -0,0 +1,75 @@ +""" +Schemi Pydantic per la gestione delle firme automatiche. +""" + +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +# ─── Signature ──────────────────────────────────────────────────────────────── + +class SignatureCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: str | None = Field(None) + body_html: str | None = Field(None) + body_text: str | None = Field(None) + + +class SignatureUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=255) + description: str | None = Field(None) + body_html: str | None = Field(None) + body_text: str | None = Field(None) + + +class SignatureResponse(BaseModel): + id: uuid.UUID + tenant_id: uuid.UUID + name: str + description: str | None + body_html: str | None + body_text: str | None + created_by: uuid.UUID | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class SignatureListResponse(BaseModel): + items: list[SignatureResponse] + total: int + + +# ─── SignatureAssignment ─────────────────────────────────────────────────────── + +SignatureContext = Literal["reply", "compose", "both"] + + +class SignatureAssignmentCreate(BaseModel): + signature_id: uuid.UUID + mailbox_id: uuid.UUID | None = None + virtual_box_id: uuid.UUID | None = None + context: SignatureContext = "both" + + +class SignatureAssignmentResponse(BaseModel): + id: uuid.UUID + tenant_id: uuid.UUID + signature_id: uuid.UUID + mailbox_id: uuid.UUID | None + virtual_box_id: uuid.UUID | None + context: str + created_at: datetime + # Nome della firma (join eagerly nel service) + signature_name: str | None = None + + model_config = {"from_attributes": True} + + +class SignatureAssignmentListResponse(BaseModel): + items: list[SignatureAssignmentResponse] + total: int diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index 7cf7bde..10900b5 100644 --- a/backend/app/services/audit_service.py +++ b/backend/app/services/audit_service.py @@ -12,25 +12,54 @@ Uso tipico nei router/servizi: resource_type="user", resource_id=new_user.id, outcome="success", - ip_address=request.client.host if request.client else None, + ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), payload={"email": new_user.email}, ) """ +import csv +import io import math import uuid from datetime import datetime from typing import Optional +from fastapi import Request from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from app.core.pagination import PaginatedResponse, PaginationParams from app.models.audit_log import AuditLog +from app.models.user import User from app.schemas.audit_log import AuditLogResponse +# ─── Helper IP reale (legge X-Real-IP o X-Forwarded-For da reverse proxy) ──── + +def get_real_ip(request: Request) -> Optional[str]: + """ + Restituisce l'IP reale del client, leggendo gli header del reverse proxy. + Priorita': X-Real-IP > primo IP di X-Forwarded-For > request.client.host + """ + x_real_ip = request.headers.get("x-real-ip") + if x_real_ip: + return x_real_ip.strip() + + x_forwarded_for = request.headers.get("x-forwarded-for", "") + if x_forwarded_for: + # Prende il primo IP della catena (quello del client originale) + first_ip = x_forwarded_for.split(",")[0].strip() + if first_ip: + return first_ip + + if request.client: + return request.client.host + + return None + + # ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ─────── async def log_audit( @@ -79,31 +108,35 @@ class AuditService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def list( + def _build_query( self, *, tenant_id: Optional[uuid.UUID], - page: int = 1, - page_size: int = 25, action: Optional[str] = None, user_id: Optional[uuid.UUID] = None, outcome: Optional[str] = None, date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, resource_type: Optional[str] = None, - ) -> PaginatedResponse[AuditLogResponse]: - """ - Restituisce la lista paginata degli eventi audit. + ): + """Costruisce la query base con JOIN utente e filtri.""" + UserAlias = aliased(User) + + stmt = ( + select( + AuditLog, + UserAlias.email.label("user_email"), + UserAlias.full_name.label("user_full_name"), + ) + .outerjoin(UserAlias, UserAlias.id == AuditLog.user_id) + ) - Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant. - """ filters = [] if tenant_id is not None: filters.append(AuditLog.tenant_id == tenant_id) if action: - # Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.* if action.endswith("*"): filters.append(AuditLog.action.like(action[:-1] + "%")) else: @@ -124,30 +157,221 @@ class AuditService: if resource_type: filters.append(AuditLog.resource_type == resource_type) - where_clause = and_(*filters) if filters else True # type: ignore[arg-type] + if filters: + stmt = stmt.where(and_(*filters)) + + return stmt + + async def list( + self, + *, + tenant_id: Optional[uuid.UUID], + page: int = 1, + page_size: int = 25, + action: Optional[str] = None, + user_id: Optional[uuid.UUID] = None, + outcome: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + resource_type: Optional[str] = None, + ) -> PaginatedResponse[AuditLogResponse]: + """ + Restituisce la lista paginata degli eventi audit con dati utente. + Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant. + """ + base_stmt = self._build_query( + tenant_id=tenant_id, + action=action, + user_id=user_id, + outcome=outcome, + date_from=date_from, + date_to=date_to, + resource_type=resource_type, + ) # Count totale - count_q = select(func.count()).select_from(AuditLog).where(where_clause) + count_q = select(func.count()).select_from(base_stmt.subquery()) total = (await self.db.execute(count_q)).scalar_one() # Dati paginati offset = (page - 1) * page_size - items_q = ( - select(AuditLog) - .where(where_clause) - .order_by(AuditLog.occurred_at.desc()) - .offset(offset) - .limit(page_size) - ) - result = await self.db.execute(items_q) - items = list(result.scalars().all()) + items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).offset(offset).limit(page_size) + rows = (await self.db.execute(items_q)).all() + + items = [] + for row in rows: + entry: AuditLog = row[0] + email: Optional[str] = row[1] + full_name: Optional[str] = row[2] + resp = AuditLogResponse.model_validate(entry) + resp.user_email = email + resp.user_full_name = full_name + items.append(resp) pages = math.ceil(total / page_size) if page_size > 0 else 0 return PaginatedResponse[AuditLogResponse]( - items=[AuditLogResponse.model_validate(item) for item in items], + items=items, total=total, page=page, page_size=page_size, pages=pages, ) + + async def export_csv( + self, + *, + tenant_id: Optional[uuid.UUID], + action: Optional[str] = None, + user_id: Optional[uuid.UUID] = None, + outcome: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + resource_type: Optional[str] = None, + limit: int = 10000, + ) -> str: + """ + Restituisce i log in formato CSV (stringa). + Massimo `limit` righe per prevenire export eccessivi. + """ + base_stmt = self._build_query( + tenant_id=tenant_id, + action=action, + user_id=user_id, + outcome=outcome, + date_from=date_from, + date_to=date_to, + resource_type=resource_type, + ) + items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit) + rows = (await self.db.execute(items_q)).all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "ID", "Data/Ora", "Azione", "Esito", + "Utente", "Email utente", "IP", + "Tipo risorsa", "ID risorsa", + ]) + + for row in rows: + entry: AuditLog = row[0] + email: Optional[str] = row[1] + full_name: Optional[str] = row[2] + writer.writerow([ + entry.id, + entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"), + entry.action, + entry.outcome, + full_name or "", + email or "", + str(entry.ip_address) if entry.ip_address else "", + entry.resource_type or "", + str(entry.resource_id) if entry.resource_id else "", + ]) + + return output.getvalue() + + async def export_pdf_bytes( + self, + *, + tenant_id: Optional[uuid.UUID], + action: Optional[str] = None, + user_id: Optional[uuid.UUID] = None, + outcome: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + resource_type: Optional[str] = None, + limit: int = 5000, + ) -> bytes: + """ + Genera un PDF con i log di audit usando reportlab. + Restituisce i byte del PDF. + """ + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4, landscape + from reportlab.lib.styles import getSampleStyleSheet + from reportlab.lib.units import cm + from reportlab.platypus import ( + SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, + ) + + base_stmt = self._build_query( + tenant_id=tenant_id, + action=action, + user_id=user_id, + outcome=outcome, + date_from=date_from, + date_to=date_to, + resource_type=resource_type, + ) + items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit) + rows = (await self.db.execute(items_q)).all() + + buf = io.BytesIO() + doc = SimpleDocTemplate( + buf, + pagesize=landscape(A4), + rightMargin=1 * cm, + leftMargin=1 * cm, + topMargin=1.5 * cm, + bottomMargin=1.5 * cm, + ) + styles = getSampleStyleSheet() + story = [] + + # Titolo + title_text = "Audit Log" + if date_from or date_to: + parts = [] + if date_from: + parts.append(f"dal {date_from.strftime('%d/%m/%Y')}") + if date_to: + parts.append(f"al {date_to.strftime('%d/%m/%Y')}") + title_text += " – " + " ".join(parts) + + story.append(Paragraph(title_text, styles["Heading1"])) + story.append(Spacer(1, 0.3 * cm)) + story.append(Paragraph( + f"Esportato il {datetime.now().strftime('%d/%m/%Y %H:%M')} — {len(rows)} record", + styles["Normal"], + )) + story.append(Spacer(1, 0.5 * cm)) + + # Tabella + headers = ["Data/Ora", "Azione", "Esito", "Utente", "IP", "Risorsa"] + table_data = [headers] + + for row in rows: + entry: AuditLog = row[0] + email: Optional[str] = row[1] + full_name: Optional[str] = row[2] + utente = full_name or email or (str(entry.user_id)[:8] if entry.user_id else "—") + risorsa = entry.resource_type or "—" + table_data.append([ + entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"), + entry.action, + "OK" if entry.outcome == "success" else "FAIL", + utente, + str(entry.ip_address) if entry.ip_address else "—", + risorsa, + ]) + + col_widths = [3.8 * cm, 5.0 * cm, 1.8 * cm, 4.5 * cm, 3.5 * cm, 3.0 * cm] + table = Table(table_data, colWidths=col_widths, repeatRows=1) + table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a5f")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 8), + ("FONTSIZE", (0, 1), (-1, -1), 7), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f8ff")]), + ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cccccc")), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ])) + story.append(table) + + doc.build(story) + return buf.getvalue() diff --git a/backend/app/services/signature_service.py b/backend/app/services/signature_service.py new file mode 100644 index 0000000..bc46382 --- /dev/null +++ b/backend/app/services/signature_service.py @@ -0,0 +1,258 @@ +""" +Service layer per la gestione delle firme automatiche. +""" + +import uuid +from typing import Sequence + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import NotFoundError, ValidationError +from app.models.signature import Signature, SignatureAssignment +from app.schemas.signature import SignatureCreate, SignatureUpdate, SignatureAssignmentCreate + + +class SignatureService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + # ─── Firme ──────────────────────────────────────────────────────────────── + + async def list_signatures( + self, tenant_id: uuid.UUID, q: str | None = None + ) -> tuple[Sequence[Signature], int]: + stmt = select(Signature).where(Signature.tenant_id == tenant_id) + if q: + stmt = stmt.where(Signature.name.ilike(f"%{q}%")) + stmt = stmt.order_by(Signature.name) + + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = (await self.db.execute(count_stmt)).scalar_one() + items = (await self.db.execute(stmt)).scalars().all() + return items, total + + async def get_signature( + self, tenant_id: uuid.UUID, signature_id: uuid.UUID + ) -> Signature: + stmt = select(Signature).where( + Signature.id == signature_id, + Signature.tenant_id == tenant_id, + ) + sig = (await self.db.execute(stmt)).scalar_one_or_none() + if sig is None: + raise NotFoundError("Firma non trovata") + return sig + + async def create_signature( + self, + tenant_id: uuid.UUID, + data: SignatureCreate, + created_by: uuid.UUID | None = None, + ) -> Signature: + sig = Signature( + tenant_id=tenant_id, + name=data.name, + description=data.description, + body_html=data.body_html, + body_text=data.body_text, + created_by=created_by, + ) + self.db.add(sig) + await self.db.commit() + await self.db.refresh(sig) + return sig + + async def update_signature( + self, + tenant_id: uuid.UUID, + signature_id: uuid.UUID, + data: SignatureUpdate, + ) -> Signature: + sig = await self.get_signature(tenant_id, signature_id) + if data.name is not None: + sig.name = data.name + if data.description is not None: + sig.description = data.description + if data.body_html is not None: + sig.body_html = data.body_html + if data.body_text is not None: + sig.body_text = data.body_text + await self.db.commit() + await self.db.refresh(sig) + return sig + + async def delete_signature( + self, tenant_id: uuid.UUID, signature_id: uuid.UUID + ) -> None: + sig = await self.get_signature(tenant_id, signature_id) + await self.db.delete(sig) + await self.db.commit() + + # ─── Assegnazioni ───────────────────────────────────────────────────────── + + async def list_assignments( + self, + tenant_id: uuid.UUID, + mailbox_id: uuid.UUID | None = None, + virtual_box_id: uuid.UUID | None = None, + ) -> tuple[list[dict], int]: + """ + Restituisce le assegnazioni con il nome della firma incluso. + Filtro opzionale per casella o virtual box. + """ + stmt = ( + select(SignatureAssignment, Signature.name.label("signature_name")) + .join(Signature, Signature.id == SignatureAssignment.signature_id) + .where(SignatureAssignment.tenant_id == tenant_id) + ) + if mailbox_id: + stmt = stmt.where(SignatureAssignment.mailbox_id == mailbox_id) + if virtual_box_id: + stmt = stmt.where(SignatureAssignment.virtual_box_id == virtual_box_id) + stmt = stmt.order_by(SignatureAssignment.created_at) + + rows = (await self.db.execute(stmt)).all() + items = [] + for row in rows: + assignment: SignatureAssignment = row[0] + sig_name: str = row[1] + items.append({ + "id": assignment.id, + "tenant_id": assignment.tenant_id, + "signature_id": assignment.signature_id, + "mailbox_id": assignment.mailbox_id, + "virtual_box_id": assignment.virtual_box_id, + "context": assignment.context, + "created_at": assignment.created_at, + "signature_name": sig_name, + }) + return items, len(items) + + async def create_assignment( + self, + tenant_id: uuid.UUID, + data: SignatureAssignmentCreate, + ) -> dict: + # Validazione: esattamente uno tra mailbox_id e virtual_box_id + if bool(data.mailbox_id) == bool(data.virtual_box_id): + raise ValidationError( + "Specificare esattamente uno tra mailbox_id e virtual_box_id" + ) + + # Verifica che la firma esista nel tenant + await self.get_signature(tenant_id, data.signature_id) + + # Rimuovi eventuale assegnazione precedente per la stessa coppia (target, context) + existing_stmt = select(SignatureAssignment).where( + SignatureAssignment.tenant_id == tenant_id, + SignatureAssignment.context == data.context, + ) + if data.mailbox_id: + existing_stmt = existing_stmt.where( + SignatureAssignment.mailbox_id == data.mailbox_id + ) + else: + existing_stmt = existing_stmt.where( + SignatureAssignment.virtual_box_id == data.virtual_box_id + ) + existing = (await self.db.execute(existing_stmt)).scalar_one_or_none() + if existing: + await self.db.delete(existing) + await self.db.flush() # Flush il DELETE prima dell'INSERT per evitare UniqueViolationError + + assignment = SignatureAssignment( + tenant_id=tenant_id, + signature_id=data.signature_id, + mailbox_id=data.mailbox_id, + virtual_box_id=data.virtual_box_id, + context=data.context, + ) + self.db.add(assignment) + await self.db.commit() + await self.db.refresh(assignment) + + # Carica il nome della firma per la risposta + sig = await self.get_signature(tenant_id, data.signature_id) + return { + "id": assignment.id, + "tenant_id": assignment.tenant_id, + "signature_id": assignment.signature_id, + "mailbox_id": assignment.mailbox_id, + "virtual_box_id": assignment.virtual_box_id, + "context": assignment.context, + "created_at": assignment.created_at, + "signature_name": sig.name, + } + + async def delete_assignment( + self, tenant_id: uuid.UUID, assignment_id: uuid.UUID + ) -> None: + stmt = select(SignatureAssignment).where( + SignatureAssignment.id == assignment_id, + SignatureAssignment.tenant_id == tenant_id, + ) + assignment = (await self.db.execute(stmt)).scalar_one_or_none() + if assignment is None: + raise NotFoundError("Assegnazione firma non trovata") + await self.db.delete(assignment) + await self.db.commit() + + async def resolve_signature( + self, + tenant_id: uuid.UUID, + context: str, + mailbox_id: uuid.UUID | None = None, + virtual_box_id: uuid.UUID | None = None, + ) -> Signature | None: + """ + Restituisce la firma assegnata per casella/vbox nel contesto specificato. + Cerca prima un'assegnazione con context == context, poi context == 'both'. + """ + if not mailbox_id and not virtual_box_id: + return None + + stmt = ( + select(SignatureAssignment) + .where( + SignatureAssignment.tenant_id == tenant_id, + SignatureAssignment.context.in_([context, "both"]), + ) + ) + if mailbox_id: + stmt = stmt.where(SignatureAssignment.mailbox_id == mailbox_id) + else: + stmt = stmt.where(SignatureAssignment.virtual_box_id == virtual_box_id) + + assignments = (await self.db.execute(stmt)).scalars().all() + if not assignments: + return None + + # Preferisce il match esatto sul contesto rispetto a 'both' + exact = next((a for a in assignments if a.context == context), None) + assignment = exact or assignments[0] + + return await self.get_signature(tenant_id, assignment.signature_id) + + async def delete_assignment_by_target( + self, + tenant_id: uuid.UUID, + context: str, + mailbox_id: uuid.UUID | None = None, + virtual_box_id: uuid.UUID | None = None, + ) -> None: + """Rimuove l'assegnazione per una specifica casella/vbox+contesto (se presente).""" + stmt = select(SignatureAssignment).where( + SignatureAssignment.tenant_id == tenant_id, + SignatureAssignment.context == context, + ) + if mailbox_id: + stmt = stmt.where(SignatureAssignment.mailbox_id == mailbox_id) + elif virtual_box_id: + stmt = stmt.where(SignatureAssignment.virtual_box_id == virtual_box_id) + else: + return + assignment = (await self.db.execute(stmt)).scalar_one_or_none() + if assignment: + await self.db.delete(assignment) + await self.db.commit() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fb28d2..fccccb2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { TemplatesPage } from '@/pages/Templates/TemplatesPage' import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage' import { ContactsPage } from '@/pages/Contacts/ContactsPage' import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage' +import { SignaturesPage } from '@/pages/Signatures/SignaturesPage' /** * Routing principale dell'applicazione PEChub. @@ -101,6 +102,7 @@ export default function App() { } /> } /> } /> + } /> {/* Profilo utente */} } /> diff --git a/frontend/src/api/audit_log.api.ts b/frontend/src/api/audit_log.api.ts index 01e66ee..729ea1e 100644 --- a/frontend/src/api/audit_log.api.ts +++ b/frontend/src/api/audit_log.api.ts @@ -9,6 +9,8 @@ export interface AuditLogEntry { id: number tenant_id: string | null user_id: string | null + user_email: string | null + user_full_name: string | null action: string resource_type: string | null resource_id: string | null @@ -38,4 +40,19 @@ export const auditLogApi = { apiClient .get('/audit-log', { params }) .then((r) => r.data), + + /** + * Esporta i log come file (CSV o PDF). + * Restituisce un Blob per il download lato browser. + */ + export: async ( + format: 'csv' | 'pdf', + params: Omit = {}, + ): Promise => { + const response = await apiClient.get('/audit-log/export', { + params: { format, ...params }, + responseType: 'blob', + }) + return response.data as Blob + }, } diff --git a/frontend/src/api/signatures.api.ts b/frontend/src/api/signatures.api.ts new file mode 100644 index 0000000..daf4b3f --- /dev/null +++ b/frontend/src/api/signatures.api.ts @@ -0,0 +1,97 @@ +import apiClient from './client' + +// ─── Firme ──────────────────────────────────────────────────────────────────── + +export interface SignatureResponse { + id: string + tenant_id: string + name: string + description: string | null + body_html: string | null + body_text: string | null + created_by: string | null + created_at: string + updated_at: string +} + +export interface SignatureCreate { + name: string + description?: string | null + body_html?: string | null + body_text?: string | null +} + +export interface SignatureUpdate { + name?: string + description?: string | null + body_html?: string | null + body_text?: string | null +} + +// ─── Assegnazioni ───────────────────────────────────────────────────────────── + +export type SignatureContext = 'reply' | 'compose' | 'both' + +export interface SignatureAssignmentResponse { + id: string + tenant_id: string + signature_id: string + mailbox_id: string | null + virtual_box_id: string | null + context: SignatureContext + created_at: string + signature_name: string | null +} + +export interface SignatureAssignmentCreate { + signature_id: string + mailbox_id?: string | null + virtual_box_id?: string | null + context: SignatureContext +} + +// ─── API client ─────────────────────────────────────────────────────────────── + +export const signaturesApi = { + // Firme + list: (q?: string) => + apiClient + .get<{ items: SignatureResponse[]; total: number }>('/signatures', { + params: { q }, + }) + .then((r) => r.data), + + get: (id: string) => + apiClient.get(`/signatures/${id}`).then((r) => r.data), + + create: (data: SignatureCreate) => + apiClient.post('/signatures', data).then((r) => r.data), + + update: (id: string, data: SignatureUpdate) => + apiClient.put(`/signatures/${id}`, data).then((r) => r.data), + + delete: (id: string) => + apiClient.delete(`/signatures/${id}`).then((r) => r.data), + + // Assegnazioni + listAssignments: (params?: { mailbox_id?: string; virtual_box_id?: string }) => + apiClient + .get<{ items: SignatureAssignmentResponse[]; total: number }>('/signatures/assignments', { + params, + }) + .then((r) => r.data), + + createAssignment: (data: SignatureAssignmentCreate) => + apiClient + .post('/signatures/assignments', data) + .then((r) => r.data), + + deleteAssignment: (id: string) => + apiClient.delete(`/signatures/assignments/${id}`).then((r) => r.data), + + // Risolve la firma per una casella/vbox nel contesto dato (usato dal ComposeModal) + resolve: (params: { context: 'reply' | 'compose'; mailbox_id?: string; virtual_box_id?: string }) => + apiClient + .get('/signatures/resolve', { params }) + .then((r) => r.data), +} diff --git a/frontend/src/components/ComposeModal/ComposeModal.tsx b/frontend/src/components/ComposeModal/ComposeModal.tsx index f944907..ae40c77 100644 --- a/frontend/src/components/ComposeModal/ComposeModal.tsx +++ b/frontend/src/components/ComposeModal/ComposeModal.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useMemo, useEffect } from 'react' -import { useForm, useFieldArray } from 'react-hook-form' +import { useForm, useFieldArray, useWatch } from 'react-hook-form' import { Send, X, @@ -11,6 +11,8 @@ import { Minus, Maximize2, Minimize2, + FileText, + ChevronDown, } from 'lucide-react' import { useQuery, useMutation } from '@tanstack/react-query' import toast from 'react-hot-toast' @@ -18,13 +20,38 @@ import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' +import { ContactPickerPopover } from '@/components/ComposeModal/ContactPickerPopover' import { sendApi } from '@/api/send.api' import { mailboxesApi } from '@/api/mailboxes.api' import { virtualBoxesApi } from '@/api/virtual_boxes.api' +import { signaturesApi } from '@/api/signatures.api' +import { templatesApi } from '@/api/templates.api' +import type { TemplateResponse } from '@/api/templates.api' import { getErrorMessage } from '@/api/client' import { useComposeStore } from '@/store/compose.store' import type { MessageResponse } from '@/types/api.types' +// ─── Utilita' firma ─────────────────────────────────────────────────────────── + +const SIG_ATTR = 'data-pechub-sig' + +/** Rimuove il blocco firma esistente e (opzionalmente) ne inserisce uno nuovo. */ +function injectSignature(body: string, sigHtml: string | null): string { + const withoutSig = body.replace( + new RegExp(`]*${SIG_ATTR}="1"[^>]*>[\\s\\S]*?<\\/div>`, 'g'), + '' + ) + if (!sigHtml) return withoutSig + const sigBlock = `
${sigHtml}
` + if (withoutSig.includes('
')) { + return withoutSig.replace('
', sigBlock + '
') + } + // Se il corpo e' vuoto (nuova PEC), anteponi un paragrafo vuoto sopra la firma + // cosi' il cursore si posiziona sopra di essa e non dentro/dopo + const content = withoutSig.trim() ? withoutSig : '

' + return content + sigBlock +} + // ─── Tipi ───────────────────────────────────────────────────────────────────── interface MailboxSelectItem { @@ -92,32 +119,73 @@ function buildInitialBody(replyTo?: MessageResponse | null, forwardOf?: MessageR return '' } +/** + * Calcola i destinatari iniziali per "Rispondi a tutti": + * from + tutti i to, deduplicati (la propria email verra' filtrata dopo che le caselle si caricano). + */ +function buildReplyAllToAddresses(replyTo: MessageResponse): { value: string }[] { + const seen = new Set() + const result: { value: string }[] = [] + const addIfNew = (addr: string) => { + const normalized = addr.trim().toLowerCase() + if (normalized && !seen.has(normalized)) { + seen.add(normalized) + result.push({ value: addr.trim() }) + } + } + if (replyTo.from_address) addIfNew(replyTo.from_address) + for (const addr of replyTo.to_addresses || []) addIfNew(addr) + return result.length > 0 ? result : [{ value: '' }] +} + // ─── Form interno (rimontato ogni volta che cambia il messaggio) ─────────────── interface ComposeFormProps { replyTo: MessageResponse | null forwardOf: MessageResponse | null + replyAll: boolean onClose: () => void } -function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) { +function ComposeForm({ replyTo, forwardOf, replyAll, onClose }: ComposeFormProps) { const fileInputRef = useRef(null) - const [showCc, setShowCc] = useState(false) + + // In modalita' "rispondi a tutti" apri CC automaticamente se ci sono CC originali + const [showCc, setShowCc] = useState( + replyAll && (replyTo?.cc_addresses?.length ?? 0) > 0 + ) const [attachments, setAttachments] = useState([]) const [bodyHtml, setBodyHtml] = useState(() => buildInitialBody(replyTo, forwardOf) ) + // Flag per eseguire il filtro della propria email una sola volta dopo il caricamento delle caselle + const ownEmailFilteredRef = useRef(false) + + const buildDefaultTo = (): { value: string }[] => { + if (replyAll && replyTo) return buildReplyAllToAddresses(replyTo) + if (replyTo) return [{ value: replyTo.from_address || '' }] + return [{ value: '' }] + } + + const buildDefaultCc = (): { value: string }[] => { + if (replyAll && replyTo && replyTo.cc_addresses?.length > 0) { + return replyTo.cc_addresses.map((a) => ({ value: a })) + } + return [] + } + const { register, control, handleSubmit, + setValue, formState: { errors }, } = useForm({ defaultValues: { mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '', - to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }], - cc_addresses: [], + to_addresses: buildDefaultTo(), + cc_addresses: buildDefaultCc(), subject: replyTo ? `Re: ${replyTo.subject || ''}` : forwardOf @@ -126,9 +194,56 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) { }, }) - const { fields: toFields, append: appendTo, remove: removeTo } = useFieldArray({ control, name: 'to_addresses' }) + const { fields: toFields, append: appendTo, remove: removeTo, replace: replaceTo } = useFieldArray({ control, name: 'to_addresses' }) const { fields: ccFields, append: appendCc, remove: removeCc } = useFieldArray({ control, name: 'cc_addresses' }) + // ── Auto-inserimento firma ──────────────────────────────────────────────── + const watchedMailboxId = useWatch({ control, name: 'mailbox_id' }) + const signatureContext = replyTo ? 'reply' : 'compose' + const sigHtmlRef = useRef(null) + + useEffect(() => { + if (!watchedMailboxId) { + sigHtmlRef.current = null + setBodyHtml((prev) => injectSignature(prev, null)) + return + } + signaturesApi + .resolve({ context: signatureContext, mailbox_id: watchedMailboxId }) + .then((sig) => { + sigHtmlRef.current = sig?.body_html ?? null + setBodyHtml((prev) => injectSignature(prev, sig?.body_html ?? null)) + }) + .catch(() => { + sigHtmlRef.current = null + }) + }, [watchedMailboxId, signatureContext]) + // ───────────────────────────────────────────────────────────────────────── + + // ── Selettore template ──────────────────────────────────────────────────── + const [showTemplatePicker, setShowTemplatePicker] = useState(false) + + const { data: templatesData, isLoading: templatesLoading } = useQuery({ + queryKey: ['templates'], + queryFn: () => templatesApi.list(), + enabled: showTemplatePicker, + staleTime: 60 * 1000, + }) + + const applyTemplate = (tpl: TemplateResponse) => { + const hrIndex = bodyHtml.indexOf('
') + const quotedPart = hrIndex >= 0 ? bodyHtml.slice(hrIndex) : '' + const templateBody = tpl.body_html || (tpl.body_text ? `

${tpl.body_text}

` : '') + const newBodyBase = templateBody + quotedPart + setBodyHtml(injectSignature(newBodyBase, sigHtmlRef.current)) + if (!replyTo && !forwardOf && tpl.subject) { + setValue('subject', tpl.subject) + } + setShowTemplatePicker(false) + toast.success(`Template "${tpl.name}" applicato`) + } + // ───────────────────────────────────────────────────────────────────────── + const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({ queryKey: ['mailboxes'], queryFn: () => mailboxesApi.list(), @@ -139,18 +254,6 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) { queryFn: () => virtualBoxesApi.getMyMailboxes(), }) - const sendMutation = useMutation({ - mutationFn: (args: { data: Parameters[0]; files: File[] }) => - sendApi.sendMultipart(args.data, args.files), - onSuccess: (job) => { - toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`) - onClose() - }, - onError: (error) => { - toast.error(getErrorMessage(error)) - }, - }) - const activeCaselle = useMemo((): MailboxSelectItem[] => { const regularActive: MailboxSelectItem[] = ( mailboxesData?.items.filter((m) => m.status === 'active') || [] @@ -179,6 +282,33 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) { const isLoadingMailboxes = mailboxesLoading || vboxMailboxesLoading + // ── Filtro propria email per "Rispondi a tutti" ─────────────────────────── + // Eseguito una sola volta dopo che le caselle si caricano e la mailbox e' selezionata + useEffect(() => { + if (!replyAll || !replyTo || isLoadingMailboxes || !watchedMailboxId || ownEmailFilteredRef.current) return + const ownMailbox = activeCaselle.find((m) => m.id === watchedMailboxId) + if (!ownMailbox) return + ownEmailFilteredRef.current = true + const ownEmail = ownMailbox.email_address.toLowerCase() + const current = toFields.map((f) => f.value) + const filtered = current.filter((v) => v.toLowerCase() !== ownEmail) + if (filtered.length === 0) filtered.push('') + replaceTo(filtered.map((v) => ({ value: v }))) + }, [replyAll, replyTo, isLoadingMailboxes, watchedMailboxId, activeCaselle, toFields, replaceTo]) + // ───────────────────────────────────────────────────────────────────────── + + const sendMutation = useMutation({ + mutationFn: (args: { data: Parameters[0]; files: File[] }) => + sendApi.sendMultipart(args.data, args.files), + onSuccess: (job) => { + toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`) + onClose() + }, + onError: (error) => { + toast.error(getErrorMessage(error)) + }, + }) + const handleFileAdd = (files: FileList | null) => { if (!files) return const valid: File[] = [] @@ -299,6 +429,10 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) { pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' }, })} /> + setValue(`to_addresses.${idx}.value`, email)} + /> {toFields.length > 1 && ( + + + {/* Pannello selezione template */} + {showTemplatePicker && ( +
+ {templatesLoading ? ( +
+ Caricamento template... +
+ ) : !templatesData?.items.length ? ( +
+ Nessun template disponibile. Creane uno in Amministrazione. +
+ ) : ( +
+ {templatesData.items.map((tpl) => ( + + ))} +
+ )} +
+ )} + { @@ -484,10 +673,15 @@ export function ComposeModal() { if (!isOpen) return null - const title = replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC' + const title = replyAll + ? 'Rispondi a tutti' + : replyTo + ? 'Rispondi a PEC' + : forwardOf + ? 'Inoltra PEC' + : 'Nuova PEC' const subtitle = replyTo?.subject || forwardOf?.subject || null - // Stile inline per il posizionamento (piu' affidabile delle classi Tailwind dinamiche) const containerStyle = (() => { if (mode === 'fullscreen') { return { @@ -507,7 +701,6 @@ export function ComposeModal() { height: '48px', } } - // normal return { position: 'fixed' as const, bottom: 0, @@ -582,13 +775,14 @@ export function ComposeModal() { {mode !== 'minimized' && (
{/* - Key basata su replyTo/forwardOf: garantisce che il form venga rimontato - (e quindi resettato) ogni volta che si apre per un messaggio diverso. + Key basata su replyTo/forwardOf/replyAll: garantisce che il form venga rimontato + ogni volta che si apre per un messaggio diverso o cambia la modalita'. */}
diff --git a/frontend/src/components/ComposeModal/ContactPickerPopover.tsx b/frontend/src/components/ComposeModal/ContactPickerPopover.tsx new file mode 100644 index 0000000..b86a7ad --- /dev/null +++ b/frontend/src/components/ComposeModal/ContactPickerPopover.tsx @@ -0,0 +1,167 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { BookUser, Search, Star, User } from 'lucide-react' +import { contactsApi, type PecContactResponse } from '@/api/contacts.api' + +interface ContactPickerPopoverProps { + onSelect: (email: string) => void + size?: 'sm' | 'md' +} + +function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + return debounced +} + +export function ContactPickerPopover({ onSelect, size = 'md' }: ContactPickerPopoverProps) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const containerRef = useRef(null) + const inputRef = useRef(null) + const debouncedQuery = useDebounce(query, 250) + + // Chiudi il popover cliccando fuori + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + // Focus sull'input quando si apre + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 50) + } else { + setQuery('') + setResults([]) + } + }, [open]) + + // Ricerca contatti + const fetchContacts = useCallback(async (q: string) => { + setLoading(true) + try { + if (q.trim().length >= 1) { + const data = await contactsApi.autocomplete(q.trim()) + setResults(data) + } else { + // Senza query: mostra tutti (primi 20 ordinati per preferiti) + const data = await contactsApi.list({ page_size: 20 }) + setResults(data.items) + } + } catch { + setResults([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (open) { + fetchContacts(debouncedQuery) + } + }, [debouncedQuery, open, fetchContacts]) + + const handleSelect = (contact: PecContactResponse) => { + onSelect(contact.email) + setOpen(false) + } + + const isSm = size === 'sm' + + return ( +
+ {/* Bottone apertura */} + + + {/* Popover */} + {open && ( +
+ {/* Campo ricerca */} +
+ + setQuery(e.target.value)} + placeholder="Cerca nella rubrica..." + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ + {/* Lista risultati */} +
+ {loading ? ( +
+ Ricerca in corso... +
+ ) : results.length === 0 ? ( +
+ {query.trim() ? 'Nessun contatto trovato' : 'Rubrica vuota'} +
+ ) : ( + results.map((contact) => ( + + )) + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 5f1c37d..e30510c 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -58,10 +58,12 @@ import { Settings2, BookUser, Calendar, + PenLine, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' import { useInboxStore } from '@/store/inbox.store' +import { useComposeStore } from '@/store/compose.store' import { useState } from 'react' import toast from 'react-hot-toast' import { useQuery } from '@tanstack/react-query' @@ -83,6 +85,7 @@ export function Sidebar() { const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth() const unreadCount = useInboxStore((s) => s.unreadCount) + const openCompose = useComposeStore((s) => s.openCompose) // Le caselle PEC vengono caricate qui e condivise via React Query cache const { data: mailboxesData } = useQuery({ @@ -482,22 +485,19 @@ export function Sidebar() {
- - 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', - ) - } +
@@ -556,6 +556,7 @@ export function Sidebar() { { to: '/virtual-boxes', label: 'Virtual Box', icon: Filter }, { to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 }, { to: '/templates', label: 'Template messaggi', icon: FileText }, + { to: '/signatures', label: 'Firme automatiche', icon: PenLine }, { to: '/notifications', label: 'Notifiche', icon: Bell }, { to: '/audit-log', label: 'Audit Log', icon: ClipboardList }, ] as const).map((item) => ( diff --git a/frontend/src/pages/AuditLog/AuditLogPage.tsx b/frontend/src/pages/AuditLog/AuditLogPage.tsx index 38234e2..fe29f70 100644 --- a/frontend/src/pages/AuditLog/AuditLogPage.tsx +++ b/frontend/src/pages/AuditLog/AuditLogPage.tsx @@ -1,18 +1,60 @@ /** - * Pagina Audit Log – visualizzazione eventi di sistema. + * Pagina Audit Log – visualizzazione ed esportazione eventi di sistema. * * Accessibile solo ad admin e super_admin. * Mostra una tabella paginata con filtri per data, azione ed esito. + * Permette di esportare i risultati in CSV o PDF. */ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { format } from 'date-fns' import { it } from 'date-fns/locale' -import { ShieldCheck, AlertCircle, Search, RotateCcw } from 'lucide-react' +import { ShieldCheck, AlertCircle, Search, RotateCcw, Download, FileText } from 'lucide-react' import { auditLogApi } from '@/api/audit_log.api' import type { AuditLogParams } from '@/api/audit_log.api' import { cn } from '@/lib/utils' +import toast from 'react-hot-toast' + +// ─── Lista completa degli eventi monitorati ─────────────────────────────────── + +const ACTION_OPTIONS: { value: string; label: string; group: string }[] = [ + // Auth + { value: 'auth.login', label: 'Login', group: 'Autenticazione' }, + { value: 'auth.password_changed', label: 'Cambio password', group: 'Autenticazione' }, + // Utenti + { value: 'user.created', label: 'Utente creato', group: 'Utenti' }, + { value: 'user.updated', label: 'Utente modificato', group: 'Utenti' }, + { value: 'user.deleted', label: 'Utente eliminato', group: 'Utenti' }, + // Caselle + { value: 'mailbox.created', label: 'Casella creata', group: 'Caselle PEC' }, + { value: 'mailbox.updated', label: 'Casella modificata', group: 'Caselle PEC' }, + { value: 'mailbox.deleted', label: 'Casella eliminata', group: 'Caselle PEC' }, + // Messaggi - invio + { value: 'message.sent', label: 'PEC inviata', group: 'Messaggi' }, + // Messaggi - stato + { value: 'message.read', label: 'Messaggio letto', group: 'Messaggi' }, + { value: 'message.unread', label: 'Segnato non letto', group: 'Messaggi' }, + { value: 'message.opened', label: 'Messaggio aperto', group: 'Messaggi' }, + { value: 'message.starred', label: 'Aggiunto ai preferiti', group: 'Messaggi' }, + { value: 'message.unstarred', label: 'Rimosso dai preferiti', group: 'Messaggi' }, + { value: 'message.archived', label: 'Archiviato', group: 'Messaggi' }, + { value: 'message.unarchived', label: 'Ripristinato da archivio', group: 'Messaggi' }, + { value: 'message.trashed', label: 'Spostato nel cestino', group: 'Messaggi' }, + { value: 'message.restored', label: 'Ripristinato dal cestino', group: 'Messaggi' }, + { value: 'message.bulk_updated', label: 'Aggiornamento massivo', group: 'Messaggi' }, + // Messaggi - allegati + { value: 'message.attachment_downloaded', label: 'Allegato scaricato', group: 'Messaggi' }, + { value: 'message.package_downloaded', label: 'Pacchetto PEC scaricato', group: 'Messaggi' }, + // Conservazione + { value: 'message.pending_conservation', label: 'Messa in conservazione', group: 'Conservazione' }, + { value: 'message.conserved', label: 'Conservata', group: 'Conservazione' }, + { value: 'message.conservation_cancelled', label: 'Conservazione annullata', group: 'Conservazione' }, + { value: 'message.conservation_removed', label: 'Rimossa dalla conservazione', group: 'Conservazione' }, +] + +// Raggruppamento per group +const ACTION_GROUPS = Array.from(new Set(ACTION_OPTIONS.map((o) => o.group))) // ─── Badge esito ────────────────────────────────────────────────────────────── @@ -40,18 +82,20 @@ function OutcomeBadge({ outcome }: { outcome: string }) { // ─── Etichetta azione leggibile ─────────────────────────────────────────────── function actionLabel(action: string): string { - const map: Record = { - 'auth.login': 'Login', - 'auth.password_changed': 'Cambio password', - 'user.created': 'Utente creato', - 'user.updated': 'Utente modificato', - 'user.deleted': 'Utente eliminato', - 'mailbox.created': 'Casella creata', - 'mailbox.updated': 'Casella modificata', - 'mailbox.deleted': 'Casella eliminata', - 'message.sent': 'PEC inviata', - } - return map[action] ?? action + return ACTION_OPTIONS.find((o) => o.value === action)?.label ?? action +} + +// ─── Helper download ───────────────────────────────────────────────────────── + +function triggerDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) } // ─── Componente principale ──────────────────────────────────────────────────── @@ -69,6 +113,10 @@ export function AuditLogPage() { // Parametri query attivi (applicati al click su "Cerca") const [activeParams, setActiveParams] = useState({}) + // Stato export + const [exportingCsv, setExportingCsv] = useState(false) + const [exportingPdf, setExportingPdf] = useState(false) + const { data, isLoading, isError } = useQuery({ queryKey: ['audit-log', page, activeParams], queryFn: () => @@ -99,6 +147,23 @@ export function AuditLogPage() { setPage(1) } + const handleExport = async (format: 'csv' | 'pdf') => { + const setter = format === 'csv' ? setExportingCsv : setExportingPdf + setter(true) + try { + const blob = await auditLogApi.export(format, activeParams) + const ext = format === 'csv' ? 'csv' : 'pdf' + const suffix = activeParams.date_from + ? `_dal_${activeParams.date_from.slice(0, 10).replace(/-/g, '')}` + : '' + triggerDownload(blob, `audit_log${suffix}.${ext}`) + } catch { + toast.error(`Errore durante l'esportazione ${format.toUpperCase()}`) + } finally { + setter(false) + } + } + const items = data?.items ?? [] const total = data?.total ?? 0 const pages = data?.pages ?? 1 @@ -125,15 +190,15 @@ export function AuditLogPage() { className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" > - - - - - - - - - + {ACTION_GROUPS.map((group) => ( + + {ACTION_OPTIONS.filter((o) => o.group === group).map((o) => ( + + ))} + + ))}
@@ -175,7 +240,7 @@ export function AuditLogPage() { {/* Bottoni */} -
+
+ + {/* Separatore */} +
+ + {/* Export CSV */} + + + {/* Export PDF */} +
@@ -293,10 +381,21 @@ export function AuditLogPage() { {entry.ip_address ?? } - {/* Utente (UUID abbreviato) */} - - {entry.user_id ? ( - + {/* Utente: mostra nome, poi email, poi UUID abbreviato */} + + {entry.user_full_name ? ( + + {entry.user_full_name} + {entry.user_email && ( + + ({entry.user_email}) + + )} + + ) : entry.user_email ? ( + {entry.user_email} + ) : entry.user_id ? ( + {entry.user_id.split('-')[0]}... ) : ( diff --git a/frontend/src/pages/Compose/ComposePage.tsx b/frontend/src/pages/Compose/ComposePage.tsx index f76da4e..698a657 100644 --- a/frontend/src/pages/Compose/ComposePage.tsx +++ b/frontend/src/pages/Compose/ComposePage.tsx @@ -1,7 +1,8 @@ -import { useState, useRef, useMemo } from 'react' +import { useState, useRef, useMemo, useEffect } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { useForm, useFieldArray } from 'react-hook-form' -import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter } from 'lucide-react' +import { useForm, useFieldArray, useWatch } from 'react-hook-form' +import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter, FileText, ChevronDown } from 'lucide-react' +import { ContactPickerPopover } from '@/components/ComposeModal/ContactPickerPopover' import { useQuery, useMutation } from '@tanstack/react-query' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' @@ -11,9 +12,29 @@ import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' import { sendApi } from '@/api/send.api' import { mailboxesApi } from '@/api/mailboxes.api' import { virtualBoxesApi } from '@/api/virtual_boxes.api' +import { signaturesApi } from '@/api/signatures.api' +import { templatesApi } from '@/api/templates.api' +import type { TemplateResponse } from '@/api/templates.api' import { getErrorMessage } from '@/api/client' import type { MessageResponse } from '@/types/api.types' +// ─── Utilita' firma ─────────────────────────────────────────────────────────── + +const SIG_ATTR = 'data-pechub-sig' + +function injectSignature(body: string, sigHtml: string | null): string { + const withoutSig = body.replace( + new RegExp(`]*${SIG_ATTR}="1"[^>]*>[\\s\\S]*?<\\/div>`, 'g'), + '' + ) + if (!sigHtml) return withoutSig + const sigBlock = `
${sigHtml}
` + if (withoutSig.includes('
')) { + return withoutSig.replace('
', sigBlock + '
') + } + return withoutSig + sigBlock +} + /** Tipo unificato casella mittente (sia da permessi diretti che da Virtual Box) */ interface MailboxSelectItem { id: string @@ -99,6 +120,7 @@ export function ComposePage() { register, control, handleSubmit, + setValue, formState: { errors }, } = useForm({ defaultValues: { @@ -127,6 +149,56 @@ export function ComposePage() { remove: removeCc, } = useFieldArray({ control, name: 'cc_addresses' }) + // ── Auto-inserimento firma ──────────────────────────────────────────────── + const watchedMailboxId = useWatch({ control, name: 'mailbox_id' }) + const signatureContext = replyTo ? 'reply' : 'compose' + // Ref per tenere traccia dell'HTML firma corrente (usato da applyTemplate) + const sigHtmlRef = useRef(null) + + useEffect(() => { + if (!watchedMailboxId) { + sigHtmlRef.current = null + setBodyHtml((prev) => injectSignature(prev, null)) + return + } + signaturesApi + .resolve({ context: signatureContext, mailbox_id: watchedMailboxId }) + .then((sig) => { + sigHtmlRef.current = sig?.body_html ?? null + setBodyHtml((prev) => injectSignature(prev, sig?.body_html ?? null)) + }) + .catch(() => { + sigHtmlRef.current = null + }) + }, [watchedMailboxId, signatureContext]) + // ───────────────────────────────────────────────────────────────────────── + + // ── Selettore template ──────────────────────────────────────────────────── + const [showTemplatePicker, setShowTemplatePicker] = useState(false) + + const { data: templatesData, isLoading: templatesLoading } = useQuery({ + queryKey: ['templates'], + queryFn: () => templatesApi.list(), + enabled: showTemplatePicker, + staleTime: 60 * 1000, + }) + + const applyTemplate = (tpl: TemplateResponse) => { + // Preserva la parte citata (tutto dal separatore
in poi) + const hrIndex = bodyHtml.indexOf('
') + const quotedPart = hrIndex >= 0 ? bodyHtml.slice(hrIndex) : '' + const templateBody = tpl.body_html || (tpl.body_text ? `

${tpl.body_text}

` : '') + const newBodyBase = templateBody + quotedPart + setBodyHtml(injectSignature(newBodyBase, sigHtmlRef.current)) + // Applica oggetto solo su nuova PEC (non su risposta/inoltro) + if (!replyTo && !forwardOf && tpl.subject) { + setValue('subject', tpl.subject) + } + setShowTemplatePicker(false) + toast.success(`Template "${tpl.name}" applicato`) + } + // ───────────────────────────────────────────────────────────────────────── + // Carica caselle disponibili per l'invio (permessi diretti) const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({ queryKey: ['mailboxes'], @@ -350,6 +422,10 @@ export function ComposePage() { }, })} /> + setValue(`to_addresses.${idx}.value`, email)} + /> {toFields.length > 1 && ( +
+ + {/* Pannello selezione template */} + {showTemplatePicker && ( +
+ {templatesLoading ? ( +
+ Caricamento template... +
+ ) : !templatesData?.items.length ? ( +
+ Nessun template disponibile. Creane uno nella sezione Amministrazione. +
+ ) : ( +
+ {templatesData.items.map((tpl) => ( + + ))} +
+ )} +
+ )} + m.id === message.mailbox_id)?.email_address : undefined } + searchTerm={debouncedSearch || undefined} /> ))} @@ -975,6 +979,7 @@ interface MessageRowProps { onToggleTrash: (e: React.MouseEvent) => void onToggleConserve: (e: React.MouseEvent) => void mailboxName?: string + searchTerm?: string } function MessageRow({ @@ -991,6 +996,7 @@ function MessageRow({ onToggleTrash, onToggleConserve, mailboxName, + searchTerm, }: MessageRowProps) { const [hovered, setHovered] = useState(false) const isUnread = !message.is_read && message.direction === 'inbound' @@ -998,7 +1004,8 @@ function MessageRow({ return (
)} + + {/* Badge "Trovato in" (solo durante ricerca attiva con match info) */} + {searchTerm && message.search_match && ( +
+ Trovato in: + {message.search_match.in_subject && ( + + + Oggetto + + )} + {message.search_match.in_body && ( + + + Corpo del messaggio + + )} + {message.search_match.in_attachments.map((att) => ( + + + {att.filename} + + ))} +
+ )}
{/* ── Azioni rapide (visibili su hover) ── */} diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index 88f2b68..49b1962 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { @@ -8,6 +8,7 @@ import { ArchiveX, Download, Reply, + ReplyAll, Forward, Paperclip, Mail, @@ -22,6 +23,7 @@ import { Eye, MessageSquare, X, + ChevronDown, } from 'lucide-react' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' @@ -168,6 +170,21 @@ export function MessageDetailPage() { const [isDownloadingPackage, setIsDownloadingPackage] = useState(false) const [isPrinting, setIsPrinting] = useState(false) + // Dropdown "Rispondi / Rispondi a tutti" + const [showReplyDropdown, setShowReplyDropdown] = useState(false) + const replyDropdownRef = useRef(null) + + useEffect(() => { + if (!showReplyDropdown) return + const handler = (e: MouseEvent) => { + if (replyDropdownRef.current && !replyDropdownRef.current.contains(e.target as Node)) { + setShowReplyDropdown(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [showReplyDropdown]) + // Feature 4: Deadline const [showDeadlineForm, setShowDeadlineForm] = useState(false) const [deadlineDate, setDeadlineDate] = useState('') @@ -533,17 +550,66 @@ export function MessageDetailPage() { )} - {/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */} - {message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && ( - - )} + {/* Rispondi / Rispondi a tutti (solo per messaggi inbound PEC certificata, non nel cestino) */} + {message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (() => { + const hasMultipleRecipients = + (message.to_addresses?.length ?? 0) > 1 || + (message.cc_addresses?.length ?? 0) > 0 + return hasMultipleRecipients ? ( + /* Split button con dropdown */ +
+ {/* Parte sinistra: "Rispondi" */} + + {/* Parte destra: freccia dropdown */} + + {/* Dropdown */} + {showReplyDropdown && ( +
+ + +
+ )} +
+ ) : ( + /* Pulsante semplice senza tendina */ + + ) + })()} {/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */} {message.pec_type === 'posta_certificata' && !message.is_trashed && ( diff --git a/frontend/src/pages/Search/SearchPage.tsx b/frontend/src/pages/Search/SearchPage.tsx index d6d639c..1cf107d 100644 --- a/frontend/src/pages/Search/SearchPage.tsx +++ b/frontend/src/pages/Search/SearchPage.tsx @@ -26,6 +26,9 @@ import { Mail, ChevronLeft, ChevronRight, + FileText, + Paperclip, + AlignLeft, } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/ui/Button' @@ -539,6 +542,35 @@ function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchRe

)} + {/* Riga 4: badge "Trovato in" (solo durante ricerca con match info) */} + {searchTerm && message.search_match && ( +
+ Trovato in: + {message.search_match.in_subject && ( + + + Oggetto + + )} + {message.search_match.in_body && ( + + + Corpo del messaggio + + )} + {message.search_match.in_attachments.map((att) => ( + + + {att.filename} + + ))} +
+ )} + {/* Tag */} {message.labels && message.labels.length > 0 && (
diff --git a/frontend/src/pages/Signatures/SignaturesPage.tsx b/frontend/src/pages/Signatures/SignaturesPage.tsx new file mode 100644 index 0000000..72e31db --- /dev/null +++ b/frontend/src/pages/Signatures/SignaturesPage.tsx @@ -0,0 +1,661 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Plus, Pencil, Trash2, PenLine, Search } from 'lucide-react' +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 { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' +import { + signaturesApi, + type SignatureResponse, + type SignatureCreate, + type SignatureContext, +} from '@/api/signatures.api' +import { mailboxesApi } from '@/api/mailboxes.api' +import { virtualBoxesApi } from '@/api/virtual_boxes.api' +import { getErrorMessage } from '@/api/client' +import { formatDate } from '@/lib/utils' +import { useAuth } from '@/hooks/useAuth' + +// ─── Tipi locali ────────────────────────────────────────────────────────────── + +type Tab = 'library' | 'mailboxes' | 'vboxes' + +// ─── Componente principale ──────────────────────────────────────────────────── + +export function SignaturesPage() { + const queryClient = useQueryClient() + const { isAdmin } = useAuth() + const [activeTab, setActiveTab] = useState('library') + + return ( +
+ {/* Header */} +
+
+
+

Firme automatiche

+

+ Gestisci le firme inserite automaticamente nelle PEC inviate +

+
+
+ + {/* Tab bar */} +
+ {[ + { id: 'library' as Tab, label: 'Libreria firme' }, + { id: 'mailboxes' as Tab, label: 'Caselle PEC' }, + { id: 'vboxes' as Tab, label: 'Virtual Box' }, + ].map((tab) => ( + + ))} +
+
+ + {/* Contenuto tab */} +
+ {activeTab === 'library' && } + {activeTab === 'mailboxes' && } + {activeTab === 'vboxes' && } +
+
+ ) +} + +// ─── Tab: Libreria firme ────────────────────────────────────────────────────── + +function LibraryTab({ isAdmin, queryClient }: { isAdmin: boolean; queryClient: ReturnType }) { + const [q, setQ] = useState('') + const [showForm, setShowForm] = useState(false) + const [editing, setEditing] = useState(null) + + // Stato form + const [formName, setFormName] = useState('') + const [formDescription, setFormDescription] = useState('') + const [formBody, setFormBody] = useState('') + + const { data, isLoading } = useQuery({ + queryKey: ['signatures', q], + queryFn: () => signaturesApi.list(q || undefined), + }) + + const createMutation = useMutation({ + mutationFn: (data: SignatureCreate) => signaturesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['signatures'] }) + toast.success('Firma creata') + closeForm() + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: SignatureCreate }) => + signaturesApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['signatures'] }) + toast.success('Firma aggiornata') + closeForm() + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => signaturesApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['signatures'] }) + toast.success('Firma eliminata') + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + + const openCreate = () => { + setEditing(null) + setFormName('') + setFormDescription('') + setFormBody('') + setShowForm(true) + } + + const openEdit = (s: SignatureResponse) => { + setEditing(s) + setFormName(s.name) + setFormDescription(s.description ?? '') + setFormBody(s.body_html ?? s.body_text ?? '') + setShowForm(true) + } + + const closeForm = () => { + setShowForm(false) + setEditing(null) + } + + const handleSubmit = () => { + if (!formName.trim()) return toast.error('Il nome e\' obbligatorio') + const payload: SignatureCreate = { + name: formName.trim(), + description: formDescription.trim() || null, + body_html: formBody || null, + body_text: null, + } + if (editing) { + updateMutation.mutate({ id: editing.id, data: payload }) + } else { + createMutation.mutate(payload) + } + } + + const items = data?.items ?? [] + + return ( +
+
+
+ + setQ(e.target.value)} + className="pl-9" + /> +
+ {isAdmin && ( + + )} +
+ + {isLoading ? ( +
+
+
+ ) : items.length === 0 ? ( +
+ +

Nessuna firma trovata

+

+ {isAdmin + ? 'Crea la prima firma con il pulsante in alto.' + : 'Nessuna firma disponibile.'} +

+
+ ) : ( +
+ {items.map((s) => ( +
+
+
+

{s.name}

+ {s.description && ( +

{s.description}

+ )} +
+ {isAdmin && ( +
+ + +
+ )} +
+ + {/* Anteprima corpo */} + {s.body_html && ( +
+ )} + +

+ Aggiornato: {formatDate(s.updated_at)} +

+
+ ))} +
+ )} + + {/* Dialog form firma */} + !o && closeForm()}> + + + {editing ? 'Modifica firma' : 'Nuova firma'} + + +
+
+ + setFormName(e.target.value)} + placeholder="Es. Firma aziendale standard" + /> +
+
+ + setFormDescription(e.target.value)} + placeholder="Breve descrizione d'uso" + /> +
+
+ +
+ +
+
+
+ + + + + +
+
+
+ ) +} + +// ─── Tab: Assegnazioni caselle PEC ──────────────────────────────────────────── + +function MailboxAssignmentsTab({ isAdmin }: { isAdmin: boolean }) { + const queryClient = useQueryClient() + + const { data: mailboxesData, isLoading: loadingMailboxes } = useQuery({ + queryKey: ['mailboxes'], + queryFn: () => mailboxesApi.list(), + }) + + const { data: signaturesData, isLoading: loadingSignatures } = useQuery({ + queryKey: ['signatures', ''], + queryFn: () => signaturesApi.list(), + }) + + const { data: assignmentsData, isLoading: loadingAssignments } = useQuery({ + queryKey: ['signature-assignments'], + queryFn: () => signaturesApi.listAssignments(), + }) + + const assignMutation = useMutation({ + mutationFn: signaturesApi.createAssignment, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['signature-assignments'] }) + toast.success('Firma assegnata') + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + + const removeMutation = useMutation({ + mutationFn: signaturesApi.deleteAssignment, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['signature-assignments'] }) + toast.success('Assegnazione rimossa') + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + + const mailboxes = mailboxesData?.items ?? [] + const signatures = signaturesData?.items ?? [] + const assignments = assignmentsData?.items ?? [] + + // Mappa: mailbox_id + context -> assignment + const assignmentMap = new Map() + for (const a of assignments) { + if (a.mailbox_id) { + assignmentMap.set(`${a.mailbox_id}:${a.context}`, a) + } + } + + const getAssignment = (mailboxId: string, context: SignatureContext) => + assignmentMap.get(`${mailboxId}:${context}`) ?? null + + const handleChange = (mailboxId: string, context: SignatureContext, signatureId: string) => { + if (!signatureId) { + // Rimuovi assegnazione esistente + const existing = getAssignment(mailboxId, context) + if (existing) removeMutation.mutate(existing.id) + } else { + assignMutation.mutate({ signature_id: signatureId, mailbox_id: mailboxId, context }) + } + } + + const isLoading = loadingMailboxes || loadingSignatures || loadingAssignments + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (mailboxes.length === 0) { + return ( +
+

Nessuna casella PEC configurata

+

Aggiungi prima le caselle PEC nella sezione Caselle PEC.

+
+ ) + } + + return ( +
+

+ Assegna una firma per ogni casella PEC e per ogni contesto d'uso. + {!isAdmin && ' Solo gli amministratori possono modificare le assegnazioni.'} +

+ +
+ + + + + + + + + + {mailboxes.map((mb) => ( + handleChange(mb.id, 'reply', sigId)} + onChangeCompose={(sigId) => handleChange(mb.id, 'compose', sigId)} + isPending={assignMutation.isPending || removeMutation.isPending} + /> + ))} + +
Casella PECFirma per RispostaFirma per Nuova PEC
+
+
+ ) +} + +interface MailboxAssignmentRowProps { + mailboxId: string + displayName: string + emailAddress: string + status: string + signatures: SignatureResponse[] + replyAssignment: { id: string; signature_id: string } | null + composeAssignment: { id: string; signature_id: string } | null + isAdmin: boolean + onChangeReply: (sigId: string) => void + onChangeCompose: (sigId: string) => void + isPending: boolean +} + +function MailboxAssignmentRow({ + displayName, + emailAddress, + status, + signatures, + replyAssignment, + composeAssignment, + isAdmin, + onChangeReply, + onChangeCompose, + isPending, +}: MailboxAssignmentRowProps) { + const statusDot = + status === 'active' + ? 'bg-green-500' + : status === 'paused' + ? 'bg-yellow-400' + : status === 'error' + ? 'bg-red-500' + : 'bg-gray-400' + + return ( + + +
+ +
+

{displayName}

+ {displayName !== emailAddress && ( +

{emailAddress}

+ )} +
+
+ + + + + + + + + ) +} + +// ─── Tab: Assegnazioni Virtual Box ──────────────────────────────────────────── + +function VboxAssignmentsTab({ isAdmin }: { isAdmin: boolean }) { + const queryClient = useQueryClient() + + const { data: vboxesData, isLoading: loadingVboxes } = useQuery({ + queryKey: ['virtual-boxes', 'all'], + queryFn: () => virtualBoxesApi.list(), + }) + + const { data: signaturesData, isLoading: loadingSignatures } = useQuery({ + queryKey: ['signatures', ''], + queryFn: () => signaturesApi.list(), + }) + + const { data: assignmentsData, isLoading: loadingAssignments } = useQuery({ + queryKey: ['signature-assignments'], + queryFn: () => signaturesApi.listAssignments(), + }) + + const assignMutation = useMutation({ + mutationFn: signaturesApi.createAssignment, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['signature-assignments'] }) + toast.success('Firma assegnata') + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + + const removeMutation = useMutation({ + mutationFn: signaturesApi.deleteAssignment, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['signature-assignments'] }) + toast.success('Assegnazione rimossa') + }, + onError: (e) => toast.error(getErrorMessage(e)), + }) + + const vboxes = vboxesData?.items ?? [] + const signatures = signaturesData?.items ?? [] + const assignments = assignmentsData?.items ?? [] + + // Mappa: virtual_box_id + context -> assignment + const assignmentMap = new Map() + for (const a of assignments) { + if (a.virtual_box_id) { + assignmentMap.set(`${a.virtual_box_id}:${a.context}`, a) + } + } + + const getAssignment = (vboxId: string, context: SignatureContext) => + assignmentMap.get(`${vboxId}:${context}`) ?? null + + const handleChange = (vboxId: string, context: SignatureContext, signatureId: string) => { + if (!signatureId) { + const existing = getAssignment(vboxId, context) + if (existing) removeMutation.mutate(existing.id) + } else { + assignMutation.mutate({ signature_id: signatureId, virtual_box_id: vboxId, context }) + } + } + + const isLoading = loadingVboxes || loadingSignatures || loadingAssignments + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (vboxes.length === 0) { + return ( +
+

Nessuna Virtual Box configurata

+

Aggiungi prima le Virtual Box nella sezione Virtual Box.

+
+ ) + } + + return ( +
+

+ Assegna una firma per ogni Virtual Box e per ogni contesto d'uso. + {!isAdmin && ' Solo gli amministratori possono modificare le assegnazioni.'} +

+ +
+ + + + + + + + + + {vboxes.map((vbox) => ( + + + + + + ))} + +
Virtual BoxFirma per RispostaFirma per Nuova PEC
+
+
+ {(vbox.label || vbox.name)[0]?.toUpperCase() ?? '?'} +
+
+

{vbox.label || vbox.name}

+ {vbox.description && ( +

+ {vbox.description} +

+ )} +
+
+
+ handleChange(vbox.id, 'reply', sigId)} + disabled={!isAdmin || assignMutation.isPending || removeMutation.isPending} + /> + + handleChange(vbox.id, 'compose', sigId)} + disabled={!isAdmin || assignMutation.isPending || removeMutation.isPending} + /> +
+
+
+ ) +} + +// ─── Select firma riutilizzabile ────────────────────────────────────────────── + +interface SignatureSelectProps { + signatures: SignatureResponse[] + value: string + onChange: (signatureId: string) => void + disabled?: boolean +} + +function SignatureSelect({ signatures, value, onChange, disabled }: SignatureSelectProps) { + return ( + + ) +} diff --git a/frontend/src/store/compose.store.ts b/frontend/src/store/compose.store.ts index ba58b19..a0eae4c 100644 --- a/frontend/src/store/compose.store.ts +++ b/frontend/src/store/compose.store.ts @@ -12,8 +12,9 @@ interface ComposeState { mode: ComposeMode replyTo: MessageResponse | null forwardOf: MessageResponse | null + replyAll: boolean - openCompose: (opts?: { replyTo?: MessageResponse; forwardOf?: MessageResponse }) => void + openCompose: (opts?: { replyTo?: MessageResponse; forwardOf?: MessageResponse; replyAll?: boolean }) => void closeCompose: () => void setMode: (mode: ComposeMode) => void } @@ -23,6 +24,7 @@ export const useComposeStore = create()((set) => ({ mode: 'normal', replyTo: null, forwardOf: null, + replyAll: false, openCompose: (opts) => set({ @@ -30,6 +32,7 @@ export const useComposeStore = create()((set) => ({ mode: 'normal', replyTo: opts?.replyTo ?? null, forwardOf: opts?.forwardOf ?? null, + replyAll: opts?.replyAll ?? false, }), closeCompose: () => @@ -38,6 +41,7 @@ export const useComposeStore = create()((set) => ({ mode: 'normal', replyTo: null, forwardOf: null, + replyAll: false, }), setMode: (mode) => set({ mode }), diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index a06e14a..b3cea6a 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -211,6 +211,19 @@ export interface MessageBulkLabelResponse { updated: number } +// ─── Search match info ──────────────────────────────────────────────────────── + +export interface AttachmentMatchInfo { + id: string + filename: string +} + +export interface SearchMatchInfo { + in_subject: boolean + in_body: boolean + in_attachments: AttachmentMatchInfo[] +} + // ─── Message ────────────────────────────────────────────────────────────────── export type PecDirection = 'inbound' | 'outbound' @@ -270,6 +283,8 @@ export interface MessageResponse { created_at: string updated_at: string labels: LabelResponse[] + // Popolato solo nelle risposte di ricerca full-text + search_match?: SearchMatchInfo | null } export interface MessageListResponse { diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf index 6378391..95ed52a 100644 --- a/infra/nginx/nginx.conf +++ b/infra/nginx/nginx.conf @@ -37,6 +37,15 @@ http { # Hide nginx version server_tokens off; + # IP reale del client: legge X-Forwarded-For impostato da Nginx Proxy Manager + # Fidati dell'IP interno di NPM e delle subnet Docker + set_real_ip_from 10.0.30.254; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + # Rate limiting zones limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m; diff --git a/worker/app/imap/sync.py b/worker/app/imap/sync.py index 868f9b6..1111dbe 100644 --- a/worker/app/imap/sync.py +++ b/worker/app/imap/sync.py @@ -528,6 +528,8 @@ async def _save_message( - Salvataggio allegati su MinIO + tabella attachments - State machine outbound: solo per messaggi inbound (ricevute PEC) - Collegamento parent_message_id via X-Riferimento-Message-ID + - Dedup outbound: evita duplicati quando un messaggio inviato via send_pec + viene poi trovato anche nella cartella Sent del server IMAP """ # ── Idempotenza: chiave composta (mailbox_id + imap_uid + imap_folder) ──── existing = await db.execute( @@ -552,17 +554,73 @@ async def _save_message( parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt) received_at = datetime.now(UTC) + # ── Dedup outbound: upsert sul record send_pec invece di creare duplicato ─ + # Problema: send_pec crea un record outbound con imap_uid=NULL e poi + # la sync della cartella Sent trova lo stesso messaggio e vorrebbe creare + # un secondo record con lo stesso message_id_header. I duplicati rompono + # il binding delle ricevute (_apply_outbound_state_machine usava + # scalar_one_or_none() che esplode con MultipleResultsFound). + # Soluzione: se esiste già un record outbound con lo stesso message_id_header + # e imap_uid=NULL (il record canonico di send_pec), aggiorniamo quel record + # con l'imap_uid/imap_folder della Sent folder invece di crearne uno nuovo. + if direction == "outbound" and parsed.message_id: + existing_outbound = await db.execute( + select(Message).where( + Message.mailbox_id == mailbox.id, + Message.message_id_header == parsed.message_id, + Message.direction == "outbound", + Message.imap_uid.is_(None), + ) + ) + send_pec_record = existing_outbound.scalar_one_or_none() + if send_pec_record: + # Aggiorna il record esistente con i dati IMAP della cartella Sent + send_pec_record.imap_uid = uid + send_pec_record.imap_folder = imap_folder + send_pec_record.updated_at = datetime.now(UTC) + # Aggiorna anche il raw_eml_path se non è già impostato + if not send_pec_record.raw_eml_path: + try: + eml_path = await upload_eml( + tenant_id=str(mailbox.tenant_id), + mailbox_id=str(mailbox.id), + uid=uid, + eml_bytes=raw_eml, + ) + send_pec_record.raw_eml_path = eml_path + except Exception as e: + logger.warning( + f"[{mailbox.email_address}] Upload EML MinIO per record send_pec " + f"UID {uid}: {e}" + ) + await db.flush() + logger.info( + f"[{mailbox.email_address}] Sent-sync: aggiornato record send_pec " + f"message_id={parsed.message_id!r} con imap_uid={uid} " + f"folder={imap_folder!r} (evitato duplicato outbound)" + ) + return True + # ── State machine: trova e aggiorna messaggio outbound ──────────────────── # Solo per messaggi inbound che sono ricevute PEC (non per posta inviata) parent_message_id: uuid.UUID | None = None if direction == "inbound" and pec_class.is_receipt and pec_class.riferimento_message_id: - parent_message_id = await _apply_outbound_state_machine( - riferimento_message_id=pec_class.riferimento_message_id, - pec_type=pec_class.pec_type, - tenant_id=mailbox.tenant_id, - db=db, - ) + try: + parent_message_id = await _apply_outbound_state_machine( + riferimento_message_id=pec_class.riferimento_message_id, + pec_type=pec_class.pec_type, + tenant_id=mailbox.tenant_id, + db=db, + ) + except Exception as bind_err: + logger.error( + f"[{mailbox.email_address}] [receipt-binding] Errore aggiornamento stato " + f"outbound per ricevuta UID={uid} tipo={pec_class.pec_type!r}: {bind_err}", + exc_info=True, + ) + # Non interrompere il salvataggio della ricevuta: il record viene + # comunque inserito, ma senza parent_message_id. # ── Upload raw EML su MinIO ─────────────────────────────────────────────── eml_path: str | None = None @@ -700,6 +758,12 @@ async def _apply_outbound_state_machine( Cerca il messaggio outbound con message_id_header == riferimento_message_id, applica la transizione di stato se valida. + Gestisce il caso di messaggi outbound duplicati (uno creato da send_pec con + imap_uid=NULL e uno creato dalla sync della cartella Sent): in caso di multipli, + prioritizza quello con imap_uid=NULL (il record canonico creato da send_pec). + Il dedup in _save_message riduce drasticamente la probabilità di multipli, + ma questa funzione gestisce anche i casi residui per robustezza. + Returns: UUID del messaggio originale se trovato, None altrimenti. """ @@ -710,15 +774,37 @@ async def _apply_outbound_state_machine( Message.direction == "outbound", ) ) - parent_msg = result.scalar_one_or_none() + candidates = result.scalars().all() - if not parent_msg: - logger.debug( - f"Messaggio outbound non trovato per riferimento={riferimento_message_id!r} " - f"(potrebbe essere stato inviato da client diverso)" + if not candidates: + logger.warning( + f"[receipt-binding] Messaggio outbound non trovato per " + f"riferimento={riferimento_message_id!r} (ricevuta: {pec_type!r}). " + f"Potrebbe essere stato inviato da un client esterno o il message_id_header " + f"non e' ancora stato persistito." ) return None + # In presenza di duplicati (es. record send_pec + record Sent-sync), + # prioritizza il messaggio con imap_uid=NULL (quello canonico di send_pec). + parent_msg: Message | None = None + if len(candidates) == 1: + parent_msg = candidates[0] + else: + logger.warning( + f"[receipt-binding] Trovati {len(candidates)} messaggi outbound con " + f"message_id_header={riferimento_message_id!r}. " + f"Prioritizzo il record con imap_uid=NULL (send_pec)." + ) + # Priorità 1: imap_uid IS NULL (creato da send_pec) + for m in candidates: + if m.imap_uid is None: + parent_msg = m + break + # Priorità 2: qualsiasi altro (creato dalla sync Sent) + if parent_msg is None: + parent_msg = candidates[0] + new_state = apply_outbound_transition(parent_msg.state, pec_type) if new_state: old_state = parent_msg.state @@ -726,8 +812,14 @@ async def _apply_outbound_state_machine( parent_msg.updated_at = datetime.now(UTC) await db.flush() logger.info( - f"State machine outbound: {riferimento_message_id!r} " - f"{old_state!r} → {new_state!r} (ricevuta: {pec_type!r})" + f"[receipt-binding] State machine outbound: {riferimento_message_id!r} " + f"{old_state!r} -> {new_state!r} (ricevuta: {pec_type!r}, " + f"msg_id={parent_msg.id})" + ) + else: + logger.debug( + f"[receipt-binding] Nessuna transizione valida per {riferimento_message_id!r} " + f"state={parent_msg.state!r} ricevuta={pec_type!r}" ) return parent_msg.id diff --git a/worker/app/jobs/sync_mailbox.py b/worker/app/jobs/sync_mailbox.py index ebdfa58..ce5391b 100644 --- a/worker/app/jobs/sync_mailbox.py +++ b/worker/app/jobs/sync_mailbox.py @@ -7,14 +7,17 @@ Questo job viene usato per: - Retry dopo un errore (called dal pool monitor) Non sostituisce il loop IMAP continuo (IMAPConnection); è un one-shot job. + +Sincronizza sia INBOX (per rilevare ricevute PEC e messaggi in arrivo) +sia la cartella Sent (per aggiornare imap_uid sul record send_pec ed +evitare duplicati outbound che rompono il binding delle ricevute). """ import logging from typing import Any from app.database import AsyncSessionLocal -from app.imap.reconnect import ExponentialBackoff -from app.imap.sync import sync_new_messages +from app.imap.sync import sync_new_messages, sync_sent_messages from app.models import Mailbox logger = logging.getLogger(__name__) @@ -22,7 +25,7 @@ logger = logging.getLogger(__name__) async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict: """ - Job arq: sincronizza una singola casella PEC. + Job arq: sincronizza una singola casella PEC (INBOX + Sent). Args: ctx: contesto arq (contiene redis, pool reference) @@ -50,28 +53,44 @@ async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict: creds = IMAPConnection._decrypt_creds(mailbox) try: - from app.imap.connection import IMAPConnection - conn = IMAPConnection(mailbox_id=mailbox_id, redis_client=redis_client) client = await conn._connect(creds) - n = await sync_new_messages(client, mailbox, db, redis_client) + # Sync INBOX: ricevute PEC e messaggi in arrivo + n_inbox = await sync_new_messages(client, mailbox, db, redis_client) + + # Sync Sent: aggiorna imap_uid sui record send_pec e previene duplicati. + # Fondamentale per il corretto binding delle ricevute PEC successive. + n_sent = 0 + try: + n_sent = await sync_sent_messages(client, mailbox, db, redis_client) + except Exception as sent_err: + logger.warning( + f"[sync_mailbox] {mailbox.email_address} errore sync Sent " + f"(non critico): {sent_err}" + ) try: await client.logout() except Exception: pass + logger.info( + f"[sync_mailbox] {mailbox.email_address}: " + f"INBOX={n_inbox} nuovi, Sent={n_sent} nuovi" + ) + return { "status": "ok", "mailbox": mailbox.email_address, - "new_messages": n, + "new_messages_inbox": n_inbox, + "new_messages_sent": n_sent, } except Exception as e: logger.error(f"[sync_mailbox] {mailbox_id} errore: {e}", exc_info=True) return { "status": "error", - "mailbox": mailbox.email_address, + "mailbox": mailbox.email_address if mailbox else mailbox_id, "message": str(e), }