Implementazioni varie

This commit is contained in:
2026-03-27 20:59:06 +01:00
parent 047990811f
commit 46784aca4c
40 changed files with 4090 additions and 34 deletions
+1 -11
View File
@@ -40,18 +40,8 @@ Gestione caselle, utenti, permessi, Virtual Box, notifiche, impostazioni
Pagina Multi-tenant (Super Admin)
Tag/etichette con colori su messaggi
Virtual Box con regole e assegnazioni utenti
COSA MANCA PRIORITA' ALTA
1. Dispatch automatico notifiche (Sistema di notifiche incompleto al 60%)
Il CRUD canali/regole/log e' implementato, ma manca tutto il lato dispatch
Non esiste worker/app/jobs/dispatch_notification.py
NotificationService non ha il metodo evaluate_rules(event_type, message) che valuta le regole e accoda i job
L'IMAP sync (sync.py) non chiama nulla al salvataggio di un nuovo messaggio
Il test canale webhook e email e' uno stub che restituisce sempre successo (solo Telegram ha invio reale)
La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i segreti (bot_token, webhook_secret, smtp_password) sono leggibili in chiaro nel DB
Canale WhatsApp: nessuna implementazione reale (stub completo)
Canale Email SMTP: nessuna implementazione reale (stub completo)
Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente
COSA MANCA PRIORITA' ALTA
3. Archiviazione Sostitutiva (Fase 6 ~15% implementata)
@@ -0,0 +1,55 @@
"""
Migrazione 0010: tabella message_templates (Feature 1 Template messaggi).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"message_templates",
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("subject", sa.Text, nullable=False, server_default=""),
sa.Column("body_text", sa.Text, nullable=True),
sa.Column("body_html", 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_template_name_tenant"),
)
op.create_index("idx_templates_tenant", "message_templates", ["tenant_id"])
def downgrade() -> None:
op.drop_index("idx_templates_tenant", table_name="message_templates")
op.drop_table("message_templates")
@@ -0,0 +1,110 @@
"""
Migrazione 0011: tabelle routing_rules, routing_rule_conditions, routing_rule_actions
(Feature 2 Regole di smistamento automatico).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Tabella principale regole
op.create_table(
"routing_rules",
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("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("priority", sa.Integer, nullable=False, server_default="100"),
sa.Column("stop_processing", sa.Boolean, nullable=False, server_default="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(),
),
)
op.create_index("idx_routing_rules_tenant", "routing_rules", ["tenant_id"])
op.create_index(
"idx_routing_rules_active",
"routing_rules",
["tenant_id", "priority"],
postgresql_where=sa.text("is_active = true"),
)
# Condizioni delle regole
op.create_table(
"routing_rule_conditions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"rule_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
nullable=False,
),
# field: from_address | to_address | subject | mailbox_id | pec_type
sa.Column("field", sa.String(50), nullable=False),
# operator: contains | equals | starts_with | ends_with | regex | not_contains
sa.Column("operator", sa.String(30), nullable=False, server_default="contains"),
sa.Column("value", sa.Text, nullable=False),
)
op.create_index(
"idx_routing_conditions_rule",
"routing_rule_conditions",
["rule_id"],
)
# Azioni delle regole
op.create_table(
"routing_rule_actions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"rule_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
nullable=False,
),
# action_type: apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
sa.Column("action_type", sa.String(50), nullable=False),
# action_value: UUID di label/vbox, URL del webhook, ecc.
sa.Column("action_value", sa.Text, nullable=True),
)
op.create_index(
"idx_routing_actions_rule",
"routing_rule_actions",
["rule_id"],
)
def downgrade() -> None:
op.drop_index("idx_routing_actions_rule", table_name="routing_rule_actions")
op.drop_table("routing_rule_actions")
op.drop_index("idx_routing_conditions_rule", table_name="routing_rule_conditions")
op.drop_table("routing_rule_conditions")
op.drop_index("idx_routing_rules_active", table_name="routing_rules")
op.drop_index("idx_routing_rules_tenant", table_name="routing_rules")
op.drop_table("routing_rules")
@@ -0,0 +1,35 @@
"""
Migrazione 0012: campi deadline su tabella messages
(Feature 4 Scadenzario e tracking deadlines).
"""
from alembic import op
import sqlalchemy as sa
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"messages",
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"messages",
sa.Column("deadline_note", sa.Text, nullable=True),
)
op.create_index(
"idx_messages_deadline",
"messages",
["tenant_id", "deadline_at"],
postgresql_where=sa.text("deadline_at IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("idx_messages_deadline", table_name="messages")
op.drop_column("messages", "deadline_note")
op.drop_column("messages", "deadline_at")
@@ -0,0 +1,30 @@
"""
Migrazione 0013: campo scheduled_at su send_jobs
(Feature 5 Invio differito).
"""
from alembic import op
import sqlalchemy as sa
revision = "0013"
down_revision = "0012"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"send_jobs",
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"idx_sendjobs_scheduled",
"send_jobs",
["scheduled_at"],
postgresql_where=sa.text("status = 'pending' AND scheduled_at IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("idx_sendjobs_scheduled", table_name="send_jobs")
op.drop_column("send_jobs", "scheduled_at")
@@ -0,0 +1,60 @@
"""
Migrazione 0014: tabella pec_contacts (Feature 6 Rubrica indirizzi PEC).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"pec_contacts",
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("email", sa.String(255), nullable=False),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("organization", sa.String(255), nullable=True),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("is_favorite", sa.Boolean, nullable=False, server_default="false"),
# auto_saved=True: contatto creato automaticamente dal sistema durante la sync
# auto_saved=False: contatto aggiunto manualmente dall'utente
sa.Column("auto_saved", sa.Boolean, nullable=False, server_default="false"),
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", "email", name="uq_pec_contact_email_tenant"),
)
op.create_index("idx_pec_contacts_tenant", "pec_contacts", ["tenant_id"])
op.create_index("idx_pec_contacts_email", "pec_contacts", ["tenant_id", "email"])
def downgrade() -> None:
op.drop_index("idx_pec_contacts_email", table_name="pec_contacts")
op.drop_index("idx_pec_contacts_tenant", table_name="pec_contacts")
op.drop_table("pec_contacts")
+132
View File
@@ -0,0 +1,132 @@
"""
Router rubrica indirizzi PEC (Feature 6).
Endpoint:
GET /contacts lista contatti (con ricerca)
POST /contacts crea contatto manuale
GET /contacts/autocomplete autocomplete per compose
GET /contacts/{id} dettaglio contatto
PUT /contacts/{id} aggiorna contatto
DELETE /contacts/{id} elimina contatto
POST /contacts/import importa da CSV
"""
import uuid
from fastapi import APIRouter, Query, UploadFile, File, status
from app.dependencies import CurrentUser, DB
from app.schemas.pec_contact import (
PecContactCreate,
PecContactImportResult,
PecContactListResponse,
PecContactResponse,
PecContactUpdate,
)
from app.services.pec_contact_service import PecContactService
router = APIRouter(tags=["Contacts"])
@router.get("/contacts", response_model=PecContactListResponse)
async def list_contacts(
current_user: CurrentUser,
db: DB,
q: str | None = Query(None, description="Ricerca per email, nome o organizzazione"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> PecContactListResponse:
"""Elenca i contatti della rubrica PEC del tenant."""
svc = PecContactService(db)
items, total = await svc.list_contacts(
current_user.tenant_id, q=q, page=page, page_size=page_size
)
return PecContactListResponse(
items=[PecContactResponse.model_validate(c) for c in items],
total=total,
)
@router.get("/contacts/autocomplete", response_model=list[PecContactResponse])
async def autocomplete_contacts(
q: str,
current_user: CurrentUser,
db: DB,
limit: int = Query(10, ge=1, le=20),
) -> list[PecContactResponse]:
"""Ricerca rapida contatti per autocomplete nel compose (minimo 2 caratteri)."""
svc = PecContactService(db)
items = await svc.search_for_autocomplete(current_user.tenant_id, q=q, limit=limit)
return [PecContactResponse.model_validate(c) for c in items]
@router.post("/contacts", response_model=PecContactResponse, status_code=status.HTTP_201_CREATED)
async def create_contact(
data: PecContactCreate,
current_user: CurrentUser,
db: DB,
) -> PecContactResponse:
"""Aggiunge un contatto manualmente alla rubrica."""
svc = PecContactService(db)
contact = await svc.create_contact(
current_user.tenant_id, data, created_by=current_user.id
)
return PecContactResponse.model_validate(contact)
@router.get("/contacts/{contact_id}", response_model=PecContactResponse)
async def get_contact(
contact_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> PecContactResponse:
"""Restituisce il dettaglio di un contatto."""
svc = PecContactService(db)
contact = await svc.get_contact(current_user.tenant_id, contact_id)
return PecContactResponse.model_validate(contact)
@router.put("/contacts/{contact_id}", response_model=PecContactResponse)
async def update_contact(
contact_id: uuid.UUID,
data: PecContactUpdate,
current_user: CurrentUser,
db: DB,
) -> PecContactResponse:
"""Aggiorna un contatto della rubrica."""
svc = PecContactService(db)
contact = await svc.update_contact(current_user.tenant_id, contact_id, data)
return PecContactResponse.model_validate(contact)
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact(
contact_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> None:
"""Elimina un contatto dalla rubrica."""
svc = PecContactService(db)
await svc.delete_contact(current_user.tenant_id, contact_id)
@router.post("/contacts/import", response_model=PecContactImportResult)
async def import_contacts_csv(
file: UploadFile = File(..., description="File CSV con colonne: email, name, organization"),
current_user: CurrentUser = ..., # type: ignore
db: DB = ..., # type: ignore
) -> PecContactImportResult:
"""
Importa contatti dalla rubrica da file CSV.
Il CSV deve avere le intestazioni: email, name, organization
(solo email e' obbligatoria).
"""
content = await file.read()
try:
csv_text = content.decode("utf-8")
except UnicodeDecodeError:
csv_text = content.decode("latin-1")
svc = PecContactService(db)
return await svc.import_csv(current_user.tenant_id, csv_text, created_by=current_user.id)
+152
View File
@@ -0,0 +1,152 @@
"""
Router scadenzario e tracking deadlines (Feature 4).
Endpoint:
GET /deadlines messaggi con scadenze imminenti
POST /messages/{id}/deadline imposta/modifica/rimuove scadenza
"""
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from pydantic import BaseModel
from sqlalchemy import and_, select
from app.dependencies import CurrentUser, DB
from app.models.message import Message
from app.schemas.message import MessageResponse
from app.core.exceptions import NotFoundError
router = APIRouter(tags=["Deadlines"])
class DeadlineSetRequest(BaseModel):
deadline_at: datetime | None = None
"""Imposta a null per rimuovere la scadenza."""
deadline_note: str | None = None
class DeadlineMessageResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
subject: str | None
from_address: str | None
to_addresses: list[str] | None = None
direction: str
pec_type: str
state: str
mailbox_id: uuid.UUID
deadline_at: datetime | None = None
deadline_note: str | None = None
is_overdue: bool = False
received_at: datetime | None = None
sent_at: datetime | None = None
created_at: datetime
@router.get("/deadlines", response_model=list[DeadlineMessageResponse])
async def list_deadlines(
current_user: CurrentUser,
db: DB,
days_ahead: int = Query(30, ge=1, le=365, description="Giorni da considerare in avanti"),
include_overdue: bool = Query(True, description="Includi scadenze gia' passate"),
) -> list[DeadlineMessageResponse]:
"""
Restituisce i messaggi con scadenze nel range specificato.
Ordinati per: scaduti prima, poi per deadline_at ASC.
"""
now = datetime.now(timezone.utc)
future_limit = now + timedelta(days=days_ahead)
conditions = [
Message.tenant_id == current_user.tenant_id,
Message.deadline_at.is_not(None),
Message.is_trashed == False, # noqa: E712
]
if include_overdue:
# Include scaduti e futuri fino al limite
conditions.append(Message.deadline_at <= future_limit)
else:
# Solo scadenze future
conditions.append(and_(Message.deadline_at > now, Message.deadline_at <= future_limit))
result = await db.execute(
select(Message)
.where(and_(*conditions))
.order_by(Message.deadline_at)
.limit(200)
)
messages = list(result.scalars().all())
items = []
for msg in messages:
is_overdue = msg.deadline_at < now if msg.deadline_at else False
items.append(DeadlineMessageResponse(
id=msg.id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
direction=msg.direction,
pec_type=msg.pec_type,
state=msg.state,
mailbox_id=msg.mailbox_id,
deadline_at=msg.deadline_at,
deadline_note=msg.deadline_note,
is_overdue=is_overdue,
received_at=msg.received_at,
sent_at=msg.sent_at,
created_at=msg.created_at,
))
return items
@router.post("/messages/{message_id}/deadline", response_model=DeadlineMessageResponse)
async def set_deadline(
message_id: uuid.UUID,
data: DeadlineSetRequest,
current_user: CurrentUser,
db: DB,
) -> DeadlineMessageResponse:
"""
Imposta, modifica o rimuove la scadenza di un messaggio.
Passa deadline_at=null per rimuovere la scadenza.
"""
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
)
msg = result.scalar_one_or_none()
if not msg:
raise NotFoundError(f"Messaggio {message_id} non trovato")
msg.deadline_at = data.deadline_at
msg.deadline_note = data.deadline_note
await db.commit()
await db.refresh(msg)
now = datetime.now(timezone.utc)
is_overdue = msg.deadline_at < now if msg.deadline_at else False
return DeadlineMessageResponse(
id=msg.id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
direction=msg.direction,
pec_type=msg.pec_type,
state=msg.state,
mailbox_id=msg.mailbox_id,
deadline_at=msg.deadline_at,
deadline_note=msg.deadline_note,
is_overdue=is_overdue,
received_at=msg.received_at,
sent_at=msg.sent_at,
created_at=msg.created_at,
)
+281
View File
@@ -709,3 +709,284 @@ async def list_receipts(
)
receipts = list(result.scalars().all())
return [MessageResponse.model_validate(r) for r in receipts]
# ─── Feature 3: Thread/conversazioni ─────────────────────────────────────────
@router.get("/{message_id}/thread", response_model=list[MessageResponse])
async def get_thread(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[MessageResponse]:
"""
Restituisce l'intera conversazione (thread) di cui fa parte il messaggio.
Risale alla radice della conversazione (risalendo i parent_message_id),
poi carica tutti i messaggi del thread ordinati cronologicamente.
Esclude le ricevute PEC (pec_type != posta_certificata).
"""
message = await _resolve_message(message_id, current_user, db)
# Risale alla radice del thread
root_id = message.id
visited: set[uuid.UUID] = {message.id}
current = message
while current.parent_message_id and current.parent_message_id not in visited:
visited.add(current.parent_message_id)
parent_result = await db.execute(
select(Message).where(
Message.id == current.parent_message_id,
Message.tenant_id == current_user.tenant_id,
)
)
parent = parent_result.scalar_one_or_none()
if not parent:
break
current = parent
root_id = current.id
# Carica ricorsivamente tutti i messaggi del thread dalla radice
# Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
thread_messages: list[Message] = []
async def _collect(msg_id: uuid.UUID) -> None:
result = await db.execute(
select(Message)
.where(
Message.id == msg_id,
Message.tenant_id == current_user.tenant_id,
Message.pec_type == "posta_certificata",
)
.options(selectinload(Message.labels))
)
msg = result.scalar_one_or_none()
if msg:
thread_messages.append(msg)
children_result = await db.execute(
select(Message)
.where(
Message.parent_message_id == msg_id,
Message.tenant_id == current_user.tenant_id,
Message.pec_type == "posta_certificata",
)
.options(selectinload(Message.labels))
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
children = list(children_result.scalars().all())
for child in children:
await _collect(child.id)
await _collect(root_id)
# Ordina cronologicamente
thread_messages.sort(
key=lambda m: m.received_at or m.sent_at or m.created_at
)
return [MessageResponse.model_validate(m) for m in thread_messages]
# ─── Feature 7: Preview allegati (presigned URL) ──────────────────────────────
@router.get("/{message_id}/attachments/{attachment_id}/preview-url")
async def get_attachment_preview_url(
message_id: uuid.UUID,
attachment_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> dict:
"""
Restituisce una presigned URL MinIO per la preview inline dell'allegato.
La URL e' valida per 5 minuti. Supporta PDF e immagini.
Per altri tipi di file reindirizza al download normale.
"""
await _resolve_message(message_id, current_user, db)
result = await db.execute(
select(Attachment).where(
Attachment.id == attachment_id,
Attachment.message_id == message_id,
)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato")
content_type = attachment.content_type or "application/octet-stream"
previewable = (
content_type.startswith("image/") or
content_type == "application/pdf"
)
if not previewable:
return {
"previewable": False,
"content_type": content_type,
"filename": attachment.filename,
}
try:
from datetime import timedelta as _timedelta
from miniopy_async import Minio
client = Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
presigned_url = await client.presigned_get_object(
settings.minio_bucket,
attachment.storage_path,
expires=_timedelta(minutes=5),
)
return {
"previewable": True,
"content_type": content_type,
"filename": attachment.filename,
"url": presigned_url,
}
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Errore generazione presigned URL allegato {attachment_id}: {e}")
return {
"previewable": False,
"content_type": content_type,
"filename": attachment.filename,
}
# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
@router.get("/{message_id}/print")
async def print_message(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> "HTMLResponse":
"""
Restituisce una rappresentazione HTML ottimizzata per la stampa del messaggio.
Include: intestazione, corpo, lista allegati, albero ricevute.
Pronto per window.print() o salvataggio come PDF tramite browser.
"""
from fastapi.responses import HTMLResponse
message = await _resolve_message(message_id, current_user, db)
att_result = await db.execute(
select(Attachment).where(Attachment.message_id == message.id).order_by(Attachment.created_at)
)
attachments = list(att_result.scalars().all())
receipts_html = ""
if message.direction == "outbound":
rec_result = await db.execute(
select(Message)
.where(Message.parent_message_id == message.id)
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
receipts = list(rec_result.scalars().all())
PEC_TYPE_LABELS = {
"accettazione": "Accettazione",
"avvenuta_consegna": "Avvenuta consegna",
"non_accettazione": "Non accettazione",
"mancata_consegna": "Mancata consegna",
"errore_consegna": "Errore consegna",
"presa_in_carico": "Presa in carico",
"preavviso_mancata_consegna": "Preavviso mancata consegna",
"rilevazione_virus": "Rilevazione virus",
}
receipt_rows = ""
for r in receipts:
label = PEC_TYPE_LABELS.get(r.pec_type, r.pec_type)
date_str = ""
if r.received_at:
date_str = r.received_at.strftime("%d/%m/%Y %H:%M:%S")
receipt_rows += f"<tr><td>{label}</td><td>{date_str}</td></tr>"
if receipt_rows:
receipts_html = f"""
<section>
<h3>Tracciamento invio</h3>
<table>
<thead><tr><th>Tipo ricevuta</th><th>Data</th></tr></thead>
<tbody>{receipt_rows}</tbody>
</table>
</section>"""
att_rows = ""
for att in attachments:
size_str = f"{att.size_bytes:,} byte" if att.size_bytes else ""
att_rows += f"<li>{att.filename} ({att.content_type or ''}) {size_str}</li>"
att_html = f"<section><h3>Allegati ({len(attachments)})</h3><ul>{att_rows}</ul></section>" if attachments else ""
from_label = "Da" if message.direction == "inbound" else "A"
from_val = message.from_address if message.direction == "inbound" else ", ".join(message.to_addresses or [])
date_val = ""
date_field = message.received_at or message.sent_at or message.created_at
if date_field:
date_val = date_field.strftime("%d/%m/%Y %H:%M:%S")
body_html = ""
if message.body_html:
body_html = f"<div class='body'>{message.body_html}</div>"
elif message.body_text:
body_html = f"<pre class='body'>{message.body_text}</pre>"
deadline_html = ""
if message.deadline_at:
dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M")
deadline_html = f"<p class='deadline'><strong>Scadenza:</strong> {dl_str}</p>"
if message.deadline_note:
deadline_html += f"<p><em>Nota scadenza: {message.deadline_note}</em></p>"
html = f"""<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>PEC - {message.subject or '(nessun oggetto)'}</title>
<style>
body {{ font-family: Arial, sans-serif; font-size: 12pt; margin: 2cm; color: #000; }}
h1 {{ font-size: 16pt; border-bottom: 2px solid #333; padding-bottom: 8px; }}
h3 {{ font-size: 13pt; margin-top: 20px; border-bottom: 1px solid #ccc; }}
.meta {{ background: #f5f5f5; border: 1px solid #ddd; padding: 12px; margin-bottom: 16px; }}
.meta p {{ margin: 4px 0; }}
.body {{ border: 1px solid #ddd; padding: 12px; white-space: pre-wrap; word-wrap: break-word; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #eee; }}
ul {{ padding-left: 20px; }}
.deadline {{ color: #c00; font-weight: bold; }}
@media print {{ body {{ margin: 1cm; }} }}
</style>
</head>
<body>
<h1>{message.subject or '(nessun oggetto)'}</h1>
<div class="meta">
<p><strong>{from_label}:</strong> {from_val}</p>
{'<p><strong>Da:</strong> ' + message.from_address + '</p>' if message.direction == "outbound" and message.from_address else ''}
{'<p><strong>A:</strong> ' + ', '.join(message.to_addresses or []) + '</p>' if message.direction == "inbound" and message.to_addresses else ''}
{'<p><strong>Cc:</strong> ' + ', '.join(message.cc_addresses or []) + '</p>' if message.cc_addresses else ''}
<p><strong>Data:</strong> {date_val}</p>
<p><strong>Stato:</strong> {message.state}</p>
<p><strong>Tipo:</strong> {message.pec_type}</p>
</div>
{deadline_html}
{body_html}
{att_html}
{receipts_html}
<p style="margin-top: 30px; font-size: 9pt; color: #888;">
Documento generato da PEChub il {date_val} ID messaggio: {message.id}
</p>
</body>
</html>"""
return HTMLResponse(content=html, media_type="text/html; charset=utf-8")
+106
View File
@@ -0,0 +1,106 @@
"""
Router regole di smistamento automatico (Feature 2).
Endpoint:
GET /routing-rules lista regole del tenant
POST /routing-rules crea regola (admin)
GET /routing-rules/{id} dettaglio regola
PUT /routing-rules/{id} aggiorna regola (admin)
DELETE /routing-rules/{id} elimina regola (admin)
POST /routing-rules/{id}/toggle abilita/disabilita regola (admin)
"""
import uuid
from fastapi import APIRouter, status
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.routing_rule import (
RoutingRuleCreate,
RoutingRuleListResponse,
RoutingRuleResponse,
RoutingRuleUpdate,
)
from app.services.routing_rule_service import RoutingRuleService
router = APIRouter(tags=["Routing Rules"])
@router.get("/routing-rules", response_model=RoutingRuleListResponse)
async def list_rules(
current_user: CurrentUser,
db: DB,
) -> RoutingRuleListResponse:
"""Elenca le regole di smistamento del tenant."""
svc = RoutingRuleService(db)
items, total = await svc.list_rules(current_user.tenant_id)
return RoutingRuleListResponse(
items=[RoutingRuleResponse.model_validate(r) for r in items],
total=total,
)
@router.post("/routing-rules", response_model=RoutingRuleResponse, status_code=status.HTTP_201_CREATED)
async def create_rule(
data: RoutingRuleCreate,
current_user: AdminUser,
db: DB,
) -> RoutingRuleResponse:
"""Crea una nuova regola di smistamento (solo admin)."""
svc = RoutingRuleService(db)
rule = await svc.create_rule(current_user.tenant_id, data, created_by=current_user.id)
return RoutingRuleResponse.model_validate(rule)
@router.get("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
async def get_rule(
rule_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> RoutingRuleResponse:
"""Restituisce il dettaglio di una regola."""
svc = RoutingRuleService(db)
rule = await svc.get_rule(current_user.tenant_id, rule_id)
return RoutingRuleResponse.model_validate(rule)
@router.put("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
async def update_rule(
rule_id: uuid.UUID,
data: RoutingRuleUpdate,
current_user: AdminUser,
db: DB,
) -> RoutingRuleResponse:
"""Aggiorna una regola di smistamento (solo admin)."""
svc = RoutingRuleService(db)
rule = await svc.update_rule(current_user.tenant_id, rule_id, data)
return RoutingRuleResponse.model_validate(rule)
@router.delete("/routing-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina una regola di smistamento (solo admin)."""
svc = RoutingRuleService(db)
await svc.delete_rule(current_user.tenant_id, rule_id)
@router.post("/routing-rules/{rule_id}/toggle", response_model=RoutingRuleResponse)
async def toggle_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> RoutingRuleResponse:
"""Abilita o disabilita una regola di smistamento (admin)."""
svc = RoutingRuleService(db)
rule = await svc.get_rule(current_user.tenant_id, rule_id)
from app.schemas.routing_rule import RoutingRuleUpdate
updated = await svc.update_rule(
current_user.tenant_id,
rule_id,
RoutingRuleUpdate(is_active=not rule.is_active),
)
return RoutingRuleResponse.model_validate(updated)
+83
View File
@@ -0,0 +1,83 @@
"""
Router template messaggi (Feature 1).
Endpoint:
GET /templates lista template del tenant
POST /templates crea template (admin)
GET /templates/{id} dettaglio template
PUT /templates/{id} aggiorna template (admin)
DELETE /templates/{id} elimina template (admin)
"""
import uuid
from fastapi import APIRouter, Query, status
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.template import TemplateCreate, TemplateListResponse, TemplateResponse, TemplateUpdate
from app.services.template_service import TemplateService
router = APIRouter(tags=["Templates"])
@router.get("/templates", response_model=TemplateListResponse)
async def list_templates(
current_user: CurrentUser,
db: DB,
q: str | None = Query(None, description="Filtro per nome"),
) -> TemplateListResponse:
"""Elenca i template del tenant corrente."""
svc = TemplateService(db)
items, total = await svc.list_templates(current_user.tenant_id, q=q)
return TemplateListResponse(
items=[TemplateResponse.model_validate(t) for t in items],
total=total,
)
@router.post("/templates", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
async def create_template(
data: TemplateCreate,
current_user: AdminUser,
db: DB,
) -> TemplateResponse:
"""Crea un nuovo template (solo admin)."""
svc = TemplateService(db)
template = await svc.create_template(current_user.tenant_id, data, created_by=current_user.id)
return TemplateResponse.model_validate(template)
@router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template(
template_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> TemplateResponse:
"""Restituisce il dettaglio di un template."""
svc = TemplateService(db)
template = await svc.get_template(current_user.tenant_id, template_id)
return TemplateResponse.model_validate(template)
@router.put("/templates/{template_id}", response_model=TemplateResponse)
async def update_template(
template_id: uuid.UUID,
data: TemplateUpdate,
current_user: AdminUser,
db: DB,
) -> TemplateResponse:
"""Aggiorna un template esistente (solo admin)."""
svc = TemplateService(db)
template = await svc.update_template(current_user.tenant_id, template_id, data)
return TemplateResponse.model_validate(template)
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina un template (solo admin)."""
svc = TemplateService(db)
await svc.delete_template(current_user.tenant_id, template_id)
+5 -1
View File
@@ -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, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
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 settings as settings_router
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
@@ -98,6 +98,10 @@ app.include_router(labels.router, prefix=API_PREFIX)
app.include_router(settings_router.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(audit_log.router, prefix=API_PREFIX)
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)
# ─── Health check ─────────────────────────────────────────────────────────────
+3
View File
@@ -10,3 +10,6 @@ from app.models.permission import MailboxPermission # noqa: F401
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
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
+6
View File
@@ -95,6 +95,10 @@ class Message(Base):
is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Scadenzario (Feature 4)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deadline_note: Mapped[str | None] = mapped_column(Text, nullable=True)
# Conservazione
is_pending_conservation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
pending_conservation_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -194,6 +198,8 @@ class SendJob(Base):
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Invio differito (Feature 5): se impostato, il job non viene processato prima di questa data
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+49
View File
@@ -0,0 +1,49 @@
"""
Modello PecContact rubrica indirizzi PEC del tenant.
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, 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 PecContact(Base):
__tablename__ = "pec_contacts"
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
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
organization: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# auto_saved=True → salvato automaticamente dalla sincronizzazione IMAP
# auto_saved=False → aggiunto manualmente dall'utente
auto_saved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
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", "email", name="uq_pec_contact_email_tenant"),
Index("idx_pec_contacts_tenant", "tenant_id"),
Index("idx_pec_contacts_email", "tenant_id", "email"),
)
def __repr__(self) -> str:
return f"<PecContact {self.email!r}>"
+114
View File
@@ -0,0 +1,114 @@
"""
Modelli RoutingRule, RoutingRuleCondition, RoutingRuleAction.
Le regole di smistamento automatico vengono valutate a ogni messaggio in arrivo:
1. Si caricano tutte le regole attive del tenant, ordinate per priority ASC
2. Per ogni regola si valutano le condizioni (AND tra le condizioni della stessa regola)
3. Se tutte le condizioni sono soddisfatte, si eseguono le azioni
4. Se stop_processing=True, si ferma l'elaborazione (non vengono valutate regole successive)
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class RoutingRule(Base):
__tablename__ = "routing_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
# Se True, le regole successive non vengono valutate una volta che questa fa match
stop_processing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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()
)
# Relazioni
conditions: Mapped[list["RoutingRuleCondition"]] = relationship(
"RoutingRuleCondition", back_populates="rule", cascade="all, delete-orphan"
)
actions: Mapped[list["RoutingRuleAction"]] = relationship(
"RoutingRuleAction", back_populates="rule", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_routing_rules_tenant", "tenant_id"),
Index("idx_routing_rules_active", "tenant_id", "priority"),
)
def __repr__(self) -> str:
return f"<RoutingRule {self.name!r} priority={self.priority}>"
class RoutingRuleCondition(Base):
"""
Singola condizione di una regola.
field : from_address | to_address | subject | mailbox_id | pec_type
operator : contains | equals | starts_with | ends_with | regex | not_contains
value : valore da confrontare (stringa)
"""
__tablename__ = "routing_rule_conditions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
rule_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
)
field: Mapped[str] = mapped_column(String(50), nullable=False)
operator: Mapped[str] = mapped_column(String(30), nullable=False, default="contains")
value: Mapped[str] = mapped_column(Text, nullable=False)
rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="conditions")
__table_args__ = (
Index("idx_routing_conditions_rule", "rule_id"),
)
class RoutingRuleAction(Base):
"""
Singola azione di una regola.
action_type : apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
action_value : UUID della label/vbox, o URL del webhook
"""
__tablename__ = "routing_rule_actions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
rule_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
)
action_type: Mapped[str] = mapped_column(String(50), nullable=False)
action_value: Mapped[str | None] = mapped_column(Text, nullable=True)
rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="actions")
__table_args__ = (
Index("idx_routing_actions_rule", "rule_id"),
)
+45
View File
@@ -0,0 +1,45 @@
"""
Modello MessageTemplate template riutilizzabili per la composizione PEC.
"""
import uuid
from datetime import datetime
from sqlalchemy import 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 MessageTemplate(Base):
__tablename__ = "message_templates"
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)
subject: Mapped[str] = mapped_column(Text, nullable=False, default="")
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_html: 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_template_name_tenant"),
Index("idx_templates_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<MessageTemplate {self.name!r}>"
+56
View File
@@ -0,0 +1,56 @@
"""
Schemi Pydantic per PecContact (Feature 6 Rubrica indirizzi PEC).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
class PecContactCreate(BaseModel):
email: EmailStr
name: str | None = None
organization: str | None = None
notes: str | None = None
is_favorite: bool = False
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
return v.lower().strip()
class PecContactUpdate(BaseModel):
name: str | None = None
organization: str | None = None
notes: str | None = None
is_favorite: bool | None = None
class PecContactResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
email: str
name: str | None = None
organization: str | None = None
notes: str | None = None
is_favorite: bool
auto_saved: bool
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
class PecContactListResponse(BaseModel):
items: list[PecContactResponse]
total: int
class PecContactImportResult(BaseModel):
created: int
updated: int
skipped: int
errors: list[str] = []
+113
View File
@@ -0,0 +1,113 @@
"""
Schemi Pydantic per RoutingRule (Feature 2 Regole di smistamento automatico).
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, field_validator
# Valori validi per field nelle condizioni
CONDITION_FIELDS = Literal[
"from_address", "to_address", "subject", "mailbox_id", "pec_type"
]
# Operatori supportati
CONDITION_OPERATORS = Literal[
"contains", "equals", "starts_with", "ends_with", "regex", "not_contains"
]
# Tipi di azione
ACTION_TYPES = Literal[
"apply_label", "assign_vbox", "mark_read", "mark_starred", "notify_webhook"
]
class RoutingRuleConditionCreate(BaseModel):
field: CONDITION_FIELDS
operator: CONDITION_OPERATORS = "contains"
value: str
@field_validator("value")
@classmethod
def value_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il valore della condizione non puo' essere vuoto")
return v.strip()
class RoutingRuleActionCreate(BaseModel):
action_type: ACTION_TYPES
action_value: str | None = None
class RoutingRuleCreate(BaseModel):
name: str
description: str | None = None
is_active: bool = True
priority: int = 100
stop_processing: bool = True
conditions: list[RoutingRuleConditionCreate] = []
actions: list[RoutingRuleActionCreate] = []
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il nome della regola non puo' essere vuoto")
return v.strip()
@field_validator("priority")
@classmethod
def priority_positive(cls, v: int) -> int:
if v < 1:
raise ValueError("La priorita' deve essere >= 1")
return v
class RoutingRuleUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_active: bool | None = None
priority: int | None = None
stop_processing: bool | None = None
conditions: list[RoutingRuleConditionCreate] | None = None
actions: list[RoutingRuleActionCreate] | None = None
class RoutingRuleConditionResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
field: str
operator: str
value: str
class RoutingRuleActionResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
action_type: str
action_value: str | None = None
class RoutingRuleResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None = None
is_active: bool
priority: int
stop_processing: bool
conditions: list[RoutingRuleConditionResponse] = []
actions: list[RoutingRuleActionResponse] = []
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
class RoutingRuleListResponse(BaseModel):
items: list[RoutingRuleResponse]
total: int
+4
View File
@@ -37,6 +37,9 @@ class SendPecRequest(BaseModel):
reply_to_message_id: uuid.UUID | None = None
"""UUID del messaggio a cui si risponde (per threading, opzionale)."""
scheduled_at: datetime | None = None
"""Data/ora di invio differito (Feature 5). Se None, invio immediato."""
@field_validator("to_addresses")
@classmethod
def at_least_one_recipient(cls, v: list[EmailStr]) -> list[EmailStr]:
@@ -67,6 +70,7 @@ class SendJobResponse(BaseModel):
max_attempts: int
next_retry_at: datetime | None = None
last_error: str | None = None
scheduled_at: datetime | None = None
queued_at: datetime
sent_at: datetime | None = None
created_by: uuid.UUID | None = None
+51
View File
@@ -0,0 +1,51 @@
"""
Schemi Pydantic per MessageTemplate (Feature 1 Template messaggi).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, field_validator
class TemplateCreate(BaseModel):
name: str
description: str | None = None
subject: str = ""
body_text: str | None = None
body_html: str | None = None
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il nome del template non puo' essere vuoto")
return v.strip()
class TemplateUpdate(BaseModel):
name: str | None = None
description: str | None = None
subject: str | None = None
body_text: str | None = None
body_html: str | None = None
class TemplateResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None = None
subject: str
body_text: str | None = None
body_html: str | None = None
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
class TemplateListResponse(BaseModel):
items: list[TemplateResponse]
total: int
+240
View File
@@ -0,0 +1,240 @@
"""
Service per la gestione della rubrica indirizzi PEC (Feature 6).
"""
import csv
import io
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.pec_contact import PecContact
from app.schemas.pec_contact import (
PecContactCreate,
PecContactImportResult,
PecContactUpdate,
)
class PecContactService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_contacts(
self,
tenant_id: uuid.UUID,
q: str | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[PecContact], int]:
query = select(PecContact).where(PecContact.tenant_id == tenant_id)
if q:
pattern = f"%{q.lower()}%"
query = query.where(
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
PecContact.organization.ilike(pattern),
)
)
query = query.order_by(PecContact.is_favorite.desc(), PecContact.name, PecContact.email)
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> PecContact:
result = await self.db.execute(
select(PecContact).where(
PecContact.id == contact_id,
PecContact.tenant_id == tenant_id,
)
)
contact = result.scalar_one_or_none()
if not contact:
raise NotFoundError(f"Contatto {contact_id} non trovato")
return contact
async def create_contact(
self,
tenant_id: uuid.UUID,
data: PecContactCreate,
created_by: uuid.UUID | None = None,
) -> PecContact:
email = data.email.lower().strip()
# Verifica unicita'
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Contatto '{email}' gia' esistente")
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=data.name,
organization=data.organization,
notes=data.notes,
is_favorite=data.is_favorite,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
await self.db.commit()
await self.db.refresh(contact)
return contact
async def update_contact(
self,
tenant_id: uuid.UUID,
contact_id: uuid.UUID,
data: PecContactUpdate,
) -> PecContact:
contact = await self.get_contact(tenant_id, contact_id)
if data.name is not None:
contact.name = data.name
if data.organization is not None:
contact.organization = data.organization
if data.notes is not None:
contact.notes = data.notes
if data.is_favorite is not None:
contact.is_favorite = data.is_favorite
await self.db.commit()
await self.db.refresh(contact)
return contact
async def delete_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> None:
contact = await self.get_contact(tenant_id, contact_id)
await self.db.delete(contact)
await self.db.commit()
async def auto_save_sender(
self,
tenant_id: uuid.UUID,
email: str,
) -> None:
"""
Salva automaticamente il mittente nella rubrica se non esiste ancora.
Operazione non bloccante: gli errori vengono ignorati silenziosamente.
Usata dal worker IMAP durante la sincronizzazione dei messaggi inbound.
"""
if not email:
return
email = email.lower().strip()
try:
# Upsert: inserisce solo se non esiste
stmt = insert(PecContact).values(
id=uuid.uuid4(),
tenant_id=tenant_id,
email=email,
auto_saved=True,
).on_conflict_do_nothing(
constraint="uq_pec_contact_email_tenant"
)
await self.db.execute(stmt)
await self.db.commit()
except Exception:
await self.db.rollback()
async def import_csv(
self,
tenant_id: uuid.UUID,
csv_content: str,
created_by: uuid.UUID | None = None,
) -> PecContactImportResult:
"""
Importa contatti da un CSV con colonne: email, name, organization.
Aggiorna i record esistenti con name/organization se forniti.
"""
result = PecContactImportResult(created=0, updated=0, skipped=0)
reader = csv.DictReader(io.StringIO(csv_content))
for row_num, row in enumerate(reader, start=2):
email = row.get("email", "").strip().lower()
if not email or "@" not in email:
result.skipped += 1
result.errors.append(f"Riga {row_num}: email non valida '{email}'")
continue
name = row.get("name", "").strip() or None
organization = row.get("organization", "").strip() or None
try:
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
contact = existing.scalar_one_or_none()
if contact:
# Aggiorna solo se i campi erano vuoti (non sovrascrive dati manuali)
updated = False
if name and not contact.name:
contact.name = name
updated = True
if organization and not contact.organization:
contact.organization = organization
updated = True
if updated:
result.updated += 1
else:
result.skipped += 1
else:
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=name,
organization=organization,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
result.created += 1
except Exception as e:
result.errors.append(f"Riga {row_num} ({email}): {e}")
result.skipped += 1
await self.db.commit()
return result
async def search_for_autocomplete(
self,
tenant_id: uuid.UUID,
q: str,
limit: int = 10,
) -> list[PecContact]:
"""Ricerca veloce per autocomplete nel compose."""
if not q or len(q) < 2:
return []
pattern = f"%{q.lower()}%"
result = await self.db.execute(
select(PecContact)
.where(
PecContact.tenant_id == tenant_id,
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
)
)
.order_by(PecContact.is_favorite.desc(), PecContact.email)
.limit(limit)
)
return list(result.scalars().all())
@@ -0,0 +1,296 @@
"""
Service per la gestione delle regole di smistamento automatico (Feature 2).
Il metodo evaluate_rules() viene chiamato dal worker dopo ogni messaggio inbound.
"""
import re
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.exceptions import NotFoundError
from app.models.label import Label, MessageLabel
from app.models.message import Message
from app.models.routing_rule import RoutingRule, RoutingRuleAction, RoutingRuleCondition
from app.schemas.routing_rule import RoutingRuleCreate, RoutingRuleUpdate
class RoutingRuleService:
def __init__(self, db: AsyncSession):
self.db = db
# ─── CRUD ─────────────────────────────────────────────────────────────────
async def list_rules(
self,
tenant_id: uuid.UUID,
) -> tuple[list[RoutingRule], int]:
query = (
select(RoutingRule)
.where(RoutingRule.tenant_id == tenant_id)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
.order_by(RoutingRule.priority)
)
count_q = select(func.count()).select_from(
select(RoutingRule).where(RoutingRule.tenant_id == tenant_id).subquery()
)
total = (await self.db.execute(count_q)).scalar_one()
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_rule(
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
) -> RoutingRule:
result = await self.db.execute(
select(RoutingRule)
.where(RoutingRule.id == rule_id, RoutingRule.tenant_id == tenant_id)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
)
rule = result.scalar_one_or_none()
if not rule:
raise NotFoundError(f"Regola {rule_id} non trovata")
return rule
async def create_rule(
self,
tenant_id: uuid.UUID,
data: RoutingRuleCreate,
created_by: uuid.UUID | None = None,
) -> RoutingRule:
rule = RoutingRule(
tenant_id=tenant_id,
name=data.name,
description=data.description,
is_active=data.is_active,
priority=data.priority,
stop_processing=data.stop_processing,
created_by=created_by,
)
self.db.add(rule)
await self.db.flush()
for cond in data.conditions:
self.db.add(RoutingRuleCondition(
rule_id=rule.id,
field=cond.field,
operator=cond.operator,
value=cond.value,
))
for action in data.actions:
self.db.add(RoutingRuleAction(
rule_id=rule.id,
action_type=action.action_type,
action_value=action.action_value,
))
await self.db.commit()
return await self.get_rule(tenant_id, rule.id)
async def update_rule(
self,
tenant_id: uuid.UUID,
rule_id: uuid.UUID,
data: RoutingRuleUpdate,
) -> RoutingRule:
rule = await self.get_rule(tenant_id, rule_id)
if data.name is not None:
rule.name = data.name
if data.description is not None:
rule.description = data.description
if data.is_active is not None:
rule.is_active = data.is_active
if data.priority is not None:
rule.priority = data.priority
if data.stop_processing is not None:
rule.stop_processing = data.stop_processing
# Se condizioni o azioni vengono aggiornate, le sostituisce completamente
if data.conditions is not None:
await self.db.execute(
delete(RoutingRuleCondition).where(RoutingRuleCondition.rule_id == rule_id)
)
for cond in data.conditions:
self.db.add(RoutingRuleCondition(
rule_id=rule_id,
field=cond.field,
operator=cond.operator,
value=cond.value,
))
if data.actions is not None:
await self.db.execute(
delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule_id)
)
for action in data.actions:
self.db.add(RoutingRuleAction(
rule_id=rule_id,
action_type=action.action_type,
action_value=action.action_value,
))
await self.db.commit()
return await self.get_rule(tenant_id, rule_id)
async def delete_rule(
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
) -> None:
rule = await self.get_rule(tenant_id, rule_id)
await self.db.delete(rule)
await self.db.commit()
# ─── Motore di valutazione ────────────────────────────────────────────────
async def evaluate_and_apply(
self,
message: Message,
) -> int:
"""
Valuta le regole attive del tenant e applica le azioni su message.
Returns:
Numero di regole che hanno prodotto match.
"""
# Carica regole attive ordinate per priority
result = await self.db.execute(
select(RoutingRule)
.where(
RoutingRule.tenant_id == message.tenant_id,
RoutingRule.is_active == True, # noqa: E712
)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
.order_by(RoutingRule.priority)
)
rules = list(result.scalars().all())
matched_count = 0
for rule in rules:
if await self._matches(message, rule.conditions):
matched_count += 1
await self._apply_actions(message, rule.actions)
if rule.stop_processing:
break
if matched_count > 0:
await self.db.flush()
return matched_count
async def _matches(
self,
message: Message,
conditions: list[RoutingRuleCondition],
) -> bool:
"""Restituisce True se tutte le condizioni (AND) sono soddisfatte."""
if not conditions:
# Una regola senza condizioni non fa mai match (comportamento sicuro)
return False
for cond in conditions:
field_value = self._get_field_value(message, cond.field)
if not self._evaluate_condition(field_value, cond.operator, cond.value):
return False
return True
def _get_field_value(self, message: Message, field: str) -> str:
"""Estrae il valore del campo dal messaggio come stringa per il confronto."""
if field == "from_address":
return (message.from_address or "").lower()
elif field == "to_address":
return " ".join(message.to_addresses or []).lower()
elif field == "subject":
return (message.subject or "").lower()
elif field == "mailbox_id":
return str(message.mailbox_id)
elif field == "pec_type":
return message.pec_type or ""
return ""
def _evaluate_condition(
self, field_value: str, operator: str, value: str
) -> bool:
v = value.lower()
fv = field_value.lower()
if operator == "contains":
return v in fv
elif operator == "not_contains":
return v not in fv
elif operator == "equals":
return fv == v
elif operator == "starts_with":
return fv.startswith(v)
elif operator == "ends_with":
return fv.endswith(v)
elif operator == "regex":
try:
return bool(re.search(value, field_value, re.IGNORECASE))
except re.error:
return False
return False
async def _apply_actions(
self,
message: Message,
actions: list[RoutingRuleAction],
) -> None:
"""Esegue le azioni sulla regola che ha fatto match."""
for action in actions:
try:
if action.action_type == "apply_label" and action.action_value:
await self._action_apply_label(message, uuid.UUID(action.action_value))
elif action.action_type == "mark_read":
message.is_read = True
elif action.action_type == "mark_starred":
message.is_starred = True
elif action.action_type == "notify_webhook" and action.action_value:
await self._action_notify_webhook(message, action.action_value)
except Exception:
# Le azioni non devono interrompere il flusso principale
pass
async def _action_apply_label(
self, message: Message, label_id: uuid.UUID
) -> None:
"""Applica un'etichetta al messaggio (se non gia' presente)."""
# Verifica che la label appartenga al tenant
label = await self.db.execute(
select(Label).where(
Label.id == label_id,
Label.tenant_id == message.tenant_id,
)
)
if not label.scalar_one_or_none():
return
# Verifica che non sia gia' applicata
existing = await self.db.execute(
select(MessageLabel).where(
MessageLabel.message_id == message.id,
MessageLabel.label_id == label_id,
)
)
if not existing.scalar_one_or_none():
self.db.add(MessageLabel(message_id=message.id, label_id=label_id))
async def _action_notify_webhook(self, message: Message, url: str) -> None:
"""Invia una notifica webhook per il messaggio."""
import aiohttp
import json
payload = {
"event": "routing_rule_match",
"message_id": str(message.id),
"subject": message.subject,
"from_address": message.from_address,
"pec_type": message.pec_type,
}
try:
async with aiohttp.ClientSession() as session:
await session.post(
url,
json=payload,
timeout=aiohttp.ClientTimeout(total=5),
)
except Exception:
pass
+15 -2
View File
@@ -166,12 +166,16 @@ class SendService:
now = datetime.now(tz=timezone.utc)
has_files = bool(attachments)
# Invio differito: il messaggio parte in stato 'draft' se programmato
scheduled_at = getattr(data, "scheduled_at", None)
is_scheduled = scheduled_at is not None and scheduled_at > now
message = Message(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
direction="outbound",
pec_type="posta_certificata",
state="queued",
state="draft" if is_scheduled else "queued",
subject=data.subject,
from_address=mailbox.email_address,
to_addresses=[str(a) for a in data.to_addresses],
@@ -211,6 +215,7 @@ class SendService:
max_attempts=5,
created_by=current_user.id,
queued_at=now,
scheduled_at=scheduled_at if is_scheduled else None,
)
self.db.add(job)
await self.db.flush()
@@ -218,7 +223,15 @@ class SendService:
# ── Enqueue job arq ───────────────────────────────────────────────────
try:
arq_pool = await _get_arq_pool()
await arq_pool.enqueue_job("send_pec", str(job.id))
if is_scheduled and scheduled_at:
# Invio differito: defer_until = scheduled_at
await arq_pool.enqueue_job(
"send_pec",
str(job.id),
_defer_until=scheduled_at,
)
else:
await arq_pool.enqueue_job("send_pec", str(job.id))
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
+116
View File
@@ -0,0 +1,116 @@
"""
Service per la gestione dei template messaggi (Feature 1).
"""
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.template import MessageTemplate
from app.schemas.template import TemplateCreate, TemplateUpdate
class TemplateService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_templates(
self,
tenant_id: uuid.UUID,
q: str | None = None,
) -> tuple[list[MessageTemplate], int]:
query = select(MessageTemplate).where(MessageTemplate.tenant_id == tenant_id)
if q:
query = query.where(MessageTemplate.name.ilike(f"%{q}%"))
query = query.order_by(MessageTemplate.name)
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar_one()
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_template(
self, tenant_id: uuid.UUID, template_id: uuid.UUID
) -> MessageTemplate:
result = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.id == template_id,
MessageTemplate.tenant_id == tenant_id,
)
)
template = result.scalar_one_or_none()
if not template:
raise NotFoundError(f"Template {template_id} non trovato")
return template
async def create_template(
self,
tenant_id: uuid.UUID,
data: TemplateCreate,
created_by: uuid.UUID | None = None,
) -> MessageTemplate:
# Verifica unicita' nome
existing = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.tenant_id == tenant_id,
MessageTemplate.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Template '{data.name}' gia' esistente")
template = MessageTemplate(
tenant_id=tenant_id,
name=data.name,
description=data.description,
subject=data.subject,
body_text=data.body_text,
body_html=data.body_html,
created_by=created_by,
)
self.db.add(template)
await self.db.commit()
await self.db.refresh(template)
return template
async def update_template(
self,
tenant_id: uuid.UUID,
template_id: uuid.UUID,
data: TemplateUpdate,
) -> MessageTemplate:
template = await self.get_template(tenant_id, template_id)
if data.name is not None:
existing = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.tenant_id == tenant_id,
MessageTemplate.name == data.name,
MessageTemplate.id != template_id,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Template '{data.name}' gia' esistente")
template.name = data.name
if data.description is not None:
template.description = data.description
if data.subject is not None:
template.subject = data.subject
if data.body_text is not None:
template.body_text = data.body_text
if data.body_html is not None:
template.body_html = data.body_html
await self.db.commit()
await self.db.refresh(template)
return template
async def delete_template(
self, tenant_id: uuid.UUID, template_id: uuid.UUID
) -> None:
template = await self.get_template(tenant_id, template_id)
await self.db.delete(template)
await self.db.commit()
+10
View File
@@ -14,6 +14,10 @@ import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
import { SearchPage } from '@/pages/Search/SearchPage'
import { ReportsPage } from '@/pages/Reports/ReportsPage'
import { AuditLogPage } from '@/pages/AuditLog/AuditLogPage'
import { TemplatesPage } from '@/pages/Templates/TemplatesPage'
import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
import { ContactsPage } from '@/pages/Contacts/ContactsPage'
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -92,6 +96,12 @@ export default function App() {
{/* Audit Log */}
<Route path="/audit-log" element={<AuditLogPage />} />
{/* Nuove funzionalita' */}
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/deadlines" element={<DeadlinesPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+65
View File
@@ -0,0 +1,65 @@
import apiClient from './client'
export interface PecContactResponse {
id: string
tenant_id: string
email: string
name: string | null
organization: string | null
notes: string | null
is_favorite: boolean
auto_saved: boolean
created_by: string | null
created_at: string
updated_at: string
}
export interface PecContactCreate {
email: string
name?: string | null
organization?: string | null
notes?: string | null
is_favorite?: boolean
}
export interface PecContactUpdate {
name?: string | null
organization?: string | null
notes?: string | null
is_favorite?: boolean
}
export interface ContactImportResult {
created: number
updated: number
skipped: number
errors: string[]
}
export const contactsApi = {
list: (params?: { q?: string; page?: number; page_size?: number }) =>
apiClient.get<{ items: PecContactResponse[]; total: number }>('/contacts', { params }).then((r) => r.data),
autocomplete: (q: string) =>
apiClient.get<PecContactResponse[]>('/contacts/autocomplete', { params: { q } }).then((r) => r.data),
get: (id: string) =>
apiClient.get<PecContactResponse>(`/contacts/${id}`).then((r) => r.data),
create: (data: PecContactCreate) =>
apiClient.post<PecContactResponse>('/contacts', data).then((r) => r.data),
update: (id: string, data: PecContactUpdate) =>
apiClient.put<PecContactResponse>(`/contacts/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/contacts/${id}`).then((r) => r.data),
importCsv: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return apiClient.post<ContactImportResult>('/contacts/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then((r) => r.data)
},
}
+31
View File
@@ -0,0 +1,31 @@
import apiClient from './client'
export interface DeadlineMessageResponse {
id: string
subject: string | null
from_address: string | null
to_addresses: string[] | null
direction: 'inbound' | 'outbound'
pec_type: string
state: string
mailbox_id: string
deadline_at: string | null
deadline_note: string | null
is_overdue: boolean
received_at: string | null
sent_at: string | null
created_at: string
}
export interface DeadlineSetRequest {
deadline_at: string | null
deadline_note?: string | null
}
export const deadlinesApi = {
list: (params?: { days_ahead?: number; include_overdue?: boolean }) =>
apiClient.get<DeadlineMessageResponse[]>('/deadlines', { params }).then((r) => r.data),
setDeadline: (messageId: string, data: DeadlineSetRequest) =>
apiClient.post<DeadlineMessageResponse>(`/messages/${messageId}/deadline`, data).then((r) => r.data),
}
+23
View File
@@ -128,6 +128,29 @@ export const messagesApi = {
getReceipts: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
// ─── Feature 3: Thread ────────────────────────────────────────────────────
getThread: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/thread`).then((r) => r.data),
// ─── Feature 7: Preview allegati ─────────────────────────────────────────
getAttachmentPreviewUrl: (messageId: string, attachmentId: string) =>
apiClient.get<{
previewable: boolean
content_type: string
filename: string
url?: string
}>(`/messages/${messageId}/attachments/${attachmentId}/preview-url`).then((r) => r.data),
// ─── Feature 8: Stampa ────────────────────────────────────────────────────
/** Apre la vista di stampa HTML in una nuova tab. */
openPrint: (messageId: string, token: string) => {
const baseUrl = (window as any).__API_BASE_URL__ || '/api/v1'
window.open(`${baseUrl}/messages/${messageId}/print?token=${token}`, '_blank')
},
/**
* Scarica il pacchetto ZIP completo della PEC (postacert.eml, daticert.xml,
* ricevute di accettazione/consegna per le mail outbound).
+65
View File
@@ -0,0 +1,65 @@
import apiClient from './client'
export type ConditionField = 'from_address' | 'to_address' | 'subject' | 'mailbox_id' | 'pec_type'
export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains'
export type ActionType = 'apply_label' | 'assign_vbox' | 'mark_read' | 'mark_starred' | 'notify_webhook'
export interface RoutingRuleCondition {
id: string
field: ConditionField
operator: ConditionOperator
value: string
}
export interface RoutingRuleAction {
id: string
action_type: ActionType
action_value: string | null
}
export interface RoutingRuleResponse {
id: string
tenant_id: string
name: string
description: string | null
is_active: boolean
priority: number
stop_processing: boolean
conditions: RoutingRuleCondition[]
actions: RoutingRuleAction[]
created_by: string | null
created_at: string
updated_at: string
}
export interface RoutingRuleCreate {
name: string
description?: string | null
is_active?: boolean
priority?: number
stop_processing?: boolean
conditions?: Array<{ field: ConditionField; operator: ConditionOperator; value: string }>
actions?: Array<{ action_type: ActionType; action_value?: string | null }>
}
export type RoutingRuleUpdate = Partial<RoutingRuleCreate>
export const routingRulesApi = {
list: () =>
apiClient.get<{ items: RoutingRuleResponse[]; total: number }>('/routing-rules').then((r) => r.data),
get: (id: string) =>
apiClient.get<RoutingRuleResponse>(`/routing-rules/${id}`).then((r) => r.data),
create: (data: RoutingRuleCreate) =>
apiClient.post<RoutingRuleResponse>('/routing-rules', data).then((r) => r.data),
update: (id: string, data: RoutingRuleUpdate) =>
apiClient.put<RoutingRuleResponse>(`/routing-rules/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/routing-rules/${id}`).then((r) => r.data),
toggle: (id: string) =>
apiClient.post<RoutingRuleResponse>(`/routing-rules/${id}/toggle`).then((r) => r.data),
}
+49
View File
@@ -0,0 +1,49 @@
import apiClient from './client'
export interface TemplateResponse {
id: string
tenant_id: string
name: string
description: string | null
subject: string
body_text: string | null
body_html: string | null
created_by: string | null
created_at: string
updated_at: string
}
export interface TemplateCreate {
name: string
description?: string | null
subject?: string
body_text?: string | null
body_html?: string | null
}
export interface TemplateUpdate {
name?: string
description?: string | null
subject?: string
body_text?: string | null
body_html?: string | null
}
export const templatesApi = {
list: (q?: string) =>
apiClient.get<{ items: TemplateResponse[]; total: number }>('/templates', {
params: { q },
}).then((r) => r.data),
get: (id: string) =>
apiClient.get<TemplateResponse>(`/templates/${id}`).then((r) => r.data),
create: (data: TemplateCreate) =>
apiClient.post<TemplateResponse>('/templates', data).then((r) => r.data),
update: (id: string, data: TemplateUpdate) =>
apiClient.put<TemplateResponse>(`/templates/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/templates/${id}`).then((r) => r.data),
}
+39 -1
View File
@@ -54,6 +54,10 @@ import {
BarChart2,
ClipboardList,
ShieldCheck,
FileText,
Settings2,
BookUser,
Calendar,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -403,10 +407,42 @@ export function Sidebar() {
</div>
)}
{/* ── Ricerca avanzata + Dashboard ── */}
{/* ── Strumenti operativi ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2 space-y-0.5">
<NavLink
to="/deadlines"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Scadenzario' : undefined}
>
<Calendar className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Scadenzario</span>}
</NavLink>
<NavLink
to="/contacts"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Rubrica PEC' : undefined}
>
<BookUser className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Rubrica PEC</span>}
</NavLink>
<NavLink
to="/search"
className={({ isActive }) =>
@@ -518,6 +554,8 @@ export function Sidebar() {
{ to: '/users', label: 'Utenti', icon: Users },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
{ to: '/templates', label: 'Template messaggi', icon: FileText },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
] as const).map((item) => (
@@ -0,0 +1,282 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Star, Trash2, Pencil, Upload, BookUser, Building2 } 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 { contactsApi, type PecContactResponse, type PecContactCreate, type PecContactUpdate } from '@/api/contacts.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
export function ContactsPage() {
const queryClient = useQueryClient()
const [q, setQ] = useState('')
const [page] = useState(1)
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<PecContactResponse | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Form state
const [formEmail, setFormEmail] = useState('')
const [formName, setFormName] = useState('')
const [formOrg, setFormOrg] = useState('')
const [formNotes, setFormNotes] = useState('')
const [formFavorite, setFormFavorite] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['contacts', q, page],
queryFn: () => contactsApi.list({ q: q || undefined, page, page_size: 50 }),
})
const createMutation = useMutation({
mutationFn: (d: PecContactCreate) => contactsApi.create(d),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success('Contatto aggiunto')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: PecContactUpdate }) =>
contactsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success('Contatto aggiornato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => contactsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success('Contatto eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const importMutation = useMutation({
mutationFn: (file: File) => contactsApi.importCsv(file),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success(`Importati: ${res.created} nuovi, ${res.updated} aggiornati, ${res.skipped} saltati`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleFavMutation = useMutation({
mutationFn: ({ id, fav }: { id: string; fav: boolean }) =>
contactsApi.update(id, { is_favorite: fav }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['contacts'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const openCreate = () => {
setEditing(null)
setFormEmail('')
setFormName('')
setFormOrg('')
setFormNotes('')
setFormFavorite(false)
setShowForm(true)
}
const openEdit = (c: PecContactResponse) => {
setEditing(c)
setFormEmail(c.email)
setFormName(c.name ?? '')
setFormOrg(c.organization ?? '')
setFormNotes(c.notes ?? '')
setFormFavorite(c.is_favorite)
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const handleSubmit = () => {
if (!formEmail.trim()) return toast.error('L\'email e\' obbligatoria')
const payload = {
email: formEmail.trim(),
name: formName.trim() || null,
organization: formOrg.trim() || null,
notes: formNotes.trim() || null,
is_favorite: formFavorite,
}
if (editing) {
updateMutation.mutate({ id: editing.id, data: payload })
} else {
createMutation.mutate(payload)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
importMutation.mutate(file)
e.target.value = ''
}
}
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Rubrica PEC</h1>
<p className="text-sm text-muted-foreground">
{total} contatti nella rubrica
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => fileInputRef.current?.click()} isLoading={importMutation.isPending}>
<Upload className="h-4 w-4 mr-2" />
Importa CSV
</Button>
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileChange} />
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuovo contatto
</Button>
</div>
</div>
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
<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 email, nome o organizzazione..."
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-9"
/>
</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">
<BookUser className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessun contatto trovato</p>
<p className="text-sm mt-1">
Aggiungi contatti manualmente o importa un CSV con colonne: email, name, organization
</p>
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Email PEC</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Nome</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Organizzazione</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Tipo</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Aggiornato</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y">
{items.map((c) => (
<tr key={c.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs">{c.email}</td>
<td className="px-4 py-3">{c.name ?? '-'}</td>
<td className="px-4 py-3 text-muted-foreground">
{c.organization ? (
<span className="flex items-center gap-1">
<Building2 className="h-3.5 w-3.5" />
{c.organization}
</span>
) : '-'}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${c.auto_saved ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
{c.auto_saved ? 'Automatico' : 'Manuale'}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.updated_at)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => toggleFavMutation.mutate({ id: c.id, fav: !c.is_favorite })}
title={c.is_favorite ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
className="p-1 rounded hover:bg-muted transition-colors"
>
<Star className={`h-4 w-4 ${c.is_favorite ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
</button>
<button
onClick={() => openEdit(c)}
className="p-1 rounded hover:bg-muted transition-colors"
title="Modifica"
>
<Pencil className="h-4 w-4 text-muted-foreground" />
</button>
<button
onClick={() => {
if (confirm(`Eliminare il contatto ${c.email}?`)) deleteMutation.mutate(c.id)
}}
className="p-1 rounded hover:bg-muted transition-colors"
title="Elimina"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica contatto' : 'Nuovo contatto'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Indirizzo PEC *</Label>
<Input value={formEmail} onChange={(e) => setFormEmail(e.target.value)} placeholder="indirizzo@pec.it" disabled={!!editing} type="email" />
</div>
<div className="space-y-2">
<Label>Nome</Label>
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="Mario Rossi" />
</div>
<div className="space-y-2">
<Label>Organizzazione</Label>
<Input value={formOrg} onChange={(e) => setFormOrg(e.target.value)} placeholder="Comune di Roma" />
</div>
<div className="space-y-2">
<Label>Note</Label>
<Input value={formNotes} onChange={(e) => setFormNotes(e.target.value)} placeholder="Note aggiuntive..." />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={formFavorite} onChange={(e) => setFormFavorite(e.target.checked)} className="rounded" />
<span className="text-sm">Aggiungi ai preferiti</span>
</label>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
{editing ? 'Salva' : 'Aggiungi'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Calendar, AlertTriangle, Clock, CheckCircle2, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { deadlinesApi, type DeadlineMessageResponse } from '@/api/deadlines.api'
import { formatDate } from '@/lib/utils'
function groupDeadlines(items: DeadlineMessageResponse[]) {
const now = new Date()
const todayEnd = new Date(now)
todayEnd.setHours(23, 59, 59, 999)
const weekEnd = new Date(now)
weekEnd.setDate(weekEnd.getDate() + 7)
const monthEnd = new Date(now)
monthEnd.setDate(monthEnd.getDate() + 30)
const overdue: DeadlineMessageResponse[] = []
const today: DeadlineMessageResponse[] = []
const thisWeek: DeadlineMessageResponse[] = []
const later: DeadlineMessageResponse[] = []
for (const item of items) {
if (!item.deadline_at) continue
const d = new Date(item.deadline_at)
if (d < now) {
overdue.push(item)
} else if (d <= todayEnd) {
today.push(item)
} else if (d <= weekEnd) {
thisWeek.push(item)
} else {
later.push(item)
}
}
return { overdue, today, thisWeek, later }
}
function DeadlineItem({ item }: { item: DeadlineMessageResponse }) {
const navigate = useNavigate()
const deadlineDate = item.deadline_at ? new Date(item.deadline_at) : null
return (
<div
className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer ${item.is_overdue ? 'border-destructive/30 bg-destructive/5' : ''}`}
onClick={() => navigate(`/messages/${item.id}`)}
>
<div className="flex-shrink-0">
{item.is_overdue ? (
<AlertTriangle className="h-5 w-5 text-destructive" />
) : (
<Clock className="h-5 w-5 text-amber-500" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground">
{item.direction === 'inbound' ? `Da: ${item.from_address}` : `A: ${(item.to_addresses ?? []).join(', ')}`}
</p>
{item.deadline_note && (
<p className="text-xs text-muted-foreground italic mt-0.5">{item.deadline_note}</p>
)}
</div>
<div className="flex-shrink-0 text-right">
<p className={`text-sm font-semibold ${item.is_overdue ? 'text-destructive' : 'text-amber-600'}`}>
{deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'}
</p>
{item.is_overdue && (
<p className="text-xs text-destructive">Scaduto</p>
)}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
)
}
function DeadlineGroup({ title, items, icon: Icon, color }: {
title: string
items: DeadlineMessageResponse[]
icon: React.ComponentType<{ className?: string }>
color: string
}) {
if (items.length === 0) return null
return (
<div className="space-y-2">
<div className={`flex items-center gap-2 ${color}`}>
<Icon className="h-4 w-4" />
<h3 className="font-semibold text-sm">{title} ({items.length})</h3>
</div>
<div className="space-y-2">
{items.map((item) => (
<DeadlineItem key={item.id} item={item} />
))}
</div>
</div>
)
}
export function DeadlinesPage() {
const [daysAhead, setDaysAhead] = useState(30)
const [includeOverdue, setIncludeOverdue] = useState(true)
const { data = [], isLoading } = useQuery({
queryKey: ['deadlines', daysAhead, includeOverdue],
queryFn: () => deadlinesApi.list({ days_ahead: daysAhead, include_overdue: includeOverdue }),
})
const groups = groupDeadlines(data)
const total = data.length
return (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5 text-primary" />
Scadenzario
</h1>
<p className="text-sm text-muted-foreground">
{total} messaggi con scadenze
</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeOverdue}
onChange={(e) => setIncludeOverdue(e.target.checked)}
className="rounded"
/>
Includi scaduti
</label>
<select
value={daysAhead}
onChange={(e) => setDaysAhead(Number(e.target.value))}
className="text-sm border rounded-md px-3 py-1.5 bg-background"
>
<option value={7}>Prossimi 7 giorni</option>
<option value={30}>Prossimi 30 giorni</option>
<option value={90}>Prossimi 90 giorni</option>
<option value={365}>Prossimo anno</option>
</select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : total === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-4 opacity-30 text-green-500" />
<p className="text-lg font-medium">Nessuna scadenza trovata</p>
<p className="text-sm mt-1">
Le scadenze si impostano dal dettaglio di ogni messaggio.
</p>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-8">
<DeadlineGroup
title="Scaduti"
items={groups.overdue}
icon={AlertTriangle}
color="text-destructive"
/>
<DeadlineGroup
title="Oggi"
items={groups.today}
icon={Clock}
color="text-amber-600"
/>
<DeadlineGroup
title="Questa settimana"
items={groups.thisWeek}
icon={Calendar}
color="text-blue-600"
/>
<DeadlineGroup
title="Successivamente"
items={groups.later}
icon={Calendar}
color="text-muted-foreground"
/>
</div>
)}
</div>
</div>
)
}
@@ -17,17 +17,132 @@ import {
RotateCcw,
MailX,
PackageOpen,
Printer,
Calendar,
Eye,
MessageSquare,
X,
} 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 { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { TagBadge } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { deadlinesApi } from '@/api/deadlines.api'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import apiClient from '@/api/client'
// ─── Thread section (Feature 3) ──────────────────────────────────────────────
function ThreadSection({ messageId, currentId, navigate }: { messageId: string; currentId: string; navigate: (to: string) => void }) {
const { data: thread = [] } = useQuery({
queryKey: ['thread', messageId],
queryFn: () => messagesApi.getThread(messageId),
})
// Mostra solo se ci sono piu' di 1 messaggio nel thread
if (thread.length <= 1) return null
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Conversazione ({thread.length} messaggi)
</h3>
<div className="space-y-1.5 rounded-lg border bg-background p-3">
{thread.map((msg) => {
const isCurrent = msg.id === currentId
return (
<button
key={msg.id}
type="button"
onClick={() => !isCurrent && navigate(`/messages/${msg.id}`)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${
isCurrent
? 'bg-primary/10 border border-primary/30 cursor-default'
: 'hover:bg-muted/60 cursor-pointer'
}`}
>
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${msg.direction === 'inbound' ? 'bg-blue-500' : 'bg-green-500'}`} />
<div className="flex-1 min-w-0">
<p className={`text-sm truncate ${isCurrent ? 'font-semibold' : 'font-medium'}`}>
{msg.subject || '(nessun oggetto)'}
</p>
<p className="text-xs text-muted-foreground">
{msg.direction === 'inbound' ? msg.from_address : `A: ${(msg.to_addresses || []).join(', ')}`}
{' · '}
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
</p>
</div>
{isCurrent && <span className="text-xs text-primary flex-shrink-0">Questo</span>}
</button>
)
})}
</div>
</div>
)
}
// ─── Attachment preview modal ─────────────────────────────────────────────────
interface AttachmentPreviewProps {
messageId: string
attachmentId: string
filename: string
contentType: string
onClose: () => void
}
function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
const { data, isLoading } = useQuery({
queryKey: ['attachment-preview', messageId, attachmentId],
queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId),
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="relative bg-background rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="font-medium truncate">{filename}</span>
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-2 min-h-[400px] flex items-center justify-center">
{isLoading && (
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
)}
{!isLoading && data && (
<>
{data.previewable && data.url ? (
<>
{contentType.startsWith('image/') ? (
<img src={data.url} alt={filename} className="max-w-full max-h-full object-contain" />
) : contentType === 'application/pdf' ? (
<iframe src={data.url} className="w-full h-[70vh]" title={filename} />
) : null}
</>
) : (
<div className="text-center text-muted-foreground">
<Eye className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Anteprima non disponibile per questo tipo di file.</p>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function MessageDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -36,6 +151,15 @@ export function MessageDetailPage() {
const [showTagSelector, setShowTagSelector] = useState(false)
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
const [isPrinting, setIsPrinting] = useState(false)
// Feature 4: Deadline
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
const [deadlineDate, setDeadlineDate] = useState('')
const [deadlineNote, setDeadlineNote] = useState('')
// Feature 7: Preview allegati
const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
// Carica messaggio
const {
@@ -362,6 +486,52 @@ export function MessageDetailPage() {
)}
Scarica
</Button>
{/* Stampa (Feature 8) */}
<Button
variant="outline"
size="sm"
isLoading={isPrinting}
onClick={async () => {
if (!message) return
setIsPrinting(true)
try {
const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
const html = await response.data.text()
const w = window.open('', '_blank')
if (w) {
w.document.write(html)
w.document.close()
setTimeout(() => w.print(), 500)
}
} catch (e) {
toast.error('Errore apertura stampa')
} finally {
setIsPrinting(false)
}
}}
title="Stampa / Salva come PDF"
>
<Printer className="h-4 w-4 mr-1" />
Stampa
</Button>
{/* Scadenza (Feature 4) */}
<Button
variant="outline"
size="sm"
onClick={() => {
const dl = (message as any).deadline_at
setDeadlineDate(dl ? dl.substring(0, 16) : '')
setDeadlineNote((message as any).deadline_note ?? '')
setShowDeadlineForm(true)
}}
title="Imposta scadenza"
className={(message as any).deadline_at ? 'border-amber-400 text-amber-600' : ''}
>
<Calendar className="h-4 w-4 mr-1" />
{(message as any).deadline_at ? 'Scadenza' : 'Scadenza'}
</Button>
</div>
</div>
@@ -522,25 +692,39 @@ export function MessageDetailPage() {
Allegati ({attachments.length})
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((att) => (
<button
key={att.id}
type="button"
onClick={() => handleDownloadAttachment(att)}
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group text-left w-full"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
{attachments.map((att) => {
const isPreviewable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
return (
<div key={att.id} className="flex items-center gap-2 rounded-lg border bg-background p-3">
<button
type="button"
onClick={() => handleDownloadAttachment(att)}
className="flex items-center gap-3 flex-1 min-w-0 text-left hover:opacity-80"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</button>
{isPreviewable && (
<button
type="button"
onClick={() => setPreviewAtt({ id: att.id, filename: att.filename, contentType: att.content_type || '' })}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary flex-shrink-0"
title="Anteprima"
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
</button>
))}
)
})}
</div>
</div>
)}
@@ -558,6 +742,41 @@ export function MessageDetailPage() {
</div>
)}
{/* Scadenza (Feature 4) */}
{(message as any).deadline_at && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-amber-700">
<Calendar className="h-4 w-4" />
<span>
<strong>Scadenza:</strong> {formatDate((message as any).deadline_at)}
</span>
{(message as any).deadline_note && (
<span className="text-amber-600 italic">— {(message as any).deadline_note}</span>
)}
</div>
<button
onClick={() => {
setDeadlineDate('')
setDeadlineNote('')
deadlinesApi.setDeadline(message.id, { deadline_at: null }).then(() => {
queryClient.invalidateQueries({ queryKey: ['message', id] })
toast.success('Scadenza rimossa')
})
}}
className="text-xs text-amber-600 hover:text-amber-800"
>
Rimuovi
</button>
</div>
</div>
)}
{/* Thread (Feature 3) */}
{message.pec_type === 'posta_certificata' && (
<ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} />
)}
{/* Messaggio originale per ricevute */}
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
@@ -575,6 +794,63 @@ export function MessageDetailPage() {
</div>
</div>
{/* Modali */}
{/* Deadline form (Feature 4) */}
{showDeadlineForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl p-6 w-96 space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5 text-amber-500" />
Imposta scadenza
</h3>
<div className="space-y-2">
<Label>Data e ora scadenza</Label>
<Input
type="datetime-local"
value={deadlineDate}
onChange={e => setDeadlineDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Nota (opzionale)</Label>
<Input
value={deadlineNote}
onChange={e => setDeadlineNote(e.target.value)}
placeholder="Es. Termine per impugnare, entro 30 giorni..."
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setShowDeadlineForm(false)}>Annulla</Button>
<Button
onClick={async () => {
await deadlinesApi.setDeadline(message.id, {
deadline_at: deadlineDate ? new Date(deadlineDate).toISOString() : null,
deadline_note: deadlineNote || null,
})
queryClient.invalidateQueries({ queryKey: ['message', id] })
toast.success(deadlineDate ? 'Scadenza impostata' : 'Scadenza rimossa')
setShowDeadlineForm(false)
}}
>
Salva
</Button>
</div>
</div>
</div>
)}
{/* Attachment preview (Feature 7) */}
{previewAtt && (
<AttachmentPreviewModal
messageId={message.id}
attachmentId={previewAtt.id}
filename={previewAtt.filename}
contentType={previewAtt.contentType}
onClose={() => setPreviewAtt(null)}
/>
)}
{/* Dialog gestione tag */}
{showTagSelector && (
<TagSelector
@@ -0,0 +1,394 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, CheckCircle, XCircle, Settings2, ChevronDown, ChevronUp } 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 { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
import { labelsApi } from '@/api/labels.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
const FIELD_LABELS: Record<ConditionField, string> = {
from_address: 'Mittente',
to_address: 'Destinatario',
subject: 'Oggetto',
mailbox_id: 'ID Casella',
pec_type: 'Tipo PEC',
}
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
contains: 'contiene',
not_contains: 'non contiene',
equals: 'uguale a',
starts_with: 'inizia per',
ends_with: 'finisce per',
regex: 'regex',
}
const ACTION_LABELS: Record<ActionType, string> = {
apply_label: 'Applica etichetta',
assign_vbox: 'Assegna Virtual Box',
mark_read: 'Segna come letto',
mark_starred: 'Aggiungi ai preferiti',
notify_webhook: 'Notifica webhook',
}
interface Condition {
field: ConditionField
operator: ConditionOperator
value: string
}
interface Action {
action_type: ActionType
action_value: string
}
export function RoutingRulesPage() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<RoutingRuleResponse | null>(null)
const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set())
// Form state
const [formName, setFormName] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formPriority, setFormPriority] = useState('100')
const [formStopProcessing, setFormStopProcessing] = useState(true)
const [formConditions, setFormConditions] = useState<Condition[]>([
{ field: 'from_address', operator: 'contains', value: '' }
])
const [formActions, setFormActions] = useState<Action[]>([
{ action_type: 'mark_read', action_value: '' }
])
const { data, isLoading } = useQuery({
queryKey: ['routing-rules'],
queryFn: () => routingRulesApi.list(),
})
const { data: labelsData } = useQuery({
queryKey: ['labels'],
queryFn: () => labelsApi.list(),
})
const labels = labelsData ?? []
const createMutation = useMutation({
mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
toast.success('Regola creata')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: RoutingRuleCreate }) =>
routingRulesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
toast.success('Regola aggiornata')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => routingRulesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
toast.success('Regola eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleMutation = useMutation({
mutationFn: (id: string) => routingRulesApi.toggle(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-rules'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const openCreate = () => {
setEditing(null)
setFormName('')
setFormDescription('')
setFormPriority('100')
setFormStopProcessing(true)
setFormConditions([{ field: 'from_address', operator: 'contains', value: '' }])
setFormActions([{ action_type: 'mark_read', action_value: '' }])
setShowForm(true)
}
const openEdit = (r: RoutingRuleResponse) => {
setEditing(r)
setFormName(r.name)
setFormDescription(r.description ?? '')
setFormPriority(String(r.priority))
setFormStopProcessing(r.stop_processing)
setFormConditions(r.conditions.map(c => ({ field: c.field as ConditionField, operator: c.operator as ConditionOperator, value: c.value })))
setFormActions(r.actions.map(a => ({ action_type: a.action_type as ActionType, action_value: a.action_value ?? '' })))
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const handleSubmit = () => {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
if (formConditions.some(c => !c.value.trim())) return toast.error('Tutte le condizioni devono avere un valore')
const payload: RoutingRuleCreate = {
name: formName.trim(),
description: formDescription.trim() || null,
priority: parseInt(formPriority) || 100,
stop_processing: formStopProcessing,
is_active: true,
conditions: formConditions.map(c => ({ field: c.field, operator: c.operator, value: c.value.trim() })),
actions: formActions.map(a => ({ action_type: a.action_type, action_value: a.action_value.trim() || null })),
}
if (editing) {
updateMutation.mutate({ id: editing.id, data: payload })
} else {
createMutation.mutate(payload)
}
}
const items = data?.items ?? []
const toggleExpand = (id: string) => {
setExpandedRules(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Settings2 className="h-5 w-5 text-primary" />
Regole di smistamento
</h1>
<p className="text-sm text-muted-foreground">
Applica automaticamente etichette e azioni ai messaggi in arrivo
</p>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuova regola
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{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">
<Settings2 className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessuna regola configurata</p>
<p className="text-sm mt-1">Le regole vengono valutate in ordine di priorita' su ogni messaggio inbound.</p>
</div>
) : (
items.map((rule) => (
<div key={rule.id} className={cn('rounded-lg border bg-card', !rule.is_active && 'opacity-60')}>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{rule.name}</span>
<span className="text-xs text-muted-foreground">P:{rule.priority}</span>
<span className={cn('text-xs px-2 py-0.5 rounded-full', rule.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600')}>
{rule.is_active ? 'Attiva' : 'Inattiva'}
</span>
{rule.stop_processing && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">Stop</span>
)}
</div>
{rule.description && <p className="text-xs text-muted-foreground mt-0.5">{rule.description}</p>}
<p className="text-xs text-muted-foreground mt-0.5">
{rule.conditions.length} condizioni / {rule.actions.length} azioni
</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(rule)} title="Modifica">
<Settings2 className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => toggleMutation.mutate(rule.id)}
title={rule.is_active ? 'Disattiva' : 'Attiva'}
>
{rule.is_active
? <XCircle className="h-4 w-4 text-amber-500" />
: <CheckCircle className="h-4 w-4 text-green-500" />
}
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => { if (confirm(`Eliminare la regola "${rule.name}"?`)) deleteMutation.mutate(rule.id) }}
title="Elimina"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toggleExpand(rule.id)}>
{expandedRules.has(rule.id) ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
{expandedRules.has(rule.id) && (
<div className="border-t px-4 py-3 space-y-3">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Condizioni (AND)</p>
{rule.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-muted/40 rounded px-3 py-1.5 mb-1">
<span className="font-medium">{FIELD_LABELS[c.field as ConditionField] ?? c.field}</span>
<span className="text-muted-foreground">{OPERATOR_LABELS[c.operator as ConditionOperator] ?? c.operator}</span>
<span className="font-mono bg-background border rounded px-1.5 py-0.5">{c.value}</span>
</div>
))}
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Azioni</p>
{rule.actions.map((a, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-blue-50 rounded px-3 py-1.5 mb-1">
<span className="font-medium text-blue-700">{ACTION_LABELS[a.action_type as ActionType] ?? a.action_type}</span>
{a.action_value && <span className="font-mono text-blue-600">{a.action_value}</span>}
</div>
))}
</div>
</div>
)}
</div>
))
)}
</div>
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica regola' : 'Nuova regola di smistamento'}</DialogTitle>
</DialogHeader>
<div className="space-y-5 mt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 col-span-2">
<Label>Nome *</Label>
<Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="Es. Multe comune Roma" />
</div>
<div className="space-y-2">
<Label>Priorita' (1=alta, 999=bassa)</Label>
<Input type="number" value={formPriority} onChange={e => setFormPriority(e.target.value)} min={1} max={999} />
</div>
<div className="space-y-2">
<Label>Descrizione</Label>
<Input value={formDescription} onChange={e => setFormDescription(e.target.value)} placeholder="Descrizione opzionale" />
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={formStopProcessing} onChange={e => setFormStopProcessing(e.target.checked)} className="rounded" />
<span className="text-sm">Interrompi elaborazione dopo questa regola (stop processing)</span>
</label>
{/* Condizioni */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Condizioni (tutte devono essere soddisfatte - AND)</Label>
<Button type="button" variant="outline" size="sm" onClick={() => setFormConditions(prev => [...prev, { field: 'from_address', operator: 'contains', value: '' }])}>
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
</Button>
</div>
{formConditions.map((cond, i) => (
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-muted/20">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.field}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, field: e.target.value as ConditionField } : c))}
>
{(Object.entries(FIELD_LABELS) as [ConditionField, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.operator}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, operator: e.target.value as ConditionOperator } : c))}
>
{(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<Input
className="flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
placeholder="Valore..."
/>
<button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
{/* Azioni */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Azioni da eseguire</Label>
<Button type="button" variant="outline" size="sm" onClick={() => setFormActions(prev => [...prev, { action_type: 'mark_read', action_value: '' }])}>
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
</Button>
</div>
{formActions.map((action, i) => (
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-blue-50">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_type}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_type: e.target.value as ActionType, action_value: '' } : a))}
>
{(Object.entries(ACTION_LABELS) as [ActionType, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
{(action.action_type === 'apply_label') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
)}
{(action.action_type === 'notify_webhook') && (
<Input
className="flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
placeholder="https://..."
/>
)}
<button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>
</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 regola'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,228 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, FileText, 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 { templatesApi, type TemplateResponse, type TemplateCreate } from '@/api/templates.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
export function TemplatesPage() {
const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [q, setQ] = useState('')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<TemplateResponse | null>(null)
// Form state
const [formName, setFormName] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formSubject, setFormSubject] = useState('')
const [formBody, setFormBody] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['templates', q],
queryFn: () => templatesApi.list(q || undefined),
})
const createMutation = useMutation({
mutationFn: (data: TemplateCreate) => templatesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] })
toast.success('Template creato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: TemplateCreate }) =>
templatesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] })
toast.success('Template aggiornato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => templatesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] })
toast.success('Template eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const openCreate = () => {
setEditing(null)
setFormName('')
setFormDescription('')
setFormSubject('')
setFormBody('')
setShowForm(true)
}
const openEdit = (t: TemplateResponse) => {
setEditing(t)
setFormName(t.name)
setFormDescription(t.description ?? '')
setFormSubject(t.subject)
setFormBody(t.body_html ?? t.body_text ?? '')
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const handleSubmit = () => {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
const payload: TemplateCreate = {
name: formName.trim(),
description: formDescription.trim() || null,
subject: formSubject.trim(),
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="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Template messaggi</h1>
<p className="text-sm text-muted-foreground">
Template riutilizzabili per la composizione PEC
</p>
</div>
{isAdmin && (
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuovo template
</Button>
)}
</div>
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
{/* Ricerca */}
<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>
{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">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessun template trovato</p>
<p className="text-sm mt-1">
{isAdmin ? 'Crea il tuo primo template con il pulsante in alto.' : 'Nessun template disponibile.'}
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((t) => (
<div key={t.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">{t.name}</h3>
{t.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{t.description}</p>
)}
</div>
{isAdmin && (
<div className="flex gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" onClick={() => openEdit(t)} 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 il template "${t.name}"?`)) {
deleteMutation.mutate(t.id)
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{t.subject && (
<p className="text-sm font-medium text-foreground/80 truncate">
Oggetto: {t.subject}
</p>
)}
<p className="text-xs text-muted-foreground">
Aggiornato: {formatDate(t.updated_at)}
</p>
</div>
))}
</div>
)}
</div>
{/* Dialog form */}
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica template' : 'Nuovo template'}</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. Risposta a ricorso" />
</div>
<div className="space-y-2">
<Label>Descrizione (opzionale)</Label>
<Input value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Breve descrizione del template" />
</div>
<div className="space-y-2">
<Label>Oggetto predefinito</Label>
<Input value={formSubject} onChange={(e) => setFormSubject(e.target.value)} placeholder="Oggetto del messaggio PEC" />
</div>
<div className="space-y-2">
<Label>Corpo del messaggio</Label>
<div className="min-h-[200px] border rounded-md overflow-hidden">
<RichTextEditor value={formBody} onChange={setFormBody} placeholder="Testo del template..." />
</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 template'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+26
View File
@@ -659,6 +659,32 @@ async def _save_message(
redis_client=redis_client,
)
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
if direction == "inbound" and pec_class.pec_type == "posta_certificata":
try:
await redis_client.enqueue_job("apply_routing_rules", str(message.id))
except Exception as e:
logger.warning(f"[{mailbox.email_address}] Impossibile enqueue apply_routing_rules: {e}")
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
if direction == "inbound" and pec_class.pec_type == "posta_certificata" and message.from_address:
try:
from sqlalchemy import text as _text
await db.execute(
_text("""
INSERT INTO pec_contacts (id, tenant_id, email, auto_saved, created_at, updated_at)
VALUES (gen_random_uuid(), :tenant_id, :email, true, now(), now())
ON CONFLICT (tenant_id, email) DO NOTHING
"""),
{"tenant_id": str(mailbox.tenant_id), "email": message.from_address.lower().strip()},
)
await db.flush()
except Exception as e:
logger.debug(f"[{mailbox.email_address}] Auto-save contatto fallito (non critico): {e}")
return True
+234
View File
@@ -0,0 +1,234 @@
"""
Job arq: apply_routing_rules applica le regole di smistamento automatico.
Viene accodato da sync.py dopo il salvataggio di ogni messaggio inbound.
Logica:
1. Carica le regole attive del tenant ordinate per priority
2. Per ogni regola valuta le condizioni (AND)
3. Se match: esegue le azioni (apply_label, mark_read, mark_starred, notify_webhook)
4. Se stop_processing=True, interrompe la catena
"""
import logging
import re
import uuid as uuid_module
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import Message
logger = logging.getLogger(__name__)
# ─── Job principale ───────────────────────────────────────────────────────────
async def apply_routing_rules(ctx: dict[str, Any], message_id: str) -> dict:
"""
Valuta le regole di smistamento automatico per un messaggio.
Args:
ctx: contesto arq
message_id: UUID del messaggio da processare
Returns:
dict con: matched_rules, actions_applied
"""
msg_uuid = uuid_module.UUID(message_id)
async with AsyncSessionLocal() as db:
# Carica il messaggio
msg = await db.get(Message, msg_uuid)
if not msg:
logger.warning(f"[routing_rules] Messaggio {message_id} non trovato")
return {"status": "skipped", "reason": "message_not_found"}
# Solo messaggi inbound di tipo posta_certificata
if msg.direction != "inbound" or msg.pec_type != "posta_certificata":
return {"status": "skipped", "reason": "not_inbound_pec"}
# Carica regole attive del tenant ordinate per priority ASC
rules_result = await db.execute(
text("""
SELECT r.id, r.name, r.priority, r.stop_processing,
COALESCE(
json_agg(
json_build_object('field', c.field, 'operator', c.operator, 'value', c.value)
ORDER BY c.id
) FILTER (WHERE c.id IS NOT NULL),
'[]'::json
) AS conditions,
COALESCE(
json_agg(
json_build_object('action_type', a.action_type, 'action_value', a.action_value)
ORDER BY a.id
) FILTER (WHERE a.id IS NOT NULL),
'[]'::json
) AS actions
FROM routing_rules r
LEFT JOIN routing_rule_conditions c ON c.rule_id = r.id
LEFT JOIN routing_rule_actions a ON a.rule_id = r.id
WHERE r.tenant_id = :tenant_id
AND r.is_active = true
GROUP BY r.id, r.name, r.priority, r.stop_processing
ORDER BY r.priority ASC
"""),
{"tenant_id": str(msg.tenant_id)},
)
rules = rules_result.mappings().all()
matched_count = 0
actions_applied: list[str] = []
for rule in rules:
conditions = rule["conditions"]
if not conditions:
continue
# Valuta condizioni (AND)
if not _evaluate_conditions(msg, conditions):
continue
matched_count += 1
logger.info(
f"[routing_rules] Regola '{rule['name']}' (priority={rule['priority']}) "
f"match per messaggio {message_id}"
)
# Esegui azioni
for action in rule["actions"]:
applied = await _apply_action(db, msg, action["action_type"], action["action_value"])
if applied:
actions_applied.append(action["action_type"])
if rule["stop_processing"]:
break
if matched_count > 0:
await db.commit()
return {
"status": "ok",
"message_id": message_id,
"matched_rules": matched_count,
"actions_applied": actions_applied,
}
# ─── Valutazione condizioni ────────────────────────────────────────────────────
def _get_field_value(msg: Message, field: str) -> str:
if field == "from_address":
return (msg.from_address or "").lower()
elif field == "to_address":
return " ".join(msg.to_addresses or []).lower()
elif field == "subject":
return (msg.subject or "").lower()
elif field == "mailbox_id":
return str(msg.mailbox_id)
elif field == "pec_type":
return msg.pec_type or ""
return ""
def _evaluate_condition(field_value: str, operator: str, value: str) -> bool:
v = value.lower()
fv = field_value.lower()
if operator == "contains":
return v in fv
elif operator == "not_contains":
return v not in fv
elif operator == "equals":
return fv == v
elif operator == "starts_with":
return fv.startswith(v)
elif operator == "ends_with":
return fv.endswith(v)
elif operator == "regex":
try:
return bool(re.search(value, field_value, re.IGNORECASE))
except re.error:
return False
return False
def _evaluate_conditions(msg: Message, conditions: list[dict]) -> bool:
"""Valuta AND tra tutte le condizioni."""
for cond in conditions:
field_val = _get_field_value(msg, cond["field"])
if not _evaluate_condition(field_val, cond["operator"], cond["value"]):
return False
return True
# ─── Esecuzione azioni ─────────────────────────────────────────────────────────
async def _apply_action(
db: AsyncSession,
msg: Message,
action_type: str,
action_value: str | None,
) -> bool:
"""Esegue una singola azione. Restituisce True se applicata."""
try:
if action_type == "apply_label" and action_value:
return await _action_apply_label(db, msg, uuid_module.UUID(action_value))
elif action_type == "mark_read":
if not msg.is_read:
msg.is_read = True
return True
elif action_type == "mark_starred":
if not msg.is_starred:
msg.is_starred = True
return True
elif action_type == "notify_webhook" and action_value:
await _action_notify_webhook(msg, action_value)
return True
except Exception as e:
logger.warning(f"[routing_rules] Errore azione {action_type}: {e}")
return False
async def _action_apply_label(
db: AsyncSession, msg: Message, label_id: uuid_module.UUID
) -> bool:
"""Applica un'etichetta al messaggio se non gia' applicata."""
# Verifica che la label esista e appartenga al tenant
label_check = await db.execute(
text("SELECT id FROM labels WHERE id = :lid AND tenant_id = :tid"),
{"lid": str(label_id), "tid": str(msg.tenant_id)},
)
if not label_check.fetchone():
return False
# Inserisci con ON CONFLICT DO NOTHING per idempotenza
await db.execute(
text("""
INSERT INTO message_labels (message_id, label_id)
VALUES (:msg_id, :label_id)
ON CONFLICT DO NOTHING
"""),
{"msg_id": str(msg.id), "label_id": str(label_id)},
)
logger.debug(f"[routing_rules] Etichetta {label_id} applicata a {msg.id}")
return True
async def _action_notify_webhook(msg: Message, url: str) -> None:
"""Invia notifica webhook per il messaggio."""
import aiohttp
payload = {
"event": "routing_rule_match",
"message_id": str(msg.id),
"subject": msg.subject,
"from_address": msg.from_address,
"pec_type": msg.pec_type,
}
try:
async with aiohttp.ClientSession() as session:
await session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=5))
except Exception as e:
logger.warning(f"[routing_rules] Webhook {url} fallito: {e}")
+2 -1
View File
@@ -24,6 +24,7 @@ from arq.connections import RedisSettings
from app.config import get_settings
from app.imap.pool import MailboxPool
from app.jobs.apply_routing_rules import apply_routing_rules
from app.jobs.dispatch_notification import dispatch_notification
from app.jobs.send_pec import send_pec
from app.jobs.sync_mailbox import sync_mailbox
@@ -133,7 +134,7 @@ class WorkerSettings:
"""Configurazione del worker arq."""
# Funzioni/job registrati
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, health_check]
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
# Callbacks lifecycle
on_startup = on_startup