mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Implementazioni varie
This commit is contained in:
+1
-11
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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}>"
|
||||
@@ -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] = []
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,6 +223,14 @@ class SendService:
|
||||
# ── Enqueue job arq ───────────────────────────────────────────────────
|
||||
try:
|
||||
arq_pool = await _get_arq_pool()
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,12 +692,14 @@ export function MessageDetailPage() {
|
||||
Allegati ({attachments.length})
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{attachments.map((att) => (
|
||||
{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
|
||||
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"
|
||||
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" />
|
||||
@@ -538,9 +710,21 @@ export function MessageDetailPage() {
|
||||
{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" />
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user