mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Modifiche varie
This commit is contained in:
@@ -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")
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Router Audit Log – consultazione degli eventi di sistema.
|
||||
Router Audit Log – consultazione ed esportazione degli eventi di sistema.
|
||||
|
||||
Endpoint:
|
||||
GET /api/v1/audit-log – lista paginata con filtri (solo admin/super_admin)
|
||||
GET /api/v1/audit-log/export – esportazione CSV o PDF (solo admin/super_admin)
|
||||
|
||||
Permessi:
|
||||
- admin: vede solo gli eventi del proprio tenant
|
||||
@@ -14,6 +15,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from app.dependencies import AdminUser, DB
|
||||
from app.schemas.audit_log import AuditLogListResponse
|
||||
@@ -22,6 +24,13 @@ from app.services.audit_service import AuditService
|
||||
router = APIRouter(prefix="/audit-log", tags=["Audit Log"])
|
||||
|
||||
|
||||
def _effective_tenant_id(current_user, tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
|
||||
"""Determina il tenant_id effettivo in base al ruolo dell'utente."""
|
||||
if current_user.is_super_admin:
|
||||
return tenant_id # None = tutti i tenant
|
||||
return current_user.tenant_id # sempre vincolato al proprio tenant
|
||||
|
||||
|
||||
@router.get("", response_model=AuditLogListResponse)
|
||||
async def list_audit_log(
|
||||
current_user: AdminUser,
|
||||
@@ -36,24 +45,10 @@ async def list_audit_log(
|
||||
resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"),
|
||||
tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"),
|
||||
) -> AuditLogListResponse:
|
||||
"""
|
||||
Restituisce la lista paginata degli eventi di audit.
|
||||
|
||||
- Admin: vede solo gli eventi del proprio tenant (tenant_id ignorato).
|
||||
- Super Admin: vede tutti i tenant, filtrabile per tenant_id.
|
||||
"""
|
||||
"""Restituisce la lista paginata degli eventi di audit."""
|
||||
svc = AuditService(db)
|
||||
|
||||
# Determina il tenant_id effettivo da applicare al filtro
|
||||
if current_user.is_super_admin:
|
||||
# Super admin: usa il tenant_id passato come filtro (None = tutti)
|
||||
effective_tenant_id = tenant_id
|
||||
else:
|
||||
# Admin normale: sempre vincolato al proprio tenant
|
||||
effective_tenant_id = current_user.tenant_id
|
||||
|
||||
return await svc.list(
|
||||
tenant_id=effective_tenant_id,
|
||||
tenant_id=_effective_tenant_id(current_user, tenant_id),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
action=action,
|
||||
@@ -63,3 +58,65 @@ async def list_audit_log(
|
||||
date_to=date_to,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_audit_log(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
format: str = Query("csv", pattern="^(csv|pdf)$", description="Formato: csv o pdf"),
|
||||
action: Optional[str] = Query(None),
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
outcome: Optional[str] = Query(None, pattern="^(success|failure)$"),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
resource_type: Optional[str] = Query(None),
|
||||
tenant_id: Optional[uuid.UUID] = Query(None),
|
||||
) -> Response:
|
||||
"""
|
||||
Esporta i log di audit in formato CSV o PDF.
|
||||
Applica gli stessi filtri dell'endpoint lista.
|
||||
"""
|
||||
svc = AuditService(db)
|
||||
effective_tid = _effective_tenant_id(current_user, tenant_id)
|
||||
|
||||
# Nome file con periodo
|
||||
suffix = ""
|
||||
if date_from:
|
||||
suffix += f"_dal_{date_from.strftime('%Y%m%d')}"
|
||||
if date_to:
|
||||
suffix += f"_al_{date_to.strftime('%Y%m%d')}"
|
||||
|
||||
if format == "csv":
|
||||
csv_content = await svc.export_csv(
|
||||
tenant_id=effective_tid,
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
outcome=outcome,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
filename = f"audit_log{suffix}.csv"
|
||||
return Response(
|
||||
content=csv_content.encode("utf-8-sig"), # BOM per compatibilita' Excel
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
else: # pdf
|
||||
pdf_bytes = await svc.export_pdf_bytes(
|
||||
tenant_id=effective_tid,
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
outcome=outcome,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
filename = f"audit_log{suffix}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.schemas.auth import (
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.user import UserResponse
|
||||
from app.services.audit_service import get_real_ip
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
settings = get_settings()
|
||||
@@ -51,7 +52,7 @@ async def login(
|
||||
body: LoginRequest,
|
||||
db: DB,
|
||||
) -> TokenResponse:
|
||||
ip = request.client.host if request.client else None
|
||||
ip = get_real_ip(request)
|
||||
ua = request.headers.get("user-agent")
|
||||
|
||||
service = AuthService(db)
|
||||
@@ -177,7 +178,7 @@ async def change_password(
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
outcome="failure",
|
||||
ip_address=request.client.host if request.client else None,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={"reason": "wrong_current_password"},
|
||||
)
|
||||
@@ -189,6 +190,6 @@ async def change_password(
|
||||
"auth.password_changed",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
|
||||
@@ -23,12 +23,13 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from fastapi import APIRouter, Query, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.services.audit_service import get_real_ip
|
||||
from app.services.search_service import SearchService
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -38,12 +39,14 @@ from app.dependencies import CurrentUser, DB
|
||||
from app.models.label import Label
|
||||
from app.models.message import Attachment, Message
|
||||
from app.schemas.message import (
|
||||
AttachmentMatchInfo,
|
||||
AttachmentResponse,
|
||||
MessageBulkUpdateRequest,
|
||||
MessageBulkUpdateResponse,
|
||||
MessageListResponse,
|
||||
MessageResponse,
|
||||
MessageUpdateRequest,
|
||||
SearchMatchInfo,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["Messages"])
|
||||
@@ -330,8 +333,45 @@ async def list_messages(
|
||||
result = await db.execute(q)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
# ── Popola search_match per i risultati di ricerca ────────────────────────
|
||||
if search and items:
|
||||
term_lower = search.lower()
|
||||
msg_ids = [m.id for m in items]
|
||||
term_like = f"%{search}%"
|
||||
|
||||
# Query batch: allegati con extracted_text che matcha il termine
|
||||
att_result = await db.execute(
|
||||
select(Attachment.id, Attachment.message_id, Attachment.filename)
|
||||
.where(
|
||||
Attachment.message_id.in_(msg_ids),
|
||||
Attachment.extracted_text.ilike(term_like),
|
||||
)
|
||||
)
|
||||
# Mappa message_id → lista di AttachmentMatchInfo che matchano
|
||||
att_matches: dict[uuid.UUID, list[AttachmentMatchInfo]] = {}
|
||||
for row in att_result.fetchall():
|
||||
att_id, msg_id, filename = row
|
||||
att_matches.setdefault(msg_id, []).append(
|
||||
AttachmentMatchInfo(id=att_id, filename=filename)
|
||||
)
|
||||
|
||||
message_responses: list[MessageResponse] = []
|
||||
for m in items:
|
||||
resp = MessageResponse.model_validate(m)
|
||||
in_subject = bool(m.subject and term_lower in m.subject.lower())
|
||||
in_body = bool(m.body_text and term_lower in m.body_text.lower())
|
||||
in_attachments = att_matches.get(m.id, [])
|
||||
resp.search_match = SearchMatchInfo(
|
||||
in_subject=in_subject,
|
||||
in_body=in_body,
|
||||
in_attachments=in_attachments,
|
||||
)
|
||||
message_responses.append(resp)
|
||||
else:
|
||||
message_responses = [MessageResponse.model_validate(m) for m in items]
|
||||
|
||||
return MessageListResponse(
|
||||
items=[MessageResponse.model_validate(m) for m in items],
|
||||
items=message_responses,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -340,6 +380,7 @@ async def list_messages(
|
||||
|
||||
@router.patch("/bulk", response_model=MessageBulkUpdateResponse)
|
||||
async def bulk_update_messages(
|
||||
request: Request,
|
||||
data: MessageBulkUpdateRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
@@ -349,6 +390,8 @@ async def bulk_update_messages(
|
||||
|
||||
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
|
||||
"""
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
if not data.ids:
|
||||
return MessageBulkUpdateResponse(updated=0, items=[])
|
||||
|
||||
@@ -415,6 +458,29 @@ async def bulk_update_messages(
|
||||
elif not data.is_conserved:
|
||||
message.conserved_at = None
|
||||
|
||||
# Registra evento audit bulk
|
||||
if messages:
|
||||
changes: dict = {}
|
||||
for field in ("is_read", "is_starred", "is_archived", "is_trashed",
|
||||
"is_pending_conservation", "is_conserved"):
|
||||
v = getattr(data, field, None)
|
||||
if v is not None:
|
||||
changes[field] = v
|
||||
await log_audit(
|
||||
db,
|
||||
"message.bulk_updated",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="message",
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={
|
||||
"count": len(messages),
|
||||
"message_ids": [str(m.id) for m in messages],
|
||||
"changes": changes,
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
if messages:
|
||||
@@ -434,17 +500,37 @@ async def bulk_update_messages(
|
||||
|
||||
@router.get("/{message_id}", response_model=MessageResponse)
|
||||
async def get_message(
|
||||
request: Request,
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> MessageResponse:
|
||||
"""Carica un messaggio per ID."""
|
||||
from app.services.audit_service import log_audit
|
||||
message = await _resolve_message(message_id, current_user, db)
|
||||
await log_audit(
|
||||
db,
|
||||
"message.opened",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="message",
|
||||
resource_id=message.id,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={
|
||||
"subject": message.subject,
|
||||
"from_address": message.from_address,
|
||||
"direction": message.direction,
|
||||
"mailbox_id": str(message.mailbox_id),
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
return MessageResponse.model_validate(message)
|
||||
|
||||
|
||||
@router.patch("/{message_id}", response_model=MessageResponse)
|
||||
async def update_message(
|
||||
request: Request,
|
||||
message_id: uuid.UUID,
|
||||
data: MessageUpdateRequest,
|
||||
current_user: CurrentUser,
|
||||
@@ -455,6 +541,8 @@ async def update_message(
|
||||
|
||||
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
|
||||
"""
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
message = await _resolve_message(message_id, current_user, db)
|
||||
|
||||
# Verifica permesso conservazione se necessario
|
||||
@@ -464,6 +552,19 @@ async def update_message(
|
||||
await perm_svc.require_can_conserve(current_user, message.mailbox_id)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
ip = get_real_ip(request)
|
||||
ua = request.headers.get("user-agent")
|
||||
base_payload = {"subject": message.subject, "mailbox_id": str(message.mailbox_id)}
|
||||
|
||||
# Mappa flag → coppia (action_true, action_false)
|
||||
_FLAG_ACTIONS: dict[str, tuple[str, str]] = {
|
||||
"is_read": ("message.read", "message.unread"),
|
||||
"is_starred": ("message.starred", "message.unstarred"),
|
||||
"is_archived": ("message.archived", "message.unarchived"),
|
||||
"is_trashed": ("message.trashed", "message.restored"),
|
||||
"is_pending_conservation": ("message.pending_conservation","message.conservation_cancelled"),
|
||||
"is_conserved": ("message.conserved", "message.conservation_removed"),
|
||||
}
|
||||
|
||||
if data.is_read is not None:
|
||||
message.is_read = data.is_read
|
||||
@@ -494,6 +595,23 @@ async def update_message(
|
||||
elif not data.is_conserved:
|
||||
message.conserved_at = None
|
||||
|
||||
# Registra un evento di audit per ogni flag modificato
|
||||
for field, (action_true, action_false) in _FLAG_ACTIONS.items():
|
||||
value = getattr(data, field, None)
|
||||
if value is not None:
|
||||
action = action_true if value else action_false
|
||||
await log_audit(
|
||||
db,
|
||||
action,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="message",
|
||||
resource_id=message_id,
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
payload=base_payload,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
refreshed = await db.execute(
|
||||
select(Message)
|
||||
@@ -524,12 +642,15 @@ async def list_attachments(
|
||||
|
||||
@router.get("/{message_id}/attachments/{attachment_id}/download")
|
||||
async def download_attachment(
|
||||
request: Request,
|
||||
message_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> StreamingResponse:
|
||||
"""Scarica un allegato direttamente da MinIO."""
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
await _resolve_message(message_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
@@ -542,6 +663,23 @@ async def download_attachment(
|
||||
if not attachment:
|
||||
raise NotFoundError(f"Allegato {attachment_id} non trovato")
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
"message.attachment_downloaded",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="attachment",
|
||||
resource_id=attachment.id,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={
|
||||
"filename": attachment.filename,
|
||||
"message_id": str(message_id),
|
||||
"size_bytes": attachment.size_bytes,
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from miniopy_async import Minio
|
||||
|
||||
@@ -580,6 +718,7 @@ async def download_attachment(
|
||||
|
||||
@router.get("/{message_id}/download-package")
|
||||
async def download_package(
|
||||
request: Request,
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
@@ -588,6 +727,7 @@ async def download_package(
|
||||
import io
|
||||
import zipfile as _zipfile
|
||||
|
||||
from app.services.audit_service import log_audit
|
||||
from miniopy_async import Minio
|
||||
|
||||
message = await _resolve_message(message_id, current_user, db)
|
||||
@@ -682,6 +822,23 @@ async def download_package(
|
||||
safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
|
||||
zip_filename = f"pec_{safe_subject}.zip"
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
"message.package_downloaded",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="message",
|
||||
resource_id=message.id,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={
|
||||
"subject": message.subject,
|
||||
"zip_filename": zip_filename,
|
||||
"zip_size_bytes": len(zip_bytes),
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return StreamingResponse(
|
||||
iter([zip_bytes]),
|
||||
media_type="application/zip",
|
||||
|
||||
@@ -13,11 +13,12 @@ import json
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile, status
|
||||
|
||||
from app.core.exceptions import ForbiddenError
|
||||
from app.dependencies import CurrentUser, DB
|
||||
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
|
||||
from app.services.audit_service import get_real_ip
|
||||
from app.services.permission_service import PermissionService
|
||||
from app.services.send_service import SendService
|
||||
|
||||
@@ -47,12 +48,33 @@ def _job_response(job) -> SendJobResponse:
|
||||
),
|
||||
)
|
||||
async def create_send_job(
|
||||
request: Request,
|
||||
data: SendPecRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> SendJobResponse:
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
svc = _svc(db)
|
||||
job = await svc.create_send_job(current_user=current_user, data=data)
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
"pec.sent",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="send_job",
|
||||
resource_id=job.id,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={
|
||||
"mailbox_id": str(job.mailbox_id),
|
||||
"to_addresses": data.to_addresses,
|
||||
"subject": data.subject,
|
||||
"has_attachments": False,
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return _job_response(job)
|
||||
@@ -74,6 +96,7 @@ async def create_send_job(
|
||||
),
|
||||
)
|
||||
async def create_send_job_multipart(
|
||||
request: Request,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
data: str = Form(
|
||||
@@ -85,6 +108,8 @@ async def create_send_job_multipart(
|
||||
description="File allegati (0 o più, max 20 MB ciascuno)",
|
||||
),
|
||||
) -> SendJobResponse:
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
# ── Parse del JSON ────────────────────────────────────────────────────────
|
||||
try:
|
||||
raw = json.loads(data)
|
||||
@@ -128,6 +153,25 @@ async def create_send_job_multipart(
|
||||
data=pec_data,
|
||||
attachments=files_data if files_data else None,
|
||||
)
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
"pec.sent",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="send_job",
|
||||
resource_id=job.id,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={
|
||||
"mailbox_id": str(job.mailbox_id),
|
||||
"to_addresses": pec_data.to_addresses,
|
||||
"subject": pec_data.subject,
|
||||
"has_attachments": bool(files_data),
|
||||
"attachment_count": len(files_data),
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return _job_response(job)
|
||||
@@ -205,10 +249,13 @@ async def get_send_job(
|
||||
summary="Annulla job di invio",
|
||||
)
|
||||
async def cancel_send_job(
|
||||
request: Request,
|
||||
job_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
svc = _svc(db)
|
||||
job = await svc.get_send_job(job_id, current_user.tenant_id)
|
||||
|
||||
@@ -218,4 +265,21 @@ async def cancel_send_job(
|
||||
raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio")
|
||||
|
||||
await svc.cancel_send_job(job_id, current_user.tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
"pec.send_cancelled",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
resource_type="send_job",
|
||||
resource_id=job_id,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={
|
||||
"job_id": str(job_id),
|
||||
"mailbox_id": str(job.mailbox_id),
|
||||
"previous_status": job.status,
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -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
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, tenants, templates, users, virtual_boxes, ws
|
||||
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws
|
||||
from app.api.v1 import settings as settings_router
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
@@ -102,6 +102,7 @@ app.include_router(templates.router, prefix=API_PREFIX)
|
||||
app.include_router(routing_rules.router, prefix=API_PREFIX)
|
||||
app.include_router(contacts.router, prefix=API_PREFIX)
|
||||
app.include_router(deadlines.router, prefix=API_PREFIX)
|
||||
app.include_router(signatures.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,3 +13,4 @@ from app.models.tenant_settings import TenantSettings # noqa: F401
|
||||
from app.models.template import MessageTemplate # noqa: F401
|
||||
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
|
||||
from app.models.pec_contact import PecContact # noqa: F401
|
||||
from app.models.signature import Signature, SignatureAssignment # noqa: F401
|
||||
|
||||
@@ -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}>"
|
||||
@@ -17,6 +17,9 @@ class AuditLogResponse(BaseModel):
|
||||
id: int
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
# Dati utente denormalizzati (arricchiti dalla JOIN nel service)
|
||||
user_email: Optional[str] = None
|
||||
user_full_name: Optional[str] = None
|
||||
action: str
|
||||
resource_type: Optional[str] = None
|
||||
resource_id: Optional[uuid.UUID] = None
|
||||
|
||||
@@ -11,6 +11,27 @@ from pydantic import BaseModel, model_validator
|
||||
from app.schemas.label import LabelResponse
|
||||
|
||||
|
||||
# ─── Search match info ────────────────────────────────────────────────────────
|
||||
|
||||
class AttachmentMatchInfo(BaseModel):
|
||||
"""Allegato in cui e' stato trovato il termine cercato."""
|
||||
id: uuid.UUID
|
||||
filename: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SearchMatchInfo(BaseModel):
|
||||
"""Indica dove e' stato trovato il termine di ricerca in un messaggio."""
|
||||
in_subject: bool = False
|
||||
in_body: bool = False
|
||||
in_attachments: list[AttachmentMatchInfo] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Attachment ───────────────────────────────────────────────────────────────
|
||||
|
||||
class AttachmentResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
message_id: uuid.UUID
|
||||
@@ -59,6 +80,8 @@ class MessageResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
labels: list[LabelResponse] = []
|
||||
# Popolato solo nelle risposte di ricerca full-text
|
||||
search_match: Optional[SearchMatchInfo] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -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
|
||||
@@ -12,25 +12,54 @@ Uso tipico nei router/servizi:
|
||||
resource_type="user",
|
||||
resource_id=new_user.id,
|
||||
outcome="success",
|
||||
ip_address=request.client.host if request.client else None,
|
||||
ip_address=get_real_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={"email": new_user.email},
|
||||
)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.core.pagination import PaginatedResponse, PaginationParams
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
from app.schemas.audit_log import AuditLogResponse
|
||||
|
||||
|
||||
# ─── Helper IP reale (legge X-Real-IP o X-Forwarded-For da reverse proxy) ────
|
||||
|
||||
def get_real_ip(request: Request) -> Optional[str]:
|
||||
"""
|
||||
Restituisce l'IP reale del client, leggendo gli header del reverse proxy.
|
||||
Priorita': X-Real-IP > primo IP di X-Forwarded-For > request.client.host
|
||||
"""
|
||||
x_real_ip = request.headers.get("x-real-ip")
|
||||
if x_real_ip:
|
||||
return x_real_ip.strip()
|
||||
|
||||
x_forwarded_for = request.headers.get("x-forwarded-for", "")
|
||||
if x_forwarded_for:
|
||||
# Prende il primo IP della catena (quello del client originale)
|
||||
first_ip = x_forwarded_for.split(",")[0].strip()
|
||||
if first_ip:
|
||||
return first_ip
|
||||
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ───────
|
||||
|
||||
async def log_audit(
|
||||
@@ -79,31 +108,35 @@ class AuditService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list(
|
||||
def _build_query(
|
||||
self,
|
||||
*,
|
||||
tenant_id: Optional[uuid.UUID],
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
outcome: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> PaginatedResponse[AuditLogResponse]:
|
||||
"""
|
||||
Restituisce la lista paginata degli eventi audit.
|
||||
):
|
||||
"""Costruisce la query base con JOIN utente e filtri."""
|
||||
UserAlias = aliased(User)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
AuditLog,
|
||||
UserAlias.email.label("user_email"),
|
||||
UserAlias.full_name.label("user_full_name"),
|
||||
)
|
||||
.outerjoin(UserAlias, UserAlias.id == AuditLog.user_id)
|
||||
)
|
||||
|
||||
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
|
||||
"""
|
||||
filters = []
|
||||
|
||||
if tenant_id is not None:
|
||||
filters.append(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if action:
|
||||
# Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.*
|
||||
if action.endswith("*"):
|
||||
filters.append(AuditLog.action.like(action[:-1] + "%"))
|
||||
else:
|
||||
@@ -124,30 +157,221 @@ class AuditService:
|
||||
if resource_type:
|
||||
filters.append(AuditLog.resource_type == resource_type)
|
||||
|
||||
where_clause = and_(*filters) if filters else True # type: ignore[arg-type]
|
||||
if filters:
|
||||
stmt = stmt.where(and_(*filters))
|
||||
|
||||
return stmt
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
tenant_id: Optional[uuid.UUID],
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
outcome: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> PaginatedResponse[AuditLogResponse]:
|
||||
"""
|
||||
Restituisce la lista paginata degli eventi audit con dati utente.
|
||||
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
|
||||
"""
|
||||
base_stmt = self._build_query(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
outcome=outcome,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
|
||||
# Count totale
|
||||
count_q = select(func.count()).select_from(AuditLog).where(where_clause)
|
||||
count_q = select(func.count()).select_from(base_stmt.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar_one()
|
||||
|
||||
# Dati paginati
|
||||
offset = (page - 1) * page_size
|
||||
items_q = (
|
||||
select(AuditLog)
|
||||
.where(where_clause)
|
||||
.order_by(AuditLog.occurred_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await self.db.execute(items_q)
|
||||
items = list(result.scalars().all())
|
||||
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).offset(offset).limit(page_size)
|
||||
rows = (await self.db.execute(items_q)).all()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
entry: AuditLog = row[0]
|
||||
email: Optional[str] = row[1]
|
||||
full_name: Optional[str] = row[2]
|
||||
resp = AuditLogResponse.model_validate(entry)
|
||||
resp.user_email = email
|
||||
resp.user_full_name = full_name
|
||||
items.append(resp)
|
||||
|
||||
pages = math.ceil(total / page_size) if page_size > 0 else 0
|
||||
|
||||
return PaginatedResponse[AuditLogResponse](
|
||||
items=[AuditLogResponse.model_validate(item) for item in items],
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
async def export_csv(
|
||||
self,
|
||||
*,
|
||||
tenant_id: Optional[uuid.UUID],
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
outcome: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
limit: int = 10000,
|
||||
) -> str:
|
||||
"""
|
||||
Restituisce i log in formato CSV (stringa).
|
||||
Massimo `limit` righe per prevenire export eccessivi.
|
||||
"""
|
||||
base_stmt = self._build_query(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
outcome=outcome,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit)
|
||||
rows = (await self.db.execute(items_q)).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"ID", "Data/Ora", "Azione", "Esito",
|
||||
"Utente", "Email utente", "IP",
|
||||
"Tipo risorsa", "ID risorsa",
|
||||
])
|
||||
|
||||
for row in rows:
|
||||
entry: AuditLog = row[0]
|
||||
email: Optional[str] = row[1]
|
||||
full_name: Optional[str] = row[2]
|
||||
writer.writerow([
|
||||
entry.id,
|
||||
entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"),
|
||||
entry.action,
|
||||
entry.outcome,
|
||||
full_name or "",
|
||||
email or "",
|
||||
str(entry.ip_address) if entry.ip_address else "",
|
||||
entry.resource_type or "",
|
||||
str(entry.resource_id) if entry.resource_id else "",
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
async def export_pdf_bytes(
|
||||
self,
|
||||
*,
|
||||
tenant_id: Optional[uuid.UUID],
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
outcome: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
limit: int = 5000,
|
||||
) -> bytes:
|
||||
"""
|
||||
Genera un PDF con i log di audit usando reportlab.
|
||||
Restituisce i byte del PDF.
|
||||
"""
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer,
|
||||
)
|
||||
|
||||
base_stmt = self._build_query(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
outcome=outcome,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit)
|
||||
rows = (await self.db.execute(items_q)).all()
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=landscape(A4),
|
||||
rightMargin=1 * cm,
|
||||
leftMargin=1 * cm,
|
||||
topMargin=1.5 * cm,
|
||||
bottomMargin=1.5 * cm,
|
||||
)
|
||||
styles = getSampleStyleSheet()
|
||||
story = []
|
||||
|
||||
# Titolo
|
||||
title_text = "Audit Log"
|
||||
if date_from or date_to:
|
||||
parts = []
|
||||
if date_from:
|
||||
parts.append(f"dal {date_from.strftime('%d/%m/%Y')}")
|
||||
if date_to:
|
||||
parts.append(f"al {date_to.strftime('%d/%m/%Y')}")
|
||||
title_text += " – " + " ".join(parts)
|
||||
|
||||
story.append(Paragraph(title_text, styles["Heading1"]))
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
story.append(Paragraph(
|
||||
f"Esportato il {datetime.now().strftime('%d/%m/%Y %H:%M')} — {len(rows)} record",
|
||||
styles["Normal"],
|
||||
))
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
# Tabella
|
||||
headers = ["Data/Ora", "Azione", "Esito", "Utente", "IP", "Risorsa"]
|
||||
table_data = [headers]
|
||||
|
||||
for row in rows:
|
||||
entry: AuditLog = row[0]
|
||||
email: Optional[str] = row[1]
|
||||
full_name: Optional[str] = row[2]
|
||||
utente = full_name or email or (str(entry.user_id)[:8] if entry.user_id else "—")
|
||||
risorsa = entry.resource_type or "—"
|
||||
table_data.append([
|
||||
entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"),
|
||||
entry.action,
|
||||
"OK" if entry.outcome == "success" else "FAIL",
|
||||
utente,
|
||||
str(entry.ip_address) if entry.ip_address else "—",
|
||||
risorsa,
|
||||
])
|
||||
|
||||
col_widths = [3.8 * cm, 5.0 * cm, 1.8 * cm, 4.5 * cm, 3.5 * cm, 3.0 * cm]
|
||||
table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
||||
table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a5f")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 8),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 7),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f8ff")]),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cccccc")),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||||
]))
|
||||
story.append(table)
|
||||
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
|
||||
@@ -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()
|
||||
@@ -18,6 +18,7 @@ import { TemplatesPage } from '@/pages/Templates/TemplatesPage'
|
||||
import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
|
||||
import { ContactsPage } from '@/pages/Contacts/ContactsPage'
|
||||
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
|
||||
import { SignaturesPage } from '@/pages/Signatures/SignaturesPage'
|
||||
|
||||
/**
|
||||
* Routing principale dell'applicazione PEChub.
|
||||
@@ -101,6 +102,7 @@ export default function App() {
|
||||
<Route path="/routing-rules" element={<RoutingRulesPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/deadlines" element={<DeadlinesPage />} />
|
||||
<Route path="/signatures" element={<SignaturesPage />} />
|
||||
|
||||
{/* Profilo utente */}
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface AuditLogEntry {
|
||||
id: number
|
||||
tenant_id: string | null
|
||||
user_id: string | null
|
||||
user_email: string | null
|
||||
user_full_name: string | null
|
||||
action: string
|
||||
resource_type: string | null
|
||||
resource_id: string | null
|
||||
@@ -38,4 +40,19 @@ export const auditLogApi = {
|
||||
apiClient
|
||||
.get<AuditLogListResponse>('/audit-log', { params })
|
||||
.then((r) => r.data),
|
||||
|
||||
/**
|
||||
* Esporta i log come file (CSV o PDF).
|
||||
* Restituisce un Blob per il download lato browser.
|
||||
*/
|
||||
export: async (
|
||||
format: 'csv' | 'pdf',
|
||||
params: Omit<AuditLogParams, 'page' | 'page_size'> = {},
|
||||
): Promise<Blob> => {
|
||||
const response = await apiClient.get('/audit-log/export', {
|
||||
params: { format, ...params },
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data as Blob
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 { useForm, useFieldArray } from 'react-hook-form'
|
||||
import { useForm, useFieldArray, useWatch } from 'react-hook-form'
|
||||
import {
|
||||
Send,
|
||||
X,
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Minus,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
@@ -18,13 +20,38 @@ import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
|
||||
import { ContactPickerPopover } from '@/components/ComposeModal/ContactPickerPopover'
|
||||
import { sendApi } from '@/api/send.api'
|
||||
import { mailboxesApi } from '@/api/mailboxes.api'
|
||||
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
|
||||
import { signaturesApi } from '@/api/signatures.api'
|
||||
import { templatesApi } from '@/api/templates.api'
|
||||
import type { TemplateResponse } from '@/api/templates.api'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { useComposeStore } from '@/store/compose.store'
|
||||
import type { MessageResponse } from '@/types/api.types'
|
||||
|
||||
// ─── Utilita' firma ───────────────────────────────────────────────────────────
|
||||
|
||||
const SIG_ATTR = 'data-pechub-sig'
|
||||
|
||||
/** Rimuove il blocco firma esistente e (opzionalmente) ne inserisce uno nuovo. */
|
||||
function injectSignature(body: string, sigHtml: string | null): string {
|
||||
const withoutSig = body.replace(
|
||||
new RegExp(`<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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MailboxSelectItem {
|
||||
@@ -92,32 +119,73 @@ function buildInitialBody(replyTo?: MessageResponse | null, forwardOf?: MessageR
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcola i destinatari iniziali per "Rispondi a tutti":
|
||||
* from + tutti i to, deduplicati (la propria email verra' filtrata dopo che le caselle si caricano).
|
||||
*/
|
||||
function buildReplyAllToAddresses(replyTo: MessageResponse): { value: string }[] {
|
||||
const seen = new Set<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) ───────────────
|
||||
|
||||
interface ComposeFormProps {
|
||||
replyTo: MessageResponse | null
|
||||
forwardOf: MessageResponse | null
|
||||
replyAll: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
function ComposeForm({ replyTo, forwardOf, replyAll, onClose }: ComposeFormProps) {
|
||||
const fileInputRef = useRef<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 [bodyHtml, setBodyHtml] = useState<string>(() =>
|
||||
buildInitialBody(replyTo, forwardOf)
|
||||
)
|
||||
|
||||
// Flag per eseguire il filtro della propria email una sola volta dopo il caricamento delle caselle
|
||||
const ownEmailFilteredRef = useRef(false)
|
||||
|
||||
const buildDefaultTo = (): { value: string }[] => {
|
||||
if (replyAll && replyTo) return buildReplyAllToAddresses(replyTo)
|
||||
if (replyTo) return [{ value: replyTo.from_address || '' }]
|
||||
return [{ value: '' }]
|
||||
}
|
||||
|
||||
const buildDefaultCc = (): { value: string }[] => {
|
||||
if (replyAll && replyTo && replyTo.cc_addresses?.length > 0) {
|
||||
return replyTo.cc_addresses.map((a) => ({ value: a }))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<ComposeFormValues>({
|
||||
defaultValues: {
|
||||
mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '',
|
||||
to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }],
|
||||
cc_addresses: [],
|
||||
to_addresses: buildDefaultTo(),
|
||||
cc_addresses: buildDefaultCc(),
|
||||
subject: replyTo
|
||||
? `Re: ${replyTo.subject || ''}`
|
||||
: forwardOf
|
||||
@@ -126,9 +194,56 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
},
|
||||
})
|
||||
|
||||
const { fields: toFields, append: appendTo, remove: removeTo } = useFieldArray({ control, name: 'to_addresses' })
|
||||
const { fields: toFields, append: appendTo, remove: removeTo, replace: replaceTo } = useFieldArray({ control, name: 'to_addresses' })
|
||||
const { fields: ccFields, append: appendCc, remove: removeCc } = useFieldArray({ control, name: 'cc_addresses' })
|
||||
|
||||
// ── Auto-inserimento firma ────────────────────────────────────────────────
|
||||
const watchedMailboxId = useWatch({ control, name: 'mailbox_id' })
|
||||
const signatureContext = replyTo ? 'reply' : 'compose'
|
||||
const sigHtmlRef = useRef<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({
|
||||
queryKey: ['mailboxes'],
|
||||
queryFn: () => mailboxesApi.list(),
|
||||
@@ -139,18 +254,6 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
queryFn: () => virtualBoxesApi.getMyMailboxes(),
|
||||
})
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (args: { data: Parameters<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 regularActive: MailboxSelectItem[] = (
|
||||
mailboxesData?.items.filter((m) => m.status === 'active') || []
|
||||
@@ -179,6 +282,33 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
|
||||
const isLoadingMailboxes = mailboxesLoading || vboxMailboxesLoading
|
||||
|
||||
// ── Filtro propria email per "Rispondi a tutti" ───────────────────────────
|
||||
// Eseguito una sola volta dopo che le caselle si caricano e la mailbox e' selezionata
|
||||
useEffect(() => {
|
||||
if (!replyAll || !replyTo || isLoadingMailboxes || !watchedMailboxId || ownEmailFilteredRef.current) return
|
||||
const ownMailbox = activeCaselle.find((m) => m.id === watchedMailboxId)
|
||||
if (!ownMailbox) return
|
||||
ownEmailFilteredRef.current = true
|
||||
const ownEmail = ownMailbox.email_address.toLowerCase()
|
||||
const current = toFields.map((f) => f.value)
|
||||
const filtered = current.filter((v) => v.toLowerCase() !== ownEmail)
|
||||
if (filtered.length === 0) filtered.push('')
|
||||
replaceTo(filtered.map((v) => ({ value: v })))
|
||||
}, [replyAll, replyTo, isLoadingMailboxes, watchedMailboxId, activeCaselle, toFields, replaceTo])
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (args: { data: Parameters<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) => {
|
||||
if (!files) return
|
||||
const valid: File[] = []
|
||||
@@ -299,6 +429,10 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
|
||||
})}
|
||||
/>
|
||||
<ContactPickerPopover
|
||||
size="sm"
|
||||
onSelect={(email) => setValue(`to_addresses.${idx}.value`, email)}
|
||||
/>
|
||||
{toFields.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -322,7 +456,7 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
{!showCc ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCc(true)}
|
||||
onClick={() => { setShowCc(true); if (ccFields.length === 0) appendCc({ value: '' }) }}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
+ Aggiungi Cc
|
||||
@@ -351,6 +485,10 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
|
||||
})}
|
||||
/>
|
||||
<ContactPickerPopover
|
||||
size="sm"
|
||||
onSelect={(email) => setValue(`cc_addresses.${idx}.value`, email)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCc(idx)}
|
||||
@@ -383,7 +521,58 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
|
||||
{/* Corpo */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
value={bodyHtml}
|
||||
onChange={setBodyHtml}
|
||||
@@ -469,7 +658,7 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
|
||||
// ─── Componente principale flottante ──────────────────────────────────────────
|
||||
|
||||
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)
|
||||
useEffect(() => {
|
||||
@@ -484,10 +673,15 @@ export function ComposeModal() {
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const title = replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'
|
||||
const title = replyAll
|
||||
? 'Rispondi a tutti'
|
||||
: replyTo
|
||||
? 'Rispondi a PEC'
|
||||
: forwardOf
|
||||
? 'Inoltra PEC'
|
||||
: 'Nuova PEC'
|
||||
const subtitle = replyTo?.subject || forwardOf?.subject || null
|
||||
|
||||
// Stile inline per il posizionamento (piu' affidabile delle classi Tailwind dinamiche)
|
||||
const containerStyle = (() => {
|
||||
if (mode === 'fullscreen') {
|
||||
return {
|
||||
@@ -507,7 +701,6 @@ export function ComposeModal() {
|
||||
height: '48px',
|
||||
}
|
||||
}
|
||||
// normal
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
bottom: 0,
|
||||
@@ -582,13 +775,14 @@ export function ComposeModal() {
|
||||
{mode !== 'minimized' && (
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/*
|
||||
Key basata su replyTo/forwardOf: garantisce che il form venga rimontato
|
||||
(e quindi resettato) ogni volta che si apre per un messaggio diverso.
|
||||
Key basata su replyTo/forwardOf/replyAll: garantisce che il form venga rimontato
|
||||
ogni volta che si apre per un messaggio diverso o cambia la modalita'.
|
||||
*/}
|
||||
<ComposeForm
|
||||
key={`${replyTo?.id ?? 'new'}-${forwardOf?.id ?? 'new'}`}
|
||||
key={`${replyTo?.id ?? 'new'}-${forwardOf?.id ?? 'new'}-${replyAll ? 'all' : 'single'}`}
|
||||
replyTo={replyTo}
|
||||
forwardOf={forwardOf}
|
||||
replyAll={replyAll}
|
||||
onClose={closeCompose}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -58,10 +58,12 @@ import {
|
||||
Settings2,
|
||||
BookUser,
|
||||
Calendar,
|
||||
PenLine,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useInboxStore } from '@/store/inbox.store'
|
||||
import { useComposeStore } from '@/store/compose.store'
|
||||
import { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -83,6 +85,7 @@ export function Sidebar() {
|
||||
|
||||
const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth()
|
||||
const unreadCount = useInboxStore((s) => s.unreadCount)
|
||||
const openCompose = useComposeStore((s) => s.openCompose)
|
||||
|
||||
// Le caselle PEC vengono caricate qui e condivise via React Query cache
|
||||
const { data: mailboxesData } = useQuery({
|
||||
@@ -482,22 +485,19 @@ export function Sidebar() {
|
||||
<div>
|
||||
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||
<div className="px-2">
|
||||
<NavLink
|
||||
to="/compose"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompose()}
|
||||
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" />
|
||||
{!collapsed && <span>Nuova PEC</span>}
|
||||
</NavLink>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -556,6 +556,7 @@ export function Sidebar() {
|
||||
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
|
||||
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
|
||||
{ to: '/templates', label: 'Template messaggi', icon: FileText },
|
||||
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine },
|
||||
{ to: '/notifications', label: 'Notifiche', icon: Bell },
|
||||
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
|
||||
] as const).map((item) => (
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
/**
|
||||
* Pagina Audit Log – visualizzazione eventi di sistema.
|
||||
* Pagina Audit Log – visualizzazione ed esportazione eventi di sistema.
|
||||
*
|
||||
* Accessibile solo ad admin e super_admin.
|
||||
* Mostra una tabella paginata con filtri per data, azione ed esito.
|
||||
* Permette di esportare i risultati in CSV o PDF.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import { it } from 'date-fns/locale'
|
||||
import { ShieldCheck, AlertCircle, Search, RotateCcw } from 'lucide-react'
|
||||
import { ShieldCheck, AlertCircle, Search, RotateCcw, Download, FileText } from 'lucide-react'
|
||||
import { auditLogApi } from '@/api/audit_log.api'
|
||||
import type { AuditLogParams } from '@/api/audit_log.api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
// ─── Lista completa degli eventi monitorati ───────────────────────────────────
|
||||
|
||||
const ACTION_OPTIONS: { value: string; label: string; group: string }[] = [
|
||||
// Auth
|
||||
{ value: 'auth.login', label: 'Login', group: 'Autenticazione' },
|
||||
{ value: 'auth.password_changed', label: 'Cambio password', group: 'Autenticazione' },
|
||||
// Utenti
|
||||
{ value: 'user.created', label: 'Utente creato', group: 'Utenti' },
|
||||
{ value: 'user.updated', label: 'Utente modificato', group: 'Utenti' },
|
||||
{ value: 'user.deleted', label: 'Utente eliminato', group: 'Utenti' },
|
||||
// Caselle
|
||||
{ value: 'mailbox.created', label: 'Casella creata', group: 'Caselle PEC' },
|
||||
{ value: 'mailbox.updated', label: 'Casella modificata', group: 'Caselle PEC' },
|
||||
{ value: 'mailbox.deleted', label: 'Casella eliminata', group: 'Caselle PEC' },
|
||||
// Messaggi - invio
|
||||
{ value: 'message.sent', label: 'PEC inviata', group: 'Messaggi' },
|
||||
// Messaggi - stato
|
||||
{ value: 'message.read', label: 'Messaggio letto', group: 'Messaggi' },
|
||||
{ value: 'message.unread', label: 'Segnato non letto', group: 'Messaggi' },
|
||||
{ value: 'message.opened', label: 'Messaggio aperto', group: 'Messaggi' },
|
||||
{ value: 'message.starred', label: 'Aggiunto ai preferiti', group: 'Messaggi' },
|
||||
{ value: 'message.unstarred', label: 'Rimosso dai preferiti', group: 'Messaggi' },
|
||||
{ value: 'message.archived', label: 'Archiviato', group: 'Messaggi' },
|
||||
{ value: 'message.unarchived', label: 'Ripristinato da archivio', group: 'Messaggi' },
|
||||
{ value: 'message.trashed', label: 'Spostato nel cestino', group: 'Messaggi' },
|
||||
{ value: 'message.restored', label: 'Ripristinato dal cestino', group: 'Messaggi' },
|
||||
{ value: 'message.bulk_updated', label: 'Aggiornamento massivo', group: 'Messaggi' },
|
||||
// Messaggi - allegati
|
||||
{ value: 'message.attachment_downloaded', label: 'Allegato scaricato', group: 'Messaggi' },
|
||||
{ value: 'message.package_downloaded', label: 'Pacchetto PEC scaricato', group: 'Messaggi' },
|
||||
// Conservazione
|
||||
{ value: 'message.pending_conservation', label: 'Messa in conservazione', group: 'Conservazione' },
|
||||
{ value: 'message.conserved', label: 'Conservata', group: 'Conservazione' },
|
||||
{ value: 'message.conservation_cancelled', label: 'Conservazione annullata', group: 'Conservazione' },
|
||||
{ value: 'message.conservation_removed', label: 'Rimossa dalla conservazione', group: 'Conservazione' },
|
||||
]
|
||||
|
||||
// Raggruppamento per group
|
||||
const ACTION_GROUPS = Array.from(new Set(ACTION_OPTIONS.map((o) => o.group)))
|
||||
|
||||
// ─── Badge esito ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,18 +82,20 @@ function OutcomeBadge({ outcome }: { outcome: string }) {
|
||||
// ─── Etichetta azione leggibile ───────────────────────────────────────────────
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'auth.login': 'Login',
|
||||
'auth.password_changed': 'Cambio password',
|
||||
'user.created': 'Utente creato',
|
||||
'user.updated': 'Utente modificato',
|
||||
'user.deleted': 'Utente eliminato',
|
||||
'mailbox.created': 'Casella creata',
|
||||
'mailbox.updated': 'Casella modificata',
|
||||
'mailbox.deleted': 'Casella eliminata',
|
||||
'message.sent': 'PEC inviata',
|
||||
}
|
||||
return map[action] ?? action
|
||||
return ACTION_OPTIONS.find((o) => o.value === action)?.label ?? action
|
||||
}
|
||||
|
||||
// ─── Helper download ─────────────────────────────────────────────────────────
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ─── Componente principale ────────────────────────────────────────────────────
|
||||
@@ -69,6 +113,10 @@ export function AuditLogPage() {
|
||||
// Parametri query attivi (applicati al click su "Cerca")
|
||||
const [activeParams, setActiveParams] = useState<AuditLogParams>({})
|
||||
|
||||
// Stato export
|
||||
const [exportingCsv, setExportingCsv] = useState(false)
|
||||
const [exportingPdf, setExportingPdf] = useState(false)
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['audit-log', page, activeParams],
|
||||
queryFn: () =>
|
||||
@@ -99,6 +147,23 @@ export function AuditLogPage() {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleExport = async (format: 'csv' | 'pdf') => {
|
||||
const setter = format === 'csv' ? setExportingCsv : setExportingPdf
|
||||
setter(true)
|
||||
try {
|
||||
const blob = await auditLogApi.export(format, activeParams)
|
||||
const ext = format === 'csv' ? 'csv' : 'pdf'
|
||||
const suffix = activeParams.date_from
|
||||
? `_dal_${activeParams.date_from.slice(0, 10).replace(/-/g, '')}`
|
||||
: ''
|
||||
triggerDownload(blob, `audit_log${suffix}.${ext}`)
|
||||
} catch {
|
||||
toast.error(`Errore durante l'esportazione ${format.toUpperCase()}`)
|
||||
} finally {
|
||||
setter(false)
|
||||
}
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const pages = data?.pages ?? 1
|
||||
@@ -125,15 +190,15 @@ export function AuditLogPage() {
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Tutte le azioni</option>
|
||||
<option value="auth.login">Login</option>
|
||||
<option value="auth.password_changed">Cambio password</option>
|
||||
<option value="user.created">Utente creato</option>
|
||||
<option value="user.updated">Utente modificato</option>
|
||||
<option value="user.deleted">Utente eliminato</option>
|
||||
<option value="mailbox.created">Casella creata</option>
|
||||
<option value="mailbox.updated">Casella modificata</option>
|
||||
<option value="mailbox.deleted">Casella eliminata</option>
|
||||
<option value="message.sent">PEC inviata</option>
|
||||
{ACTION_GROUPS.map((group) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{ACTION_OPTIONS.filter((o) => o.group === group).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +240,7 @@ export function AuditLogPage() {
|
||||
</div>
|
||||
|
||||
{/* Bottoni */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
<button
|
||||
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"
|
||||
@@ -190,6 +255,29 @@ export function AuditLogPage() {
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reimposta
|
||||
</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>
|
||||
|
||||
@@ -293,10 +381,21 @@ export function AuditLogPage() {
|
||||
{entry.ip_address ?? <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
|
||||
{/* Utente (UUID abbreviato) */}
|
||||
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
|
||||
{entry.user_id ? (
|
||||
<span title={entry.user_id}>
|
||||
{/* Utente: mostra nome, poi email, poi UUID abbreviato */}
|
||||
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
|
||||
{entry.user_full_name ? (
|
||||
<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]}...
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useRef, useMemo } from 'react'
|
||||
import { useState, useRef, useMemo, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useForm, useFieldArray } from 'react-hook-form'
|
||||
import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter } from 'lucide-react'
|
||||
import { useForm, useFieldArray, useWatch } from 'react-hook-form'
|
||||
import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter, FileText, ChevronDown } from 'lucide-react'
|
||||
import { ContactPickerPopover } from '@/components/ComposeModal/ContactPickerPopover'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -11,9 +12,29 @@ import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
|
||||
import { sendApi } from '@/api/send.api'
|
||||
import { mailboxesApi } from '@/api/mailboxes.api'
|
||||
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
|
||||
import { signaturesApi } from '@/api/signatures.api'
|
||||
import { templatesApi } from '@/api/templates.api'
|
||||
import type { TemplateResponse } from '@/api/templates.api'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import type { MessageResponse } from '@/types/api.types'
|
||||
|
||||
// ─── Utilita' firma ───────────────────────────────────────────────────────────
|
||||
|
||||
const SIG_ATTR = 'data-pechub-sig'
|
||||
|
||||
function injectSignature(body: string, sigHtml: string | null): string {
|
||||
const withoutSig = body.replace(
|
||||
new RegExp(`<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) */
|
||||
interface MailboxSelectItem {
|
||||
id: string
|
||||
@@ -99,6 +120,7 @@ export function ComposePage() {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<ComposeFormValues>({
|
||||
defaultValues: {
|
||||
@@ -127,6 +149,56 @@ export function ComposePage() {
|
||||
remove: removeCc,
|
||||
} = useFieldArray({ control, name: 'cc_addresses' })
|
||||
|
||||
// ── Auto-inserimento firma ────────────────────────────────────────────────
|
||||
const watchedMailboxId = useWatch({ control, name: 'mailbox_id' })
|
||||
const signatureContext = replyTo ? 'reply' : 'compose'
|
||||
// Ref per tenere traccia dell'HTML firma corrente (usato da applyTemplate)
|
||||
const sigHtmlRef = useRef<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)
|
||||
const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
|
||||
queryKey: ['mailboxes'],
|
||||
@@ -350,6 +422,10 @@ export function ComposePage() {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<ContactPickerPopover
|
||||
size="md"
|
||||
onSelect={(email) => setValue(`to_addresses.${idx}.value`, email)}
|
||||
/>
|
||||
{toFields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -408,6 +484,10 @@ export function ComposePage() {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<ContactPickerPopover
|
||||
size="md"
|
||||
onSelect={(email) => setValue(`cc_addresses.${idx}.value`, email)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -443,7 +523,58 @@ export function ComposePage() {
|
||||
|
||||
{/* Corpo – Rich Text Editor */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
value={bodyHtml}
|
||||
onChange={setBodyHtml}
|
||||
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
ChevronUp,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
FileText,
|
||||
Paperclip,
|
||||
AlignLeft,
|
||||
} from 'lucide-react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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
|
||||
: undefined
|
||||
}
|
||||
searchTerm={debouncedSearch || undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -975,6 +979,7 @@ interface MessageRowProps {
|
||||
onToggleTrash: (e: React.MouseEvent) => void
|
||||
onToggleConserve: (e: React.MouseEvent) => void
|
||||
mailboxName?: string
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
function MessageRow({
|
||||
@@ -991,6 +996,7 @@ function MessageRow({
|
||||
onToggleTrash,
|
||||
onToggleConserve,
|
||||
mailboxName,
|
||||
searchTerm,
|
||||
}: MessageRowProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const isUnread = !message.is_read && message.direction === 'inbound'
|
||||
@@ -998,7 +1004,8 @@ function MessageRow({
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
isSelected && 'bg-blue-100/60 dark:bg-blue-900/30',
|
||||
)}
|
||||
@@ -1091,6 +1098,35 @@ function MessageRow({
|
||||
{truncate(message.body_text, 120)}
|
||||
</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>
|
||||
|
||||
{/* ── 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ArchiveX,
|
||||
Download,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Paperclip,
|
||||
Mail,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Eye,
|
||||
MessageSquare,
|
||||
X,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -168,6 +170,21 @@ export function MessageDetailPage() {
|
||||
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
|
||||
const [isPrinting, setIsPrinting] = useState(false)
|
||||
|
||||
// Dropdown "Rispondi / Rispondi a tutti"
|
||||
const [showReplyDropdown, setShowReplyDropdown] = useState(false)
|
||||
const replyDropdownRef = useRef<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
|
||||
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
|
||||
const [deadlineDate, setDeadlineDate] = useState('')
|
||||
@@ -533,8 +550,56 @@ export function MessageDetailPage() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */}
|
||||
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
|
||||
{/* Rispondi / Rispondi a tutti (solo per messaggi inbound PEC certificata, non nel cestino) */}
|
||||
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (() => {
|
||||
const hasMultipleRecipients =
|
||||
(message.to_addresses?.length ?? 0) > 1 ||
|
||||
(message.cc_addresses?.length ?? 0) > 0
|
||||
return hasMultipleRecipients ? (
|
||||
/* Split button con dropdown */
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -543,7 +608,8 @@ export function MessageDetailPage() {
|
||||
<Reply className="h-4 w-4 mr-1" />
|
||||
Rispondi
|
||||
</Button>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */}
|
||||
{message.pec_type === 'posta_certificata' && !message.is_trashed && (
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
Mail,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Paperclip,
|
||||
AlignLeft,
|
||||
} from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -539,6 +542,35 @@ function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchRe
|
||||
</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 */}
|
||||
{message.labels && message.labels.length > 0 && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,9 @@ interface ComposeState {
|
||||
mode: ComposeMode
|
||||
replyTo: MessageResponse | null
|
||||
forwardOf: MessageResponse | null
|
||||
replyAll: boolean
|
||||
|
||||
openCompose: (opts?: { replyTo?: MessageResponse; forwardOf?: MessageResponse }) => void
|
||||
openCompose: (opts?: { replyTo?: MessageResponse; forwardOf?: MessageResponse; replyAll?: boolean }) => void
|
||||
closeCompose: () => void
|
||||
setMode: (mode: ComposeMode) => void
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
|
||||
mode: 'normal',
|
||||
replyTo: null,
|
||||
forwardOf: null,
|
||||
replyAll: false,
|
||||
|
||||
openCompose: (opts) =>
|
||||
set({
|
||||
@@ -30,6 +32,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
|
||||
mode: 'normal',
|
||||
replyTo: opts?.replyTo ?? null,
|
||||
forwardOf: opts?.forwardOf ?? null,
|
||||
replyAll: opts?.replyAll ?? false,
|
||||
}),
|
||||
|
||||
closeCompose: () =>
|
||||
@@ -38,6 +41,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
|
||||
mode: 'normal',
|
||||
replyTo: null,
|
||||
forwardOf: null,
|
||||
replyAll: false,
|
||||
}),
|
||||
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
@@ -211,6 +211,19 @@ export interface MessageBulkLabelResponse {
|
||||
updated: number
|
||||
}
|
||||
|
||||
// ─── Search match info ────────────────────────────────────────────────────────
|
||||
|
||||
export interface AttachmentMatchInfo {
|
||||
id: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface SearchMatchInfo {
|
||||
in_subject: boolean
|
||||
in_body: boolean
|
||||
in_attachments: AttachmentMatchInfo[]
|
||||
}
|
||||
|
||||
// ─── Message ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type PecDirection = 'inbound' | 'outbound'
|
||||
@@ -270,6 +283,8 @@ export interface MessageResponse {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
labels: LabelResponse[]
|
||||
// Popolato solo nelle risposte di ricerca full-text
|
||||
search_match?: SearchMatchInfo | null
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
|
||||
@@ -37,6 +37,15 @@ http {
|
||||
# Hide nginx version
|
||||
server_tokens off;
|
||||
|
||||
# IP reale del client: legge X-Forwarded-For impostato da Nginx Proxy Manager
|
||||
# Fidati dell'IP interno di NPM e delle subnet Docker
|
||||
set_real_ip_from 10.0.30.254;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 127.0.0.1;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
|
||||
|
||||
+99
-7
@@ -528,6 +528,8 @@ async def _save_message(
|
||||
- Salvataggio allegati su MinIO + tabella attachments
|
||||
- State machine outbound: solo per messaggi inbound (ricevute PEC)
|
||||
- Collegamento parent_message_id via X-Riferimento-Message-ID
|
||||
- Dedup outbound: evita duplicati quando un messaggio inviato via send_pec
|
||||
viene poi trovato anche nella cartella Sent del server IMAP
|
||||
"""
|
||||
# ── Idempotenza: chiave composta (mailbox_id + imap_uid + imap_folder) ────
|
||||
existing = await db.execute(
|
||||
@@ -552,17 +554,73 @@ async def _save_message(
|
||||
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
|
||||
received_at = datetime.now(UTC)
|
||||
|
||||
# ── Dedup outbound: upsert sul record send_pec invece di creare duplicato ─
|
||||
# Problema: send_pec crea un record outbound con imap_uid=NULL e poi
|
||||
# la sync della cartella Sent trova lo stesso messaggio e vorrebbe creare
|
||||
# un secondo record con lo stesso message_id_header. I duplicati rompono
|
||||
# il binding delle ricevute (_apply_outbound_state_machine usava
|
||||
# scalar_one_or_none() che esplode con MultipleResultsFound).
|
||||
# Soluzione: se esiste già un record outbound con lo stesso message_id_header
|
||||
# e imap_uid=NULL (il record canonico di send_pec), aggiorniamo quel record
|
||||
# con l'imap_uid/imap_folder della Sent folder invece di crearne uno nuovo.
|
||||
if direction == "outbound" and parsed.message_id:
|
||||
existing_outbound = await db.execute(
|
||||
select(Message).where(
|
||||
Message.mailbox_id == mailbox.id,
|
||||
Message.message_id_header == parsed.message_id,
|
||||
Message.direction == "outbound",
|
||||
Message.imap_uid.is_(None),
|
||||
)
|
||||
)
|
||||
send_pec_record = existing_outbound.scalar_one_or_none()
|
||||
if send_pec_record:
|
||||
# Aggiorna il record esistente con i dati IMAP della cartella Sent
|
||||
send_pec_record.imap_uid = uid
|
||||
send_pec_record.imap_folder = imap_folder
|
||||
send_pec_record.updated_at = datetime.now(UTC)
|
||||
# Aggiorna anche il raw_eml_path se non è già impostato
|
||||
if not send_pec_record.raw_eml_path:
|
||||
try:
|
||||
eml_path = await upload_eml(
|
||||
tenant_id=str(mailbox.tenant_id),
|
||||
mailbox_id=str(mailbox.id),
|
||||
uid=uid,
|
||||
eml_bytes=raw_eml,
|
||||
)
|
||||
send_pec_record.raw_eml_path = eml_path
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[{mailbox.email_address}] Upload EML MinIO per record send_pec "
|
||||
f"UID {uid}: {e}"
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"[{mailbox.email_address}] Sent-sync: aggiornato record send_pec "
|
||||
f"message_id={parsed.message_id!r} con imap_uid={uid} "
|
||||
f"folder={imap_folder!r} (evitato duplicato outbound)"
|
||||
)
|
||||
return True
|
||||
|
||||
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
|
||||
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
|
||||
parent_message_id: uuid.UUID | None = None
|
||||
|
||||
if direction == "inbound" and pec_class.is_receipt and pec_class.riferimento_message_id:
|
||||
try:
|
||||
parent_message_id = await _apply_outbound_state_machine(
|
||||
riferimento_message_id=pec_class.riferimento_message_id,
|
||||
pec_type=pec_class.pec_type,
|
||||
tenant_id=mailbox.tenant_id,
|
||||
db=db,
|
||||
)
|
||||
except Exception as bind_err:
|
||||
logger.error(
|
||||
f"[{mailbox.email_address}] [receipt-binding] Errore aggiornamento stato "
|
||||
f"outbound per ricevuta UID={uid} tipo={pec_class.pec_type!r}: {bind_err}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Non interrompere il salvataggio della ricevuta: il record viene
|
||||
# comunque inserito, ma senza parent_message_id.
|
||||
|
||||
# ── Upload raw EML su MinIO ───────────────────────────────────────────────
|
||||
eml_path: str | None = None
|
||||
@@ -700,6 +758,12 @@ async def _apply_outbound_state_machine(
|
||||
Cerca il messaggio outbound con message_id_header == riferimento_message_id,
|
||||
applica la transizione di stato se valida.
|
||||
|
||||
Gestisce il caso di messaggi outbound duplicati (uno creato da send_pec con
|
||||
imap_uid=NULL e uno creato dalla sync della cartella Sent): in caso di multipli,
|
||||
prioritizza quello con imap_uid=NULL (il record canonico creato da send_pec).
|
||||
Il dedup in _save_message riduce drasticamente la probabilità di multipli,
|
||||
ma questa funzione gestisce anche i casi residui per robustezza.
|
||||
|
||||
Returns:
|
||||
UUID del messaggio originale se trovato, None altrimenti.
|
||||
"""
|
||||
@@ -710,15 +774,37 @@ async def _apply_outbound_state_machine(
|
||||
Message.direction == "outbound",
|
||||
)
|
||||
)
|
||||
parent_msg = result.scalar_one_or_none()
|
||||
candidates = result.scalars().all()
|
||||
|
||||
if not parent_msg:
|
||||
logger.debug(
|
||||
f"Messaggio outbound non trovato per riferimento={riferimento_message_id!r} "
|
||||
f"(potrebbe essere stato inviato da client diverso)"
|
||||
if not candidates:
|
||||
logger.warning(
|
||||
f"[receipt-binding] Messaggio outbound non trovato per "
|
||||
f"riferimento={riferimento_message_id!r} (ricevuta: {pec_type!r}). "
|
||||
f"Potrebbe essere stato inviato da un client esterno o il message_id_header "
|
||||
f"non e' ancora stato persistito."
|
||||
)
|
||||
return None
|
||||
|
||||
# In presenza di duplicati (es. record send_pec + record Sent-sync),
|
||||
# prioritizza il messaggio con imap_uid=NULL (quello canonico di send_pec).
|
||||
parent_msg: Message | None = None
|
||||
if len(candidates) == 1:
|
||||
parent_msg = candidates[0]
|
||||
else:
|
||||
logger.warning(
|
||||
f"[receipt-binding] Trovati {len(candidates)} messaggi outbound con "
|
||||
f"message_id_header={riferimento_message_id!r}. "
|
||||
f"Prioritizzo il record con imap_uid=NULL (send_pec)."
|
||||
)
|
||||
# Priorità 1: imap_uid IS NULL (creato da send_pec)
|
||||
for m in candidates:
|
||||
if m.imap_uid is None:
|
||||
parent_msg = m
|
||||
break
|
||||
# Priorità 2: qualsiasi altro (creato dalla sync Sent)
|
||||
if parent_msg is None:
|
||||
parent_msg = candidates[0]
|
||||
|
||||
new_state = apply_outbound_transition(parent_msg.state, pec_type)
|
||||
if new_state:
|
||||
old_state = parent_msg.state
|
||||
@@ -726,8 +812,14 @@ async def _apply_outbound_state_machine(
|
||||
parent_msg.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"State machine outbound: {riferimento_message_id!r} "
|
||||
f"{old_state!r} → {new_state!r} (ricevuta: {pec_type!r})"
|
||||
f"[receipt-binding] State machine outbound: {riferimento_message_id!r} "
|
||||
f"{old_state!r} -> {new_state!r} (ricevuta: {pec_type!r}, "
|
||||
f"msg_id={parent_msg.id})"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[receipt-binding] Nessuna transizione valida per {riferimento_message_id!r} "
|
||||
f"state={parent_msg.state!r} ricevuta={pec_type!r}"
|
||||
)
|
||||
|
||||
return parent_msg.id
|
||||
|
||||
@@ -7,14 +7,17 @@ Questo job viene usato per:
|
||||
- Retry dopo un errore (called dal pool monitor)
|
||||
|
||||
Non sostituisce il loop IMAP continuo (IMAPConnection); è un one-shot job.
|
||||
|
||||
Sincronizza sia INBOX (per rilevare ricevute PEC e messaggi in arrivo)
|
||||
sia la cartella Sent (per aggiornare imap_uid sul record send_pec ed
|
||||
evitare duplicati outbound che rompono il binding delle ricevute).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.imap.reconnect import ExponentialBackoff
|
||||
from app.imap.sync import sync_new_messages
|
||||
from app.imap.sync import sync_new_messages, sync_sent_messages
|
||||
from app.models import Mailbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,7 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict:
|
||||
"""
|
||||
Job arq: sincronizza una singola casella PEC.
|
||||
Job arq: sincronizza una singola casella PEC (INBOX + Sent).
|
||||
|
||||
Args:
|
||||
ctx: contesto arq (contiene redis, pool reference)
|
||||
@@ -50,28 +53,44 @@ async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict:
|
||||
creds = IMAPConnection._decrypt_creds(mailbox)
|
||||
|
||||
try:
|
||||
from app.imap.connection import IMAPConnection
|
||||
|
||||
conn = IMAPConnection(mailbox_id=mailbox_id, redis_client=redis_client)
|
||||
client = await conn._connect(creds)
|
||||
|
||||
n = await sync_new_messages(client, mailbox, db, redis_client)
|
||||
# Sync INBOX: ricevute PEC e messaggi in arrivo
|
||||
n_inbox = await sync_new_messages(client, mailbox, db, redis_client)
|
||||
|
||||
# Sync Sent: aggiorna imap_uid sui record send_pec e previene duplicati.
|
||||
# Fondamentale per il corretto binding delle ricevute PEC successive.
|
||||
n_sent = 0
|
||||
try:
|
||||
n_sent = await sync_sent_messages(client, mailbox, db, redis_client)
|
||||
except Exception as sent_err:
|
||||
logger.warning(
|
||||
f"[sync_mailbox] {mailbox.email_address} errore sync Sent "
|
||||
f"(non critico): {sent_err}"
|
||||
)
|
||||
|
||||
try:
|
||||
await client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"[sync_mailbox] {mailbox.email_address}: "
|
||||
f"INBOX={n_inbox} nuovi, Sent={n_sent} nuovi"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"mailbox": mailbox.email_address,
|
||||
"new_messages": n,
|
||||
"new_messages_inbox": n_inbox,
|
||||
"new_messages_sent": n_sent,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[sync_mailbox] {mailbox_id} errore: {e}", exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"mailbox": mailbox.email_address,
|
||||
"mailbox": mailbox.email_address if mailbox else mailbox_id,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user