Modifiche varie

This commit is contained in:
2026-06-04 20:54:49 +02:00
parent ccc4167e28
commit e31676d22e
31 changed files with 3058 additions and 153 deletions
@@ -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")
+74 -17
View File
@@ -1,8 +1,9 @@
""" """
Router Audit Log consultazione degli eventi di sistema. Router Audit Log consultazione ed esportazione degli eventi di sistema.
Endpoint: 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: Permessi:
- admin: vede solo gli eventi del proprio tenant - admin: vede solo gli eventi del proprio tenant
@@ -14,6 +15,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from fastapi.responses import Response, StreamingResponse
from app.dependencies import AdminUser, DB from app.dependencies import AdminUser, DB
from app.schemas.audit_log import AuditLogListResponse 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"]) 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) @router.get("", response_model=AuditLogListResponse)
async def list_audit_log( async def list_audit_log(
current_user: AdminUser, current_user: AdminUser,
@@ -36,24 +45,10 @@ async def list_audit_log(
resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"), 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)"), tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"),
) -> AuditLogListResponse: ) -> AuditLogListResponse:
""" """Restituisce la lista paginata degli eventi di audit."""
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.
"""
svc = AuditService(db) 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( return await svc.list(
tenant_id=effective_tenant_id, tenant_id=_effective_tenant_id(current_user, tenant_id),
page=page, page=page,
page_size=page_size, page_size=page_size,
action=action, action=action,
@@ -63,3 +58,65 @@ async def list_audit_log(
date_to=date_to, date_to=date_to,
resource_type=resource_type, 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}"'},
)
+4 -3
View File
@@ -33,6 +33,7 @@ from app.schemas.auth import (
TokenResponse, TokenResponse,
) )
from app.schemas.user import UserResponse from app.schemas.user import UserResponse
from app.services.audit_service import get_real_ip
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
settings = get_settings() settings = get_settings()
@@ -51,7 +52,7 @@ async def login(
body: LoginRequest, body: LoginRequest,
db: DB, db: DB,
) -> TokenResponse: ) -> TokenResponse:
ip = request.client.host if request.client else None ip = get_real_ip(request)
ua = request.headers.get("user-agent") ua = request.headers.get("user-agent")
service = AuthService(db) service = AuthService(db)
@@ -177,7 +178,7 @@ async def change_password(
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
user_id=current_user.id, user_id=current_user.id,
outcome="failure", 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"), user_agent=request.headers.get("user-agent"),
payload={"reason": "wrong_current_password"}, payload={"reason": "wrong_current_password"},
) )
@@ -189,6 +190,6 @@ async def change_password(
"auth.password_changed", "auth.password_changed",
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
user_id=current_user.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"), user_agent=request.headers.get("user-agent"),
) )
+159 -2
View File
@@ -23,12 +23,13 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Query, status from fastapi import APIRouter, Query, Request, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.services.audit_service import get_real_ip
from app.services.search_service import SearchService from app.services.search_service import SearchService
from app.config import get_settings 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.label import Label
from app.models.message import Attachment, Message from app.models.message import Attachment, Message
from app.schemas.message import ( from app.schemas.message import (
AttachmentMatchInfo,
AttachmentResponse, AttachmentResponse,
MessageBulkUpdateRequest, MessageBulkUpdateRequest,
MessageBulkUpdateResponse, MessageBulkUpdateResponse,
MessageListResponse, MessageListResponse,
MessageResponse, MessageResponse,
MessageUpdateRequest, MessageUpdateRequest,
SearchMatchInfo,
) )
router = APIRouter(prefix="/messages", tags=["Messages"]) router = APIRouter(prefix="/messages", tags=["Messages"])
@@ -330,8 +333,45 @@ async def list_messages(
result = await db.execute(q) result = await db.execute(q)
items = list(result.scalars().all()) 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( return MessageListResponse(
items=[MessageResponse.model_validate(m) for m in items], items=message_responses,
total=total, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
@@ -340,6 +380,7 @@ async def list_messages(
@router.patch("/bulk", response_model=MessageBulkUpdateResponse) @router.patch("/bulk", response_model=MessageBulkUpdateResponse)
async def bulk_update_messages( async def bulk_update_messages(
request: Request,
data: MessageBulkUpdateRequest, data: MessageBulkUpdateRequest,
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
@@ -349,6 +390,8 @@ async def bulk_update_messages(
Per is_pending_conservation=True o is_conserved=True richiede can_conserve. Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
""" """
from app.services.audit_service import log_audit
if not data.ids: if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[]) return MessageBulkUpdateResponse(updated=0, items=[])
@@ -415,6 +458,29 @@ async def bulk_update_messages(
elif not data.is_conserved: elif not data.is_conserved:
message.conserved_at = None 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() await db.commit()
if messages: if messages:
@@ -434,17 +500,37 @@ async def bulk_update_messages(
@router.get("/{message_id}", response_model=MessageResponse) @router.get("/{message_id}", response_model=MessageResponse)
async def get_message( async def get_message(
request: Request,
message_id: uuid.UUID, message_id: uuid.UUID,
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> MessageResponse: ) -> MessageResponse:
"""Carica un messaggio per ID.""" """Carica un messaggio per ID."""
from app.services.audit_service import log_audit
message = await _resolve_message(message_id, current_user, db) 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) return MessageResponse.model_validate(message)
@router.patch("/{message_id}", response_model=MessageResponse) @router.patch("/{message_id}", response_model=MessageResponse)
async def update_message( async def update_message(
request: Request,
message_id: uuid.UUID, message_id: uuid.UUID,
data: MessageUpdateRequest, data: MessageUpdateRequest,
current_user: CurrentUser, current_user: CurrentUser,
@@ -455,6 +541,8 @@ async def update_message(
Per is_pending_conservation=True o is_conserved=True richiede can_conserve. 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) message = await _resolve_message(message_id, current_user, db)
# Verifica permesso conservazione se necessario # Verifica permesso conservazione se necessario
@@ -464,6 +552,19 @@ async def update_message(
await perm_svc.require_can_conserve(current_user, message.mailbox_id) await perm_svc.require_can_conserve(current_user, message.mailbox_id)
now = datetime.now(timezone.utc) 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: if data.is_read is not None:
message.is_read = data.is_read message.is_read = data.is_read
@@ -494,6 +595,23 @@ async def update_message(
elif not data.is_conserved: elif not data.is_conserved:
message.conserved_at = None 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() await db.commit()
refreshed = await db.execute( refreshed = await db.execute(
select(Message) select(Message)
@@ -524,12 +642,15 @@ async def list_attachments(
@router.get("/{message_id}/attachments/{attachment_id}/download") @router.get("/{message_id}/attachments/{attachment_id}/download")
async def download_attachment( async def download_attachment(
request: Request,
message_id: uuid.UUID, message_id: uuid.UUID,
attachment_id: uuid.UUID, attachment_id: uuid.UUID,
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> StreamingResponse: ) -> StreamingResponse:
"""Scarica un allegato direttamente da MinIO.""" """Scarica un allegato direttamente da MinIO."""
from app.services.audit_service import log_audit
await _resolve_message(message_id, current_user, db) await _resolve_message(message_id, current_user, db)
result = await db.execute( result = await db.execute(
@@ -542,6 +663,23 @@ async def download_attachment(
if not attachment: if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato") 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: try:
from miniopy_async import Minio from miniopy_async import Minio
@@ -580,6 +718,7 @@ async def download_attachment(
@router.get("/{message_id}/download-package") @router.get("/{message_id}/download-package")
async def download_package( async def download_package(
request: Request,
message_id: uuid.UUID, message_id: uuid.UUID,
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
@@ -588,6 +727,7 @@ async def download_package(
import io import io
import zipfile as _zipfile import zipfile as _zipfile
from app.services.audit_service import log_audit
from miniopy_async import Minio from miniopy_async import Minio
message = await _resolve_message(message_id, current_user, db) 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] safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
zip_filename = f"pec_{safe_subject}.zip" 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( return StreamingResponse(
iter([zip_bytes]), iter([zip_bytes]),
media_type="application/zip", media_type="application/zip",
+65 -1
View File
@@ -13,11 +13,12 @@ import json
import uuid import uuid
from typing import Annotated 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.core.exceptions import ForbiddenError
from app.dependencies import CurrentUser, DB from app.dependencies import CurrentUser, DB
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest 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.permission_service import PermissionService
from app.services.send_service import SendService from app.services.send_service import SendService
@@ -47,12 +48,33 @@ def _job_response(job) -> SendJobResponse:
), ),
) )
async def create_send_job( async def create_send_job(
request: Request,
data: SendPecRequest, data: SendPecRequest,
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> SendJobResponse: ) -> SendJobResponse:
from app.services.audit_service import log_audit
svc = _svc(db) svc = _svc(db)
job = await svc.create_send_job(current_user=current_user, data=data) 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.commit()
await db.refresh(job) await db.refresh(job)
return _job_response(job) return _job_response(job)
@@ -74,6 +96,7 @@ async def create_send_job(
), ),
) )
async def create_send_job_multipart( async def create_send_job_multipart(
request: Request,
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
data: str = Form( data: str = Form(
@@ -85,6 +108,8 @@ async def create_send_job_multipart(
description="File allegati (0 o più, max 20 MB ciascuno)", description="File allegati (0 o più, max 20 MB ciascuno)",
), ),
) -> SendJobResponse: ) -> SendJobResponse:
from app.services.audit_service import log_audit
# ── Parse del JSON ──────────────────────────────────────────────────────── # ── Parse del JSON ────────────────────────────────────────────────────────
try: try:
raw = json.loads(data) raw = json.loads(data)
@@ -128,6 +153,25 @@ async def create_send_job_multipart(
data=pec_data, data=pec_data,
attachments=files_data if files_data else None, 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.commit()
await db.refresh(job) await db.refresh(job)
return _job_response(job) return _job_response(job)
@@ -205,10 +249,13 @@ async def get_send_job(
summary="Annulla job di invio", summary="Annulla job di invio",
) )
async def cancel_send_job( async def cancel_send_job(
request: Request,
job_id: uuid.UUID, job_id: uuid.UUID,
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> None: ) -> None:
from app.services.audit_service import log_audit
svc = _svc(db) svc = _svc(db)
job = await svc.get_send_job(job_id, current_user.tenant_id) 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") raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio")
await svc.cancel_send_job(job_id, current_user.tenant_id) 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() await db.commit()
+174
View File
@@ -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)
+2 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from app.api.v1 import 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.api.v1 import settings as settings_router
from app.config import get_settings from app.config import get_settings
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
@@ -102,6 +102,7 @@ app.include_router(templates.router, prefix=API_PREFIX)
app.include_router(routing_rules.router, prefix=API_PREFIX) app.include_router(routing_rules.router, prefix=API_PREFIX)
app.include_router(contacts.router, prefix=API_PREFIX) app.include_router(contacts.router, prefix=API_PREFIX)
app.include_router(deadlines.router, prefix=API_PREFIX) app.include_router(deadlines.router, prefix=API_PREFIX)
app.include_router(signatures.router, prefix=API_PREFIX)
# ─── Health check ───────────────────────────────────────────────────────────── # ─── Health check ─────────────────────────────────────────────────────────────
+1
View File
@@ -13,3 +13,4 @@ from app.models.tenant_settings import TenantSettings # noqa: F401
from app.models.template import MessageTemplate # noqa: F401 from app.models.template import MessageTemplate # noqa: F401
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # 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.pec_contact import PecContact # noqa: F401
from app.models.signature import Signature, SignatureAssignment # noqa: F401
+118
View File
@@ -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"<Signature {self.name!r}>"
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"<SignatureAssignment sig={self.signature_id} {target} ctx={self.context!r}>"
+3
View File
@@ -17,6 +17,9 @@ class AuditLogResponse(BaseModel):
id: int id: int
tenant_id: Optional[uuid.UUID] = None tenant_id: Optional[uuid.UUID] = None
user_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 action: str
resource_type: Optional[str] = None resource_type: Optional[str] = None
resource_id: Optional[uuid.UUID] = None resource_id: Optional[uuid.UUID] = None
+23
View File
@@ -11,6 +11,27 @@ from pydantic import BaseModel, model_validator
from app.schemas.label import LabelResponse 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): class AttachmentResponse(BaseModel):
id: uuid.UUID id: uuid.UUID
message_id: uuid.UUID message_id: uuid.UUID
@@ -59,6 +80,8 @@ class MessageResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
labels: list[LabelResponse] = [] labels: list[LabelResponse] = []
# Popolato solo nelle risposte di ricerca full-text
search_match: Optional[SearchMatchInfo] = None
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
+75
View File
@@ -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
+246 -22
View File
@@ -12,25 +12,54 @@ Uso tipico nei router/servizi:
resource_type="user", resource_type="user",
resource_id=new_user.id, resource_id=new_user.id,
outcome="success", 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"), user_agent=request.headers.get("user-agent"),
payload={"email": new_user.email}, payload={"email": new_user.email},
) )
""" """
import csv
import io
import math import math
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import Request
from sqlalchemy import select, func, and_ from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.core.pagination import PaginatedResponse, PaginationParams from app.core.pagination import PaginatedResponse, PaginationParams
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.user import User
from app.schemas.audit_log import AuditLogResponse 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) ─────── # ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ───────
async def log_audit( async def log_audit(
@@ -79,31 +108,35 @@ class AuditService:
def __init__(self, db: AsyncSession) -> None: def __init__(self, db: AsyncSession) -> None:
self.db = db self.db = db
async def list( def _build_query(
self, self,
*, *,
tenant_id: Optional[uuid.UUID], tenant_id: Optional[uuid.UUID],
page: int = 1,
page_size: int = 25,
action: Optional[str] = None, action: Optional[str] = None,
user_id: Optional[uuid.UUID] = None, user_id: Optional[uuid.UUID] = None,
outcome: Optional[str] = None, outcome: Optional[str] = None,
date_from: Optional[datetime] = None, date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None, date_to: Optional[datetime] = None,
resource_type: Optional[str] = None, resource_type: Optional[str] = None,
) -> PaginatedResponse[AuditLogResponse]: ):
""" """Costruisce la query base con JOIN utente e filtri."""
Restituisce la lista paginata degli eventi audit. 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 = [] filters = []
if tenant_id is not None: if tenant_id is not None:
filters.append(AuditLog.tenant_id == tenant_id) filters.append(AuditLog.tenant_id == tenant_id)
if action: if action:
# Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.*
if action.endswith("*"): if action.endswith("*"):
filters.append(AuditLog.action.like(action[:-1] + "%")) filters.append(AuditLog.action.like(action[:-1] + "%"))
else: else:
@@ -124,30 +157,221 @@ class AuditService:
if resource_type: if resource_type:
filters.append(AuditLog.resource_type == 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 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() total = (await self.db.execute(count_q)).scalar_one()
# Dati paginati # Dati paginati
offset = (page - 1) * page_size offset = (page - 1) * page_size
items_q = ( items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).offset(offset).limit(page_size)
select(AuditLog) rows = (await self.db.execute(items_q)).all()
.where(where_clause)
.order_by(AuditLog.occurred_at.desc()) items = []
.offset(offset) for row in rows:
.limit(page_size) entry: AuditLog = row[0]
) email: Optional[str] = row[1]
result = await self.db.execute(items_q) full_name: Optional[str] = row[2]
items = list(result.scalars().all()) 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 pages = math.ceil(total / page_size) if page_size > 0 else 0
return PaginatedResponse[AuditLogResponse]( return PaginatedResponse[AuditLogResponse](
items=[AuditLogResponse.model_validate(item) for item in items], items=items,
total=total, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
pages=pages, 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()
+258
View File
@@ -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()
+2
View File
@@ -18,6 +18,7 @@ import { TemplatesPage } from '@/pages/Templates/TemplatesPage'
import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage' import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
import { ContactsPage } from '@/pages/Contacts/ContactsPage' import { ContactsPage } from '@/pages/Contacts/ContactsPage'
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage' import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
import { SignaturesPage } from '@/pages/Signatures/SignaturesPage'
/** /**
* Routing principale dell'applicazione PEChub. * Routing principale dell'applicazione PEChub.
@@ -101,6 +102,7 @@ export default function App() {
<Route path="/routing-rules" element={<RoutingRulesPage />} /> <Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
<Route path="/deadlines" element={<DeadlinesPage />} /> <Route path="/deadlines" element={<DeadlinesPage />} />
<Route path="/signatures" element={<SignaturesPage />} />
{/* Profilo utente */} {/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
+17
View File
@@ -9,6 +9,8 @@ export interface AuditLogEntry {
id: number id: number
tenant_id: string | null tenant_id: string | null
user_id: string | null user_id: string | null
user_email: string | null
user_full_name: string | null
action: string action: string
resource_type: string | null resource_type: string | null
resource_id: string | null resource_id: string | null
@@ -38,4 +40,19 @@ export const auditLogApi = {
apiClient apiClient
.get<AuditLogListResponse>('/audit-log', { params }) .get<AuditLogListResponse>('/audit-log', { params })
.then((r) => r.data), .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<AuditLogParams, 'page' | 'page_size'> = {},
): Promise<Blob> => {
const response = await apiClient.get('/audit-log/export', {
params: { format, ...params },
responseType: 'blob',
})
return response.data as Blob
},
} }
+97
View File
@@ -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<SignatureResponse>(`/signatures/${id}`).then((r) => r.data),
create: (data: SignatureCreate) =>
apiClient.post<SignatureResponse>('/signatures', data).then((r) => r.data),
update: (id: string, data: SignatureUpdate) =>
apiClient.put<SignatureResponse>(`/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<SignatureAssignmentResponse>('/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<SignatureResponse | null>('/signatures/resolve', { params })
.then((r) => r.data),
}
@@ -1,5 +1,5 @@
import { useState, useRef, useMemo, useEffect } from 'react' import { useState, useRef, useMemo, useEffect } from 'react'
import { useForm, useFieldArray } from 'react-hook-form' import { useForm, useFieldArray, useWatch } from 'react-hook-form'
import { import {
Send, Send,
X, X,
@@ -11,6 +11,8 @@ import {
Minus, Minus,
Maximize2, Maximize2,
Minimize2, Minimize2,
FileText,
ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import { useQuery, useMutation } from '@tanstack/react-query' import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@@ -18,13 +20,38 @@ import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import { ContactPickerPopover } from '@/components/ComposeModal/ContactPickerPopover'
import { sendApi } from '@/api/send.api' import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api' import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.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 { getErrorMessage } from '@/api/client'
import { useComposeStore } from '@/store/compose.store' import { useComposeStore } from '@/store/compose.store'
import type { MessageResponse } from '@/types/api.types' 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(`<div[^>]*${SIG_ATTR}="1"[^>]*>[\\s\\S]*?<\\/div>`, 'g'),
''
)
if (!sigHtml) return withoutSig
const sigBlock = `<div ${SIG_ATTR}="1" style="margin-top:12px;padding-top:8px;border-top:1px solid #d1d5db">${sigHtml}</div>`
if (withoutSig.includes('<hr>')) {
return withoutSig.replace('<hr>', sigBlock + '<hr>')
}
// 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 : '<p></p>'
return content + sigBlock
}
// ─── Tipi ───────────────────────────────────────────────────────────────────── // ─── Tipi ─────────────────────────────────────────────────────────────────────
interface MailboxSelectItem { interface MailboxSelectItem {
@@ -92,32 +119,73 @@ function buildInitialBody(replyTo?: MessageResponse | null, forwardOf?: MessageR
return '' 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<string>()
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) ─────────────── // ─── Form interno (rimontato ogni volta che cambia il messaggio) ───────────────
interface ComposeFormProps { interface ComposeFormProps {
replyTo: MessageResponse | null replyTo: MessageResponse | null
forwardOf: MessageResponse | null forwardOf: MessageResponse | null
replyAll: boolean
onClose: () => void onClose: () => void
} }
function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) { function ComposeForm({ replyTo, forwardOf, replyAll, onClose }: ComposeFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(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<File[]>([]) const [attachments, setAttachments] = useState<File[]>([])
const [bodyHtml, setBodyHtml] = useState<string>(() => const [bodyHtml, setBodyHtml] = useState<string>(() =>
buildInitialBody(replyTo, forwardOf) 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 { const {
register, register,
control, control,
handleSubmit, handleSubmit,
setValue,
formState: { errors }, formState: { errors },
} = useForm<ComposeFormValues>({ } = useForm<ComposeFormValues>({
defaultValues: { defaultValues: {
mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '', mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '',
to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }], to_addresses: buildDefaultTo(),
cc_addresses: [], cc_addresses: buildDefaultCc(),
subject: replyTo subject: replyTo
? `Re: ${replyTo.subject || ''}` ? `Re: ${replyTo.subject || ''}`
: forwardOf : 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' }) 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<string | null>(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('<hr>')
const quotedPart = hrIndex >= 0 ? bodyHtml.slice(hrIndex) : ''
const templateBody = tpl.body_html || (tpl.body_text ? `<p>${tpl.body_text}</p>` : '')
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({ const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
queryKey: ['mailboxes'], queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(), queryFn: () => mailboxesApi.list(),
@@ -139,18 +254,6 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
queryFn: () => virtualBoxesApi.getMyMailboxes(), queryFn: () => virtualBoxesApi.getMyMailboxes(),
}) })
const sendMutation = useMutation({
mutationFn: (args: { data: Parameters<typeof sendApi.sendMultipart>[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 activeCaselle = useMemo((): MailboxSelectItem[] => {
const regularActive: MailboxSelectItem[] = ( const regularActive: MailboxSelectItem[] = (
mailboxesData?.items.filter((m) => m.status === 'active') || [] mailboxesData?.items.filter((m) => m.status === 'active') || []
@@ -179,6 +282,33 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
const isLoadingMailboxes = mailboxesLoading || vboxMailboxesLoading 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<typeof sendApi.sendMultipart>[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) => { const handleFileAdd = (files: FileList | null) => {
if (!files) return if (!files) return
const valid: File[] = [] const valid: File[] = []
@@ -299,6 +429,10 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' }, pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
})} })}
/> />
<ContactPickerPopover
size="sm"
onSelect={(email) => setValue(`to_addresses.${idx}.value`, email)}
/>
{toFields.length > 1 && ( {toFields.length > 1 && (
<button <button
type="button" type="button"
@@ -322,7 +456,7 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
{!showCc ? ( {!showCc ? (
<button <button
type="button" type="button"
onClick={() => setShowCc(true)} onClick={() => { setShowCc(true); if (ccFields.length === 0) appendCc({ value: '' }) }}
className="text-xs text-primary hover:underline" className="text-xs text-primary hover:underline"
> >
+ Aggiungi Cc + Aggiungi Cc
@@ -351,6 +485,10 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' }, pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
})} })}
/> />
<ContactPickerPopover
size="sm"
onSelect={(email) => setValue(`cc_addresses.${idx}.value`, email)}
/>
<button <button
type="button" type="button"
onClick={() => removeCc(idx)} onClick={() => removeCc(idx)}
@@ -383,7 +521,58 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
{/* Corpo */} {/* Corpo */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Testo del messaggio</Label> <Label className="text-xs">Testo del messaggio</Label>
<button
type="button"
onClick={() => setShowTemplatePicker((v) => !v)}
className="flex items-center gap-1 text-xs text-primary hover:underline"
title="Usa un template come punto di partenza"
>
<FileText className="h-3 w-3" />
Usa template
<ChevronDown
className={`h-3 w-3 transition-transform ${showTemplatePicker ? 'rotate-180' : ''}`}
/>
</button>
</div>
{/* Pannello selezione template */}
{showTemplatePicker && (
<div className="rounded-md border bg-background shadow-sm overflow-hidden">
{templatesLoading ? (
<div className="p-3 text-xs text-muted-foreground text-center">
Caricamento template...
</div>
) : !templatesData?.items.length ? (
<div className="p-3 text-xs text-muted-foreground text-center">
Nessun template disponibile. Creane uno in Amministrazione.
</div>
) : (
<div className="max-h-44 overflow-y-auto divide-y">
{templatesData.items.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => applyTemplate(tpl)}
className="w-full text-left px-3 py-2 hover:bg-muted/60 transition-colors"
>
<p className="text-xs font-semibold text-foreground">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-muted-foreground truncate">{tpl.description}</p>
)}
{tpl.subject && (
<p className="text-xs text-muted-foreground/70 italic truncate">
Oggetto: {tpl.subject}
</p>
)}
</button>
))}
</div>
)}
</div>
)}
<RichTextEditor <RichTextEditor
value={bodyHtml} value={bodyHtml}
onChange={setBodyHtml} onChange={setBodyHtml}
@@ -469,7 +658,7 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
// ─── Componente principale flottante ────────────────────────────────────────── // ─── Componente principale flottante ──────────────────────────────────────────
export function ComposeModal() { export function ComposeModal() {
const { isOpen, mode, replyTo, forwardOf, closeCompose, setMode } = useComposeStore() const { isOpen, mode, replyTo, forwardOf, replyAll, closeCompose, setMode } = useComposeStore()
// Chiudi con ESC (solo quando non minimizzato) // Chiudi con ESC (solo quando non minimizzato)
useEffect(() => { useEffect(() => {
@@ -484,10 +673,15 @@ export function ComposeModal() {
if (!isOpen) return null 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 const subtitle = replyTo?.subject || forwardOf?.subject || null
// Stile inline per il posizionamento (piu' affidabile delle classi Tailwind dinamiche)
const containerStyle = (() => { const containerStyle = (() => {
if (mode === 'fullscreen') { if (mode === 'fullscreen') {
return { return {
@@ -507,7 +701,6 @@ export function ComposeModal() {
height: '48px', height: '48px',
} }
} }
// normal
return { return {
position: 'fixed' as const, position: 'fixed' as const,
bottom: 0, bottom: 0,
@@ -582,13 +775,14 @@ export function ComposeModal() {
{mode !== 'minimized' && ( {mode !== 'minimized' && (
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
{/* {/*
Key basata su replyTo/forwardOf: garantisce che il form venga rimontato Key basata su replyTo/forwardOf/replyAll: garantisce che il form venga rimontato
(e quindi resettato) ogni volta che si apre per un messaggio diverso. ogni volta che si apre per un messaggio diverso o cambia la modalita'.
*/} */}
<ComposeForm <ComposeForm
key={`${replyTo?.id ?? 'new'}-${forwardOf?.id ?? 'new'}`} key={`${replyTo?.id ?? 'new'}-${forwardOf?.id ?? 'new'}-${replyAll ? 'all' : 'single'}`}
replyTo={replyTo} replyTo={replyTo}
forwardOf={forwardOf} forwardOf={forwardOf}
replyAll={replyAll}
onClose={closeCompose} onClose={closeCompose}
/> />
</div> </div>
@@ -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<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(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<PecContactResponse[]>([])
const [loading, setLoading] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className="relative flex-shrink-0">
{/* Bottone apertura */}
<button
type="button"
title="Seleziona dalla rubrica"
onClick={() => setOpen((v) => !v)}
className={`
flex items-center justify-center rounded border border-input bg-background
text-muted-foreground hover:text-primary hover:border-primary/60
transition-colors
${isSm ? 'h-8 w-8' : 'h-10 w-10'}
${open ? 'border-primary/60 text-primary bg-primary/5' : ''}
`}
>
<BookUser className={isSm ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
</button>
{/* Popover */}
{open && (
<div
className="absolute z-50 right-0 top-full mt-1 w-72 rounded-lg border bg-background shadow-lg overflow-hidden"
style={{ minWidth: '260px' }}
>
{/* Campo ricerca */}
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30">
<Search className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Cerca nella rubrica..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
{/* Lista risultati */}
<div className="max-h-52 overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
Ricerca in corso...
</div>
) : results.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{query.trim() ? 'Nessun contatto trovato' : 'Rubrica vuota'}
</div>
) : (
results.map((contact) => (
<button
key={contact.id}
type="button"
onClick={() => handleSelect(contact)}
className="w-full text-left px-3 py-2 hover:bg-muted/60 transition-colors flex items-start gap-2 group"
>
<div className="mt-0.5 flex-shrink-0 h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center">
{contact.is_favorite ? (
<Star className="h-3 w-3 text-amber-500 fill-amber-500" />
) : (
<User className="h-3 w-3 text-primary/60" />
)}
</div>
<div className="min-w-0 flex-1">
{(contact.name || contact.organization) && (
<p className="text-xs font-medium text-foreground truncate">
{contact.name || contact.organization}
</p>
)}
<p className="text-xs text-muted-foreground truncate">
{contact.email}
</p>
{contact.name && contact.organization && (
<p className="text-xs text-muted-foreground/70 truncate">
{contact.organization}
</p>
)}
</div>
</button>
))
)}
</div>
</div>
)}
</div>
)
}
+13 -12
View File
@@ -58,10 +58,12 @@ import {
Settings2, Settings2,
BookUser, BookUser,
Calendar, Calendar,
PenLine,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { useInboxStore } from '@/store/inbox.store' import { useInboxStore } from '@/store/inbox.store'
import { useComposeStore } from '@/store/compose.store'
import { useState } from 'react' import { useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -83,6 +85,7 @@ export function Sidebar() {
const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth() const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount) const unreadCount = useInboxStore((s) => s.unreadCount)
const openCompose = useComposeStore((s) => s.openCompose)
// Le caselle PEC vengono caricate qui e condivise via React Query cache // Le caselle PEC vengono caricate qui e condivise via React Query cache
const { data: mailboxesData } = useQuery({ const { data: mailboxesData } = useQuery({
@@ -482,22 +485,19 @@ export function Sidebar() {
<div> <div>
<div className="border-t border-gray-700 mx-4 mb-3" /> <div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2"> <div className="px-2">
<NavLink <button
to="/compose" type="button"
className={({ isActive }) => onClick={() => openCompose()}
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Nuova PEC' : undefined} title={collapsed ? 'Nuova PEC' : undefined}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)}
> >
<MailCheck className="h-5 w-5 flex-shrink-0" /> <MailCheck className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Nuova PEC</span>} {!collapsed && <span>Nuova PEC</span>}
</NavLink> </button>
</div> </div>
</div> </div>
@@ -556,6 +556,7 @@ export function Sidebar() {
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter }, { to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 }, { to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
{ to: '/templates', label: 'Template messaggi', icon: FileText }, { to: '/templates', label: 'Template messaggi', icon: FileText },
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine },
{ to: '/notifications', label: 'Notifiche', icon: Bell }, { to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList }, { to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
] as const).map((item) => ( ] as const).map((item) => (
+126 -27
View File
@@ -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. * Accessibile solo ad admin e super_admin.
* Mostra una tabella paginata con filtri per data, azione ed esito. * 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 { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { format } from 'date-fns' import { format } from 'date-fns'
import { it } from 'date-fns/locale' 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 { auditLogApi } from '@/api/audit_log.api'
import type { AuditLogParams } from '@/api/audit_log.api' import type { AuditLogParams } from '@/api/audit_log.api'
import { cn } from '@/lib/utils' 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 ────────────────────────────────────────────────────────────── // ─── Badge esito ──────────────────────────────────────────────────────────────
@@ -40,18 +82,20 @@ function OutcomeBadge({ outcome }: { outcome: string }) {
// ─── Etichetta azione leggibile ─────────────────────────────────────────────── // ─── Etichetta azione leggibile ───────────────────────────────────────────────
function actionLabel(action: string): string { function actionLabel(action: string): string {
const map: Record<string, string> = { return ACTION_OPTIONS.find((o) => o.value === action)?.label ?? action
'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
// ─── 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 ──────────────────────────────────────────────────── // ─── Componente principale ────────────────────────────────────────────────────
@@ -69,6 +113,10 @@ export function AuditLogPage() {
// Parametri query attivi (applicati al click su "Cerca") // Parametri query attivi (applicati al click su "Cerca")
const [activeParams, setActiveParams] = useState<AuditLogParams>({}) const [activeParams, setActiveParams] = useState<AuditLogParams>({})
// Stato export
const [exportingCsv, setExportingCsv] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ['audit-log', page, activeParams], queryKey: ['audit-log', page, activeParams],
queryFn: () => queryFn: () =>
@@ -99,6 +147,23 @@ export function AuditLogPage() {
setPage(1) 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 items = data?.items ?? []
const total = data?.total ?? 0 const total = data?.total ?? 0
const pages = data?.pages ?? 1 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" 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"
> >
<option value="">Tutte le azioni</option> <option value="">Tutte le azioni</option>
<option value="auth.login">Login</option> {ACTION_GROUPS.map((group) => (
<option value="auth.password_changed">Cambio password</option> <optgroup key={group} label={group}>
<option value="user.created">Utente creato</option> {ACTION_OPTIONS.filter((o) => o.group === group).map((o) => (
<option value="user.updated">Utente modificato</option> <option key={o.value} value={o.value}>
<option value="user.deleted">Utente eliminato</option> {o.label}
<option value="mailbox.created">Casella creata</option> </option>
<option value="mailbox.updated">Casella modificata</option> ))}
<option value="mailbox.deleted">Casella eliminata</option> </optgroup>
<option value="message.sent">PEC inviata</option> ))}
</select> </select>
</div> </div>
@@ -175,7 +240,7 @@ export function AuditLogPage() {
</div> </div>
{/* Bottoni */} {/* Bottoni */}
<div className="flex gap-2 mt-4"> <div className="flex flex-wrap gap-2 mt-4">
<button <button
onClick={handleSearch} onClick={handleSearch}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
@@ -190,6 +255,29 @@ export function AuditLogPage() {
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
Reimposta Reimposta
</button> </button>
{/* Separatore */}
<div className="flex-1" />
{/* Export CSV */}
<button
onClick={() => handleExport('csv')}
disabled={exportingCsv}
className="flex items-center gap-2 px-4 py-2 border border-green-600 text-green-700 text-sm font-medium rounded-md hover:bg-green-50 transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
{exportingCsv ? 'Esportazione...' : 'Esporta CSV'}
</button>
{/* Export PDF */}
<button
onClick={() => handleExport('pdf')}
disabled={exportingPdf}
className="flex items-center gap-2 px-4 py-2 border border-red-600 text-red-700 text-sm font-medium rounded-md hover:bg-red-50 transition-colors disabled:opacity-50"
>
<FileText className="h-4 w-4" />
{exportingPdf ? 'Esportazione...' : 'Esporta PDF'}
</button>
</div> </div>
</div> </div>
@@ -293,10 +381,21 @@ export function AuditLogPage() {
{entry.ip_address ?? <span className="text-gray-300"></span>} {entry.ip_address ?? <span className="text-gray-300"></span>}
</td> </td>
{/* Utente (UUID abbreviato) */} {/* Utente: mostra nome, poi email, poi UUID abbreviato */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono"> <td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
{entry.user_id ? ( {entry.user_full_name ? (
<span title={entry.user_id}> <span title={entry.user_email ?? entry.user_id ?? ''}>
{entry.user_full_name}
{entry.user_email && (
<span className="ml-1 text-gray-400 text-xs">
({entry.user_email})
</span>
)}
</span>
) : entry.user_email ? (
<span title={entry.user_id ?? ''}>{entry.user_email}</span>
) : entry.user_id ? (
<span className="font-mono text-xs text-gray-400" title={entry.user_id}>
{entry.user_id.split('-')[0]}... {entry.user_id.split('-')[0]}...
</span> </span>
) : ( ) : (
+134 -3
View File
@@ -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 { useNavigate, useLocation } from 'react-router-dom'
import { useForm, useFieldArray } from 'react-hook-form' import { useForm, useFieldArray, useWatch } from 'react-hook-form'
import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter } from 'lucide-react' 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 { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -11,9 +12,29 @@ import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import { sendApi } from '@/api/send.api' import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api' import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.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 { getErrorMessage } from '@/api/client'
import type { MessageResponse } from '@/types/api.types' 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(`<div[^>]*${SIG_ATTR}="1"[^>]*>[\\s\\S]*?<\\/div>`, 'g'),
''
)
if (!sigHtml) return withoutSig
const sigBlock = `<div ${SIG_ATTR}="1" style="margin-top:12px;padding-top:8px;border-top:1px solid #d1d5db">${sigHtml}</div>`
if (withoutSig.includes('<hr>')) {
return withoutSig.replace('<hr>', sigBlock + '<hr>')
}
return withoutSig + sigBlock
}
/** Tipo unificato casella mittente (sia da permessi diretti che da Virtual Box) */ /** Tipo unificato casella mittente (sia da permessi diretti che da Virtual Box) */
interface MailboxSelectItem { interface MailboxSelectItem {
id: string id: string
@@ -99,6 +120,7 @@ export function ComposePage() {
register, register,
control, control,
handleSubmit, handleSubmit,
setValue,
formState: { errors }, formState: { errors },
} = useForm<ComposeFormValues>({ } = useForm<ComposeFormValues>({
defaultValues: { defaultValues: {
@@ -127,6 +149,56 @@ export function ComposePage() {
remove: removeCc, remove: removeCc,
} = useFieldArray({ control, name: 'cc_addresses' }) } = 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<string | null>(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 <hr> in poi)
const hrIndex = bodyHtml.indexOf('<hr>')
const quotedPart = hrIndex >= 0 ? bodyHtml.slice(hrIndex) : ''
const templateBody = tpl.body_html || (tpl.body_text ? `<p>${tpl.body_text}</p>` : '')
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) // Carica caselle disponibili per l'invio (permessi diretti)
const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({ const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
queryKey: ['mailboxes'], queryKey: ['mailboxes'],
@@ -350,6 +422,10 @@ export function ComposePage() {
}, },
})} })}
/> />
<ContactPickerPopover
size="md"
onSelect={(email) => setValue(`to_addresses.${idx}.value`, email)}
/>
{toFields.length > 1 && ( {toFields.length > 1 && (
<Button <Button
type="button" type="button"
@@ -408,6 +484,10 @@ export function ComposePage() {
}, },
})} })}
/> />
<ContactPickerPopover
size="md"
onSelect={(email) => setValue(`cc_addresses.${idx}.value`, email)}
/>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -443,7 +523,58 @@ export function ComposePage() {
{/* Corpo Rich Text Editor */} {/* Corpo Rich Text Editor */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>Testo del messaggio</Label> <Label>Testo del messaggio</Label>
<button
type="button"
onClick={() => setShowTemplatePicker((v) => !v)}
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
title="Usa un template come punto di partenza"
>
<FileText className="h-4 w-4" />
Usa template
<ChevronDown
className={`h-4 w-4 transition-transform ${showTemplatePicker ? 'rotate-180' : ''}`}
/>
</button>
</div>
{/* Pannello selezione template */}
{showTemplatePicker && (
<div className="rounded-lg border bg-background shadow-sm overflow-hidden">
{templatesLoading ? (
<div className="p-4 text-sm text-muted-foreground text-center">
Caricamento template...
</div>
) : !templatesData?.items.length ? (
<div className="p-4 text-sm text-muted-foreground text-center">
Nessun template disponibile. Creane uno nella sezione Amministrazione.
</div>
) : (
<div className="max-h-56 overflow-y-auto divide-y">
{templatesData.items.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => applyTemplate(tpl)}
className="w-full text-left px-4 py-3 hover:bg-muted/60 transition-colors"
>
<p className="text-sm font-semibold text-foreground">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-muted-foreground mt-0.5">{tpl.description}</p>
)}
{tpl.subject && (
<p className="text-xs text-muted-foreground/70 italic mt-0.5 truncate">
Oggetto: {tpl.subject}
</p>
)}
</button>
))}
</div>
)}
</div>
)}
<RichTextEditor <RichTextEditor
value={bodyHtml} value={bodyHtml}
onChange={setBodyHtml} onChange={setBodyHtml}
+37 -1
View File
@@ -41,6 +41,9 @@ import {
ChevronUp, ChevronUp,
ShieldCheck, ShieldCheck,
ShieldX, ShieldX,
FileText,
Paperclip,
AlignLeft,
} from 'lucide-react' } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@@ -911,6 +914,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address ? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
: undefined : undefined
} }
searchTerm={debouncedSearch || undefined}
/> />
))} ))}
</div> </div>
@@ -975,6 +979,7 @@ interface MessageRowProps {
onToggleTrash: (e: React.MouseEvent) => void onToggleTrash: (e: React.MouseEvent) => void
onToggleConserve: (e: React.MouseEvent) => void onToggleConserve: (e: React.MouseEvent) => void
mailboxName?: string mailboxName?: string
searchTerm?: string
} }
function MessageRow({ function MessageRow({
@@ -991,6 +996,7 @@ function MessageRow({
onToggleTrash, onToggleTrash,
onToggleConserve, onToggleConserve,
mailboxName, mailboxName,
searchTerm,
}: MessageRowProps) { }: MessageRowProps) {
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const isUnread = !message.is_read && message.direction === 'inbound' const isUnread = !message.is_read && message.direction === 'inbound'
@@ -998,7 +1004,8 @@ function MessageRow({
return ( return (
<div <div
className={cn( className={cn(
'flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors group', 'flex gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors group',
searchTerm ? 'items-start' : 'items-center',
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20', isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
isSelected && 'bg-blue-100/60 dark:bg-blue-900/30', isSelected && 'bg-blue-100/60 dark:bg-blue-900/30',
)} )}
@@ -1091,6 +1098,35 @@ function MessageRow({
{truncate(message.body_text, 120)} {truncate(message.body_text, 120)}
</p> </p>
)} )}
{/* Badge "Trovato in" (solo durante ricerca attiva con match info) */}
{searchTerm && message.search_match && (
<div className="flex items-center flex-wrap gap-1 mt-1.5">
<span className="text-xs text-muted-foreground mr-0.5">Trovato in:</span>
{message.search_match.in_subject && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium">
<FileText className="h-3 w-3" />
Oggetto
</span>
)}
{message.search_match.in_body && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium">
<AlignLeft className="h-3 w-3" />
Corpo del messaggio
</span>
)}
{message.search_match.in_attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium max-w-[200px]"
title={att.filename}
>
<Paperclip className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{att.filename}</span>
</span>
))}
</div>
)}
</div> </div>
{/* ── Azioni rapide (visibili su hover) ── */} {/* ── Azioni rapide (visibili su hover) ── */}
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
@@ -8,6 +8,7 @@ import {
ArchiveX, ArchiveX,
Download, Download,
Reply, Reply,
ReplyAll,
Forward, Forward,
Paperclip, Paperclip,
Mail, Mail,
@@ -22,6 +23,7 @@ import {
Eye, Eye,
MessageSquare, MessageSquare,
X, X,
ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -168,6 +170,21 @@ export function MessageDetailPage() {
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false) const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
const [isPrinting, setIsPrinting] = useState(false) const [isPrinting, setIsPrinting] = useState(false)
// Dropdown "Rispondi / Rispondi a tutti"
const [showReplyDropdown, setShowReplyDropdown] = useState(false)
const replyDropdownRef = useRef<HTMLDivElement>(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 // Feature 4: Deadline
const [showDeadlineForm, setShowDeadlineForm] = useState(false) const [showDeadlineForm, setShowDeadlineForm] = useState(false)
const [deadlineDate, setDeadlineDate] = useState('') const [deadlineDate, setDeadlineDate] = useState('')
@@ -533,8 +550,56 @@ export function MessageDetailPage() {
</Button> </Button>
)} )}
{/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */} {/* Rispondi / Rispondi a tutti (solo per messaggi inbound PEC certificata, non nel cestino) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && ( {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 */
<div ref={replyDropdownRef} className="relative flex">
{/* Parte sinistra: "Rispondi" */}
<button
type="button"
onClick={() => { openCompose({ replyTo: message }); setShowReplyDropdown(false) }}
className="inline-flex items-center h-8 px-3 text-sm border border-input bg-background rounded-l-md hover:bg-muted transition-colors"
>
<Reply className="h-4 w-4 mr-1" />
Rispondi
</button>
{/* Parte destra: freccia dropdown */}
<button
type="button"
onClick={() => setShowReplyDropdown((v) => !v)}
className="inline-flex items-center h-8 px-1.5 text-sm border border-l-0 border-input bg-background rounded-r-md hover:bg-muted transition-colors"
title="Altre opzioni di risposta"
>
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${showReplyDropdown ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown */}
{showReplyDropdown && (
<div className="absolute right-0 top-full mt-1 z-30 min-w-[180px] rounded-md border bg-background shadow-lg overflow-hidden">
<button
type="button"
onClick={() => { openCompose({ replyTo: message }); setShowReplyDropdown(false) }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
>
<Reply className="h-4 w-4 text-muted-foreground" />
Rispondi
</button>
<button
type="button"
onClick={() => { openCompose({ replyTo: message, replyAll: true }); setShowReplyDropdown(false) }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
>
<ReplyAll className="h-4 w-4 text-muted-foreground" />
Rispondi a tutti
</button>
</div>
)}
</div>
) : (
/* Pulsante semplice senza tendina */
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -543,7 +608,8 @@ export function MessageDetailPage() {
<Reply className="h-4 w-4 mr-1" /> <Reply className="h-4 w-4 mr-1" />
Rispondi Rispondi
</Button> </Button>
)} )
})()}
{/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */} {/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */}
{message.pec_type === 'posta_certificata' && !message.is_trashed && ( {message.pec_type === 'posta_certificata' && !message.is_trashed && (
+32
View File
@@ -26,6 +26,9 @@ import {
Mail, Mail,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
FileText,
Paperclip,
AlignLeft,
} from 'lucide-react' } from 'lucide-react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -539,6 +542,35 @@ function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchRe
</p> </p>
)} )}
{/* Riga 4: badge "Trovato in" (solo durante ricerca con match info) */}
{searchTerm && message.search_match && (
<div className="flex items-center flex-wrap gap-1 mt-1.5">
<span className="text-xs text-muted-foreground mr-0.5">Trovato in:</span>
{message.search_match.in_subject && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium">
<FileText className="h-3 w-3" />
Oggetto
</span>
)}
{message.search_match.in_body && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium">
<AlignLeft className="h-3 w-3" />
Corpo del messaggio
</span>
)}
{message.search_match.in_attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium max-w-[200px]"
title={att.filename}
>
<Paperclip className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{att.filename}</span>
</span>
))}
</div>
)}
{/* Tag */} {/* Tag */}
{message.labels && message.labels.length > 0 && ( {message.labels && message.labels.length > 0 && (
<div className="mt-1"> <div className="mt-1">
@@ -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<Tab>('library')
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Firme automatiche</h1>
<p className="text-sm text-muted-foreground">
Gestisci le firme inserite automaticamente nelle PEC inviate
</p>
</div>
</div>
{/* Tab bar */}
<div className="flex gap-1 mt-4 border-b -mb-[1px]">
{[
{ id: 'library' as Tab, label: 'Libreria firme' },
{ id: 'mailboxes' as Tab, label: 'Caselle PEC' },
{ id: 'vboxes' as Tab, label: 'Virtual Box' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Contenuto tab */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'library' && <LibraryTab isAdmin={isAdmin} queryClient={queryClient} />}
{activeTab === 'mailboxes' && <MailboxAssignmentsTab isAdmin={isAdmin} />}
{activeTab === 'vboxes' && <VboxAssignmentsTab isAdmin={isAdmin} />}
</div>
</div>
)
}
// ─── Tab: Libreria firme ──────────────────────────────────────────────────────
function LibraryTab({ isAdmin, queryClient }: { isAdmin: boolean; queryClient: ReturnType<typeof useQueryClient> }) {
const [q, setQ] = useState('')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<SignatureResponse | null>(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 (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per nome..."
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-9"
/>
</div>
{isAdmin && (
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuova firma
</Button>
)}
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<PenLine className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessuna firma trovata</p>
<p className="text-sm mt-1">
{isAdmin
? 'Crea la prima firma con il pulsante in alto.'
: 'Nessuna firma disponibile.'}
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((s) => (
<div
key={s.id}
className="rounded-lg border bg-card p-4 space-y-2 hover:shadow-sm transition-shadow"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold truncate">{s.name}</h3>
{s.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{s.description}</p>
)}
</div>
{isAdmin && (
<div className="flex gap-1 flex-shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(s)}
className="h-8 w-8"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
if (confirm(`Eliminare la firma "${s.name}"?`)) {
deleteMutation.mutate(s.id)
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{/* Anteprima corpo */}
{s.body_html && (
<div
className="text-xs text-muted-foreground border rounded p-2 bg-muted/30 max-h-20 overflow-hidden line-clamp-3"
dangerouslySetInnerHTML={{ __html: s.body_html }}
/>
)}
<p className="text-xs text-muted-foreground">
Aggiornato: {formatDate(s.updated_at)}
</p>
</div>
))}
</div>
)}
{/* Dialog form firma */}
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica firma' : 'Nuova firma'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Es. Firma aziendale standard"
/>
</div>
<div className="space-y-2">
<Label>Descrizione (opzionale)</Label>
<Input
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Breve descrizione d'uso"
/>
</div>
<div className="space-y-2">
<Label>Testo della firma</Label>
<div className="min-h-[200px] border rounded-md overflow-hidden">
<RichTextEditor
value={formBody}
onChange={setFormBody}
placeholder="Scrivi qui la firma..."
/>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>
Annulla
</Button>
<Button
onClick={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
>
{editing ? 'Salva modifiche' : 'Crea firma'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── 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<string, (typeof assignments)[0]>()
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 (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (mailboxes.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground p-6">
<p className="text-lg font-medium">Nessuna casella PEC configurata</p>
<p className="text-sm mt-1">Aggiungi prima le caselle PEC nella sezione Caselle PEC.</p>
</div>
)
}
return (
<div className="p-6 space-y-4">
<p className="text-sm text-muted-foreground">
Assegna una firma per ogni casella PEC e per ogni contesto d'uso.
{!isAdmin && ' Solo gli amministratori possono modificare le assegnazioni.'}
</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Casella PEC</th>
<th className="text-left px-4 py-3 font-medium">Firma per Risposta</th>
<th className="text-left px-4 py-3 font-medium">Firma per Nuova PEC</th>
</tr>
</thead>
<tbody className="divide-y">
{mailboxes.map((mb) => (
<MailboxAssignmentRow
key={mb.id}
mailboxId={mb.id}
displayName={mb.display_name || mb.email_address}
emailAddress={mb.email_address}
status={mb.status}
signatures={signatures}
replyAssignment={getAssignment(mb.id, 'reply')}
composeAssignment={getAssignment(mb.id, 'compose')}
isAdmin={isAdmin}
onChangeReply={(sigId) => handleChange(mb.id, 'reply', sigId)}
onChangeCompose={(sigId) => handleChange(mb.id, 'compose', sigId)}
isPending={assignMutation.isPending || removeMutation.isPending}
/>
))}
</tbody>
</table>
</div>
</div>
)
}
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 (
<tr className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full flex-shrink-0 ${statusDot}`} />
<div>
<p className="font-medium">{displayName}</p>
{displayName !== emailAddress && (
<p className="text-xs text-muted-foreground">{emailAddress}</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={replyAssignment?.signature_id ?? ''}
onChange={onChangeReply}
disabled={!isAdmin || isPending}
/>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={composeAssignment?.signature_id ?? ''}
onChange={onChangeCompose}
disabled={!isAdmin || isPending}
/>
</td>
</tr>
)
}
// ─── 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<string, (typeof assignments)[0]>()
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 (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (vboxes.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground p-6">
<p className="text-lg font-medium">Nessuna Virtual Box configurata</p>
<p className="text-sm mt-1">Aggiungi prima le Virtual Box nella sezione Virtual Box.</p>
</div>
)
}
return (
<div className="p-6 space-y-4">
<p className="text-sm text-muted-foreground">
Assegna una firma per ogni Virtual Box e per ogni contesto d'uso.
{!isAdmin && ' Solo gli amministratori possono modificare le assegnazioni.'}
</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Virtual Box</th>
<th className="text-left px-4 py-3 font-medium">Firma per Risposta</th>
<th className="text-left px-4 py-3 font-medium">Firma per Nuova PEC</th>
</tr>
</thead>
<tbody className="divide-y">
{vboxes.map((vbox) => (
<tr key={vbox.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-purple-800 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
{(vbox.label || vbox.name)[0]?.toUpperCase() ?? '?'}
</div>
<div>
<p className="font-medium">{vbox.label || vbox.name}</p>
{vbox.description && (
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
{vbox.description}
</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={getAssignment(vbox.id, 'reply')?.signature_id ?? ''}
onChange={(sigId) => handleChange(vbox.id, 'reply', sigId)}
disabled={!isAdmin || assignMutation.isPending || removeMutation.isPending}
/>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={getAssignment(vbox.id, 'compose')?.signature_id ?? ''}
onChange={(sigId) => handleChange(vbox.id, 'compose', sigId)}
disabled={!isAdmin || assignMutation.isPending || removeMutation.isPending}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─── Select firma riutilizzabile ──────────────────────────────────────────────
interface SignatureSelectProps {
signatures: SignatureResponse[]
value: string
onChange: (signatureId: string) => void
disabled?: boolean
}
function SignatureSelect({ signatures, value, onChange, disabled }: SignatureSelectProps) {
return (
<select
className="flex h-8 w-full max-w-[220px] rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
<option value="">-- Nessuna firma --</option>
{signatures.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
)
}
+5 -1
View File
@@ -12,8 +12,9 @@ interface ComposeState {
mode: ComposeMode mode: ComposeMode
replyTo: MessageResponse | null replyTo: MessageResponse | null
forwardOf: 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 closeCompose: () => void
setMode: (mode: ComposeMode) => void setMode: (mode: ComposeMode) => void
} }
@@ -23,6 +24,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
mode: 'normal', mode: 'normal',
replyTo: null, replyTo: null,
forwardOf: null, forwardOf: null,
replyAll: false,
openCompose: (opts) => openCompose: (opts) =>
set({ set({
@@ -30,6 +32,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
mode: 'normal', mode: 'normal',
replyTo: opts?.replyTo ?? null, replyTo: opts?.replyTo ?? null,
forwardOf: opts?.forwardOf ?? null, forwardOf: opts?.forwardOf ?? null,
replyAll: opts?.replyAll ?? false,
}), }),
closeCompose: () => closeCompose: () =>
@@ -38,6 +41,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
mode: 'normal', mode: 'normal',
replyTo: null, replyTo: null,
forwardOf: null, forwardOf: null,
replyAll: false,
}), }),
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
+15
View File
@@ -211,6 +211,19 @@ export interface MessageBulkLabelResponse {
updated: number 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 ────────────────────────────────────────────────────────────────── // ─── Message ──────────────────────────────────────────────────────────────────
export type PecDirection = 'inbound' | 'outbound' export type PecDirection = 'inbound' | 'outbound'
@@ -270,6 +283,8 @@ export interface MessageResponse {
created_at: string created_at: string
updated_at: string updated_at: string
labels: LabelResponse[] labels: LabelResponse[]
// Popolato solo nelle risposte di ricerca full-text
search_match?: SearchMatchInfo | null
} }
export interface MessageListResponse { export interface MessageListResponse {
+9
View File
@@ -37,6 +37,15 @@ http {
# Hide nginx version # Hide nginx version
server_tokens off; 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 # Rate limiting zones
limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
+99 -7
View File
@@ -528,6 +528,8 @@ async def _save_message(
- Salvataggio allegati su MinIO + tabella attachments - Salvataggio allegati su MinIO + tabella attachments
- State machine outbound: solo per messaggi inbound (ricevute PEC) - State machine outbound: solo per messaggi inbound (ricevute PEC)
- Collegamento parent_message_id via X-Riferimento-Message-ID - Collegamento parent_message_id via X-Riferimento-Message-ID
- 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) ──── # ── Idempotenza: chiave composta (mailbox_id + imap_uid + imap_folder) ────
existing = await db.execute( existing = await db.execute(
@@ -552,17 +554,73 @@ async def _save_message(
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt) parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
received_at = datetime.now(UTC) 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 ──────────────────── # ── State machine: trova e aggiorna messaggio outbound ────────────────────
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata) # Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
parent_message_id: uuid.UUID | None = None parent_message_id: uuid.UUID | None = None
if direction == "inbound" and pec_class.is_receipt and pec_class.riferimento_message_id: if direction == "inbound" and pec_class.is_receipt and pec_class.riferimento_message_id:
try:
parent_message_id = await _apply_outbound_state_machine( parent_message_id = await _apply_outbound_state_machine(
riferimento_message_id=pec_class.riferimento_message_id, riferimento_message_id=pec_class.riferimento_message_id,
pec_type=pec_class.pec_type, pec_type=pec_class.pec_type,
tenant_id=mailbox.tenant_id, tenant_id=mailbox.tenant_id,
db=db, 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 ─────────────────────────────────────────────── # ── Upload raw EML su MinIO ───────────────────────────────────────────────
eml_path: str | None = None 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, Cerca il messaggio outbound con message_id_header == riferimento_message_id,
applica la transizione di stato se valida. 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: Returns:
UUID del messaggio originale se trovato, None altrimenti. UUID del messaggio originale se trovato, None altrimenti.
""" """
@@ -710,15 +774,37 @@ async def _apply_outbound_state_machine(
Message.direction == "outbound", Message.direction == "outbound",
) )
) )
parent_msg = result.scalar_one_or_none() candidates = result.scalars().all()
if not parent_msg: if not candidates:
logger.debug( logger.warning(
f"Messaggio outbound non trovato per riferimento={riferimento_message_id!r} " f"[receipt-binding] Messaggio outbound non trovato per "
f"(potrebbe essere stato inviato da client diverso)" 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 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) new_state = apply_outbound_transition(parent_msg.state, pec_type)
if new_state: if new_state:
old_state = parent_msg.state old_state = parent_msg.state
@@ -726,8 +812,14 @@ async def _apply_outbound_state_machine(
parent_msg.updated_at = datetime.now(UTC) parent_msg.updated_at = datetime.now(UTC)
await db.flush() await db.flush()
logger.info( logger.info(
f"State machine outbound: {riferimento_message_id!r} " f"[receipt-binding] State machine outbound: {riferimento_message_id!r} "
f"{old_state!r} {new_state!r} (ricevuta: {pec_type!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 return parent_msg.id
+27 -8
View File
@@ -7,14 +7,17 @@ Questo job viene usato per:
- Retry dopo un errore (called dal pool monitor) - Retry dopo un errore (called dal pool monitor)
Non sostituisce il loop IMAP continuo (IMAPConnection); è un one-shot job. 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 import logging
from typing import Any from typing import Any
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.imap.reconnect import ExponentialBackoff from app.imap.sync import sync_new_messages, sync_sent_messages
from app.imap.sync import sync_new_messages
from app.models import Mailbox from app.models import Mailbox
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,7 +25,7 @@ logger = logging.getLogger(__name__)
async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict: 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: Args:
ctx: contesto arq (contiene redis, pool reference) 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) creds = IMAPConnection._decrypt_creds(mailbox)
try: try:
from app.imap.connection import IMAPConnection
conn = IMAPConnection(mailbox_id=mailbox_id, redis_client=redis_client) conn = IMAPConnection(mailbox_id=mailbox_id, redis_client=redis_client)
client = await conn._connect(creds) 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: try:
await client.logout() await client.logout()
except Exception: except Exception:
pass pass
logger.info(
f"[sync_mailbox] {mailbox.email_address}: "
f"INBOX={n_inbox} nuovi, Sent={n_sent} nuovi"
)
return { return {
"status": "ok", "status": "ok",
"mailbox": mailbox.email_address, "mailbox": mailbox.email_address,
"new_messages": n, "new_messages_inbox": n_inbox,
"new_messages_sent": n_sent,
} }
except Exception as e: except Exception as e:
logger.error(f"[sync_mailbox] {mailbox_id} errore: {e}", exc_info=True) logger.error(f"[sync_mailbox] {mailbox_id} errore: {e}", exc_info=True)
return { return {
"status": "error", "status": "error",
"mailbox": mailbox.email_address, "mailbox": mailbox.email_address if mailbox else mailbox_id,
"message": str(e), "message": str(e),
} }