Implementazioni varie

This commit is contained in:
2026-03-27 20:59:06 +01:00
parent 047990811f
commit 46784aca4c
40 changed files with 4090 additions and 34 deletions
@@ -0,0 +1,55 @@
"""
Migrazione 0010: tabella message_templates (Feature 1 Template messaggi).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"message_templates",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("subject", sa.Text, nullable=False, server_default=""),
sa.Column("body_text", sa.Text, nullable=True),
sa.Column("body_html", sa.Text, nullable=True),
sa.Column(
"created_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("tenant_id", "name", name="uq_template_name_tenant"),
)
op.create_index("idx_templates_tenant", "message_templates", ["tenant_id"])
def downgrade() -> None:
op.drop_index("idx_templates_tenant", table_name="message_templates")
op.drop_table("message_templates")
@@ -0,0 +1,110 @@
"""
Migrazione 0011: tabelle routing_rules, routing_rule_conditions, routing_rule_actions
(Feature 2 Regole di smistamento automatico).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Tabella principale regole
op.create_table(
"routing_rules",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("priority", sa.Integer, nullable=False, server_default="100"),
sa.Column("stop_processing", sa.Boolean, nullable=False, server_default="true"),
sa.Column(
"created_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("idx_routing_rules_tenant", "routing_rules", ["tenant_id"])
op.create_index(
"idx_routing_rules_active",
"routing_rules",
["tenant_id", "priority"],
postgresql_where=sa.text("is_active = true"),
)
# Condizioni delle regole
op.create_table(
"routing_rule_conditions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"rule_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
nullable=False,
),
# field: from_address | to_address | subject | mailbox_id | pec_type
sa.Column("field", sa.String(50), nullable=False),
# operator: contains | equals | starts_with | ends_with | regex | not_contains
sa.Column("operator", sa.String(30), nullable=False, server_default="contains"),
sa.Column("value", sa.Text, nullable=False),
)
op.create_index(
"idx_routing_conditions_rule",
"routing_rule_conditions",
["rule_id"],
)
# Azioni delle regole
op.create_table(
"routing_rule_actions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"rule_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
nullable=False,
),
# action_type: apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
sa.Column("action_type", sa.String(50), nullable=False),
# action_value: UUID di label/vbox, URL del webhook, ecc.
sa.Column("action_value", sa.Text, nullable=True),
)
op.create_index(
"idx_routing_actions_rule",
"routing_rule_actions",
["rule_id"],
)
def downgrade() -> None:
op.drop_index("idx_routing_actions_rule", table_name="routing_rule_actions")
op.drop_table("routing_rule_actions")
op.drop_index("idx_routing_conditions_rule", table_name="routing_rule_conditions")
op.drop_table("routing_rule_conditions")
op.drop_index("idx_routing_rules_active", table_name="routing_rules")
op.drop_index("idx_routing_rules_tenant", table_name="routing_rules")
op.drop_table("routing_rules")
@@ -0,0 +1,35 @@
"""
Migrazione 0012: campi deadline su tabella messages
(Feature 4 Scadenzario e tracking deadlines).
"""
from alembic import op
import sqlalchemy as sa
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"messages",
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"messages",
sa.Column("deadline_note", sa.Text, nullable=True),
)
op.create_index(
"idx_messages_deadline",
"messages",
["tenant_id", "deadline_at"],
postgresql_where=sa.text("deadline_at IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("idx_messages_deadline", table_name="messages")
op.drop_column("messages", "deadline_note")
op.drop_column("messages", "deadline_at")
@@ -0,0 +1,30 @@
"""
Migrazione 0013: campo scheduled_at su send_jobs
(Feature 5 Invio differito).
"""
from alembic import op
import sqlalchemy as sa
revision = "0013"
down_revision = "0012"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"send_jobs",
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"idx_sendjobs_scheduled",
"send_jobs",
["scheduled_at"],
postgresql_where=sa.text("status = 'pending' AND scheduled_at IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("idx_sendjobs_scheduled", table_name="send_jobs")
op.drop_column("send_jobs", "scheduled_at")
@@ -0,0 +1,60 @@
"""
Migrazione 0014: tabella pec_contacts (Feature 6 Rubrica indirizzi PEC).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"pec_contacts",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("organization", sa.String(255), nullable=True),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("is_favorite", sa.Boolean, nullable=False, server_default="false"),
# auto_saved=True: contatto creato automaticamente dal sistema durante la sync
# auto_saved=False: contatto aggiunto manualmente dall'utente
sa.Column("auto_saved", sa.Boolean, nullable=False, server_default="false"),
sa.Column(
"created_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("tenant_id", "email", name="uq_pec_contact_email_tenant"),
)
op.create_index("idx_pec_contacts_tenant", "pec_contacts", ["tenant_id"])
op.create_index("idx_pec_contacts_email", "pec_contacts", ["tenant_id", "email"])
def downgrade() -> None:
op.drop_index("idx_pec_contacts_email", table_name="pec_contacts")
op.drop_index("idx_pec_contacts_tenant", table_name="pec_contacts")
op.drop_table("pec_contacts")
+132
View File
@@ -0,0 +1,132 @@
"""
Router rubrica indirizzi PEC (Feature 6).
Endpoint:
GET /contacts lista contatti (con ricerca)
POST /contacts crea contatto manuale
GET /contacts/autocomplete autocomplete per compose
GET /contacts/{id} dettaglio contatto
PUT /contacts/{id} aggiorna contatto
DELETE /contacts/{id} elimina contatto
POST /contacts/import importa da CSV
"""
import uuid
from fastapi import APIRouter, Query, UploadFile, File, status
from app.dependencies import CurrentUser, DB
from app.schemas.pec_contact import (
PecContactCreate,
PecContactImportResult,
PecContactListResponse,
PecContactResponse,
PecContactUpdate,
)
from app.services.pec_contact_service import PecContactService
router = APIRouter(tags=["Contacts"])
@router.get("/contacts", response_model=PecContactListResponse)
async def list_contacts(
current_user: CurrentUser,
db: DB,
q: str | None = Query(None, description="Ricerca per email, nome o organizzazione"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> PecContactListResponse:
"""Elenca i contatti della rubrica PEC del tenant."""
svc = PecContactService(db)
items, total = await svc.list_contacts(
current_user.tenant_id, q=q, page=page, page_size=page_size
)
return PecContactListResponse(
items=[PecContactResponse.model_validate(c) for c in items],
total=total,
)
@router.get("/contacts/autocomplete", response_model=list[PecContactResponse])
async def autocomplete_contacts(
q: str,
current_user: CurrentUser,
db: DB,
limit: int = Query(10, ge=1, le=20),
) -> list[PecContactResponse]:
"""Ricerca rapida contatti per autocomplete nel compose (minimo 2 caratteri)."""
svc = PecContactService(db)
items = await svc.search_for_autocomplete(current_user.tenant_id, q=q, limit=limit)
return [PecContactResponse.model_validate(c) for c in items]
@router.post("/contacts", response_model=PecContactResponse, status_code=status.HTTP_201_CREATED)
async def create_contact(
data: PecContactCreate,
current_user: CurrentUser,
db: DB,
) -> PecContactResponse:
"""Aggiunge un contatto manualmente alla rubrica."""
svc = PecContactService(db)
contact = await svc.create_contact(
current_user.tenant_id, data, created_by=current_user.id
)
return PecContactResponse.model_validate(contact)
@router.get("/contacts/{contact_id}", response_model=PecContactResponse)
async def get_contact(
contact_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> PecContactResponse:
"""Restituisce il dettaglio di un contatto."""
svc = PecContactService(db)
contact = await svc.get_contact(current_user.tenant_id, contact_id)
return PecContactResponse.model_validate(contact)
@router.put("/contacts/{contact_id}", response_model=PecContactResponse)
async def update_contact(
contact_id: uuid.UUID,
data: PecContactUpdate,
current_user: CurrentUser,
db: DB,
) -> PecContactResponse:
"""Aggiorna un contatto della rubrica."""
svc = PecContactService(db)
contact = await svc.update_contact(current_user.tenant_id, contact_id, data)
return PecContactResponse.model_validate(contact)
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact(
contact_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> None:
"""Elimina un contatto dalla rubrica."""
svc = PecContactService(db)
await svc.delete_contact(current_user.tenant_id, contact_id)
@router.post("/contacts/import", response_model=PecContactImportResult)
async def import_contacts_csv(
file: UploadFile = File(..., description="File CSV con colonne: email, name, organization"),
current_user: CurrentUser = ..., # type: ignore
db: DB = ..., # type: ignore
) -> PecContactImportResult:
"""
Importa contatti dalla rubrica da file CSV.
Il CSV deve avere le intestazioni: email, name, organization
(solo email e' obbligatoria).
"""
content = await file.read()
try:
csv_text = content.decode("utf-8")
except UnicodeDecodeError:
csv_text = content.decode("latin-1")
svc = PecContactService(db)
return await svc.import_csv(current_user.tenant_id, csv_text, created_by=current_user.id)
+152
View File
@@ -0,0 +1,152 @@
"""
Router scadenzario e tracking deadlines (Feature 4).
Endpoint:
GET /deadlines messaggi con scadenze imminenti
POST /messages/{id}/deadline imposta/modifica/rimuove scadenza
"""
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from pydantic import BaseModel
from sqlalchemy import and_, select
from app.dependencies import CurrentUser, DB
from app.models.message import Message
from app.schemas.message import MessageResponse
from app.core.exceptions import NotFoundError
router = APIRouter(tags=["Deadlines"])
class DeadlineSetRequest(BaseModel):
deadline_at: datetime | None = None
"""Imposta a null per rimuovere la scadenza."""
deadline_note: str | None = None
class DeadlineMessageResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
subject: str | None
from_address: str | None
to_addresses: list[str] | None = None
direction: str
pec_type: str
state: str
mailbox_id: uuid.UUID
deadline_at: datetime | None = None
deadline_note: str | None = None
is_overdue: bool = False
received_at: datetime | None = None
sent_at: datetime | None = None
created_at: datetime
@router.get("/deadlines", response_model=list[DeadlineMessageResponse])
async def list_deadlines(
current_user: CurrentUser,
db: DB,
days_ahead: int = Query(30, ge=1, le=365, description="Giorni da considerare in avanti"),
include_overdue: bool = Query(True, description="Includi scadenze gia' passate"),
) -> list[DeadlineMessageResponse]:
"""
Restituisce i messaggi con scadenze nel range specificato.
Ordinati per: scaduti prima, poi per deadline_at ASC.
"""
now = datetime.now(timezone.utc)
future_limit = now + timedelta(days=days_ahead)
conditions = [
Message.tenant_id == current_user.tenant_id,
Message.deadline_at.is_not(None),
Message.is_trashed == False, # noqa: E712
]
if include_overdue:
# Include scaduti e futuri fino al limite
conditions.append(Message.deadline_at <= future_limit)
else:
# Solo scadenze future
conditions.append(and_(Message.deadline_at > now, Message.deadline_at <= future_limit))
result = await db.execute(
select(Message)
.where(and_(*conditions))
.order_by(Message.deadline_at)
.limit(200)
)
messages = list(result.scalars().all())
items = []
for msg in messages:
is_overdue = msg.deadline_at < now if msg.deadline_at else False
items.append(DeadlineMessageResponse(
id=msg.id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
direction=msg.direction,
pec_type=msg.pec_type,
state=msg.state,
mailbox_id=msg.mailbox_id,
deadline_at=msg.deadline_at,
deadline_note=msg.deadline_note,
is_overdue=is_overdue,
received_at=msg.received_at,
sent_at=msg.sent_at,
created_at=msg.created_at,
))
return items
@router.post("/messages/{message_id}/deadline", response_model=DeadlineMessageResponse)
async def set_deadline(
message_id: uuid.UUID,
data: DeadlineSetRequest,
current_user: CurrentUser,
db: DB,
) -> DeadlineMessageResponse:
"""
Imposta, modifica o rimuove la scadenza di un messaggio.
Passa deadline_at=null per rimuovere la scadenza.
"""
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
)
msg = result.scalar_one_or_none()
if not msg:
raise NotFoundError(f"Messaggio {message_id} non trovato")
msg.deadline_at = data.deadline_at
msg.deadline_note = data.deadline_note
await db.commit()
await db.refresh(msg)
now = datetime.now(timezone.utc)
is_overdue = msg.deadline_at < now if msg.deadline_at else False
return DeadlineMessageResponse(
id=msg.id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
direction=msg.direction,
pec_type=msg.pec_type,
state=msg.state,
mailbox_id=msg.mailbox_id,
deadline_at=msg.deadline_at,
deadline_note=msg.deadline_note,
is_overdue=is_overdue,
received_at=msg.received_at,
sent_at=msg.sent_at,
created_at=msg.created_at,
)
+281
View File
@@ -709,3 +709,284 @@ async def list_receipts(
)
receipts = list(result.scalars().all())
return [MessageResponse.model_validate(r) for r in receipts]
# ─── Feature 3: Thread/conversazioni ─────────────────────────────────────────
@router.get("/{message_id}/thread", response_model=list[MessageResponse])
async def get_thread(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[MessageResponse]:
"""
Restituisce l'intera conversazione (thread) di cui fa parte il messaggio.
Risale alla radice della conversazione (risalendo i parent_message_id),
poi carica tutti i messaggi del thread ordinati cronologicamente.
Esclude le ricevute PEC (pec_type != posta_certificata).
"""
message = await _resolve_message(message_id, current_user, db)
# Risale alla radice del thread
root_id = message.id
visited: set[uuid.UUID] = {message.id}
current = message
while current.parent_message_id and current.parent_message_id not in visited:
visited.add(current.parent_message_id)
parent_result = await db.execute(
select(Message).where(
Message.id == current.parent_message_id,
Message.tenant_id == current_user.tenant_id,
)
)
parent = parent_result.scalar_one_or_none()
if not parent:
break
current = parent
root_id = current.id
# Carica ricorsivamente tutti i messaggi del thread dalla radice
# Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
thread_messages: list[Message] = []
async def _collect(msg_id: uuid.UUID) -> None:
result = await db.execute(
select(Message)
.where(
Message.id == msg_id,
Message.tenant_id == current_user.tenant_id,
Message.pec_type == "posta_certificata",
)
.options(selectinload(Message.labels))
)
msg = result.scalar_one_or_none()
if msg:
thread_messages.append(msg)
children_result = await db.execute(
select(Message)
.where(
Message.parent_message_id == msg_id,
Message.tenant_id == current_user.tenant_id,
Message.pec_type == "posta_certificata",
)
.options(selectinload(Message.labels))
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
children = list(children_result.scalars().all())
for child in children:
await _collect(child.id)
await _collect(root_id)
# Ordina cronologicamente
thread_messages.sort(
key=lambda m: m.received_at or m.sent_at or m.created_at
)
return [MessageResponse.model_validate(m) for m in thread_messages]
# ─── Feature 7: Preview allegati (presigned URL) ──────────────────────────────
@router.get("/{message_id}/attachments/{attachment_id}/preview-url")
async def get_attachment_preview_url(
message_id: uuid.UUID,
attachment_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> dict:
"""
Restituisce una presigned URL MinIO per la preview inline dell'allegato.
La URL e' valida per 5 minuti. Supporta PDF e immagini.
Per altri tipi di file reindirizza al download normale.
"""
await _resolve_message(message_id, current_user, db)
result = await db.execute(
select(Attachment).where(
Attachment.id == attachment_id,
Attachment.message_id == message_id,
)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato")
content_type = attachment.content_type or "application/octet-stream"
previewable = (
content_type.startswith("image/") or
content_type == "application/pdf"
)
if not previewable:
return {
"previewable": False,
"content_type": content_type,
"filename": attachment.filename,
}
try:
from datetime import timedelta as _timedelta
from miniopy_async import Minio
client = Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
presigned_url = await client.presigned_get_object(
settings.minio_bucket,
attachment.storage_path,
expires=_timedelta(minutes=5),
)
return {
"previewable": True,
"content_type": content_type,
"filename": attachment.filename,
"url": presigned_url,
}
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Errore generazione presigned URL allegato {attachment_id}: {e}")
return {
"previewable": False,
"content_type": content_type,
"filename": attachment.filename,
}
# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
@router.get("/{message_id}/print")
async def print_message(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> "HTMLResponse":
"""
Restituisce una rappresentazione HTML ottimizzata per la stampa del messaggio.
Include: intestazione, corpo, lista allegati, albero ricevute.
Pronto per window.print() o salvataggio come PDF tramite browser.
"""
from fastapi.responses import HTMLResponse
message = await _resolve_message(message_id, current_user, db)
att_result = await db.execute(
select(Attachment).where(Attachment.message_id == message.id).order_by(Attachment.created_at)
)
attachments = list(att_result.scalars().all())
receipts_html = ""
if message.direction == "outbound":
rec_result = await db.execute(
select(Message)
.where(Message.parent_message_id == message.id)
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
receipts = list(rec_result.scalars().all())
PEC_TYPE_LABELS = {
"accettazione": "Accettazione",
"avvenuta_consegna": "Avvenuta consegna",
"non_accettazione": "Non accettazione",
"mancata_consegna": "Mancata consegna",
"errore_consegna": "Errore consegna",
"presa_in_carico": "Presa in carico",
"preavviso_mancata_consegna": "Preavviso mancata consegna",
"rilevazione_virus": "Rilevazione virus",
}
receipt_rows = ""
for r in receipts:
label = PEC_TYPE_LABELS.get(r.pec_type, r.pec_type)
date_str = ""
if r.received_at:
date_str = r.received_at.strftime("%d/%m/%Y %H:%M:%S")
receipt_rows += f"<tr><td>{label}</td><td>{date_str}</td></tr>"
if receipt_rows:
receipts_html = f"""
<section>
<h3>Tracciamento invio</h3>
<table>
<thead><tr><th>Tipo ricevuta</th><th>Data</th></tr></thead>
<tbody>{receipt_rows}</tbody>
</table>
</section>"""
att_rows = ""
for att in attachments:
size_str = f"{att.size_bytes:,} byte" if att.size_bytes else ""
att_rows += f"<li>{att.filename} ({att.content_type or ''}) {size_str}</li>"
att_html = f"<section><h3>Allegati ({len(attachments)})</h3><ul>{att_rows}</ul></section>" if attachments else ""
from_label = "Da" if message.direction == "inbound" else "A"
from_val = message.from_address if message.direction == "inbound" else ", ".join(message.to_addresses or [])
date_val = ""
date_field = message.received_at or message.sent_at or message.created_at
if date_field:
date_val = date_field.strftime("%d/%m/%Y %H:%M:%S")
body_html = ""
if message.body_html:
body_html = f"<div class='body'>{message.body_html}</div>"
elif message.body_text:
body_html = f"<pre class='body'>{message.body_text}</pre>"
deadline_html = ""
if message.deadline_at:
dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M")
deadline_html = f"<p class='deadline'><strong>Scadenza:</strong> {dl_str}</p>"
if message.deadline_note:
deadline_html += f"<p><em>Nota scadenza: {message.deadline_note}</em></p>"
html = f"""<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>PEC - {message.subject or '(nessun oggetto)'}</title>
<style>
body {{ font-family: Arial, sans-serif; font-size: 12pt; margin: 2cm; color: #000; }}
h1 {{ font-size: 16pt; border-bottom: 2px solid #333; padding-bottom: 8px; }}
h3 {{ font-size: 13pt; margin-top: 20px; border-bottom: 1px solid #ccc; }}
.meta {{ background: #f5f5f5; border: 1px solid #ddd; padding: 12px; margin-bottom: 16px; }}
.meta p {{ margin: 4px 0; }}
.body {{ border: 1px solid #ddd; padding: 12px; white-space: pre-wrap; word-wrap: break-word; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #eee; }}
ul {{ padding-left: 20px; }}
.deadline {{ color: #c00; font-weight: bold; }}
@media print {{ body {{ margin: 1cm; }} }}
</style>
</head>
<body>
<h1>{message.subject or '(nessun oggetto)'}</h1>
<div class="meta">
<p><strong>{from_label}:</strong> {from_val}</p>
{'<p><strong>Da:</strong> ' + message.from_address + '</p>' if message.direction == "outbound" and message.from_address else ''}
{'<p><strong>A:</strong> ' + ', '.join(message.to_addresses or []) + '</p>' if message.direction == "inbound" and message.to_addresses else ''}
{'<p><strong>Cc:</strong> ' + ', '.join(message.cc_addresses or []) + '</p>' if message.cc_addresses else ''}
<p><strong>Data:</strong> {date_val}</p>
<p><strong>Stato:</strong> {message.state}</p>
<p><strong>Tipo:</strong> {message.pec_type}</p>
</div>
{deadline_html}
{body_html}
{att_html}
{receipts_html}
<p style="margin-top: 30px; font-size: 9pt; color: #888;">
Documento generato da PEChub il {date_val} ID messaggio: {message.id}
</p>
</body>
</html>"""
return HTMLResponse(content=html, media_type="text/html; charset=utf-8")
+106
View File
@@ -0,0 +1,106 @@
"""
Router regole di smistamento automatico (Feature 2).
Endpoint:
GET /routing-rules lista regole del tenant
POST /routing-rules crea regola (admin)
GET /routing-rules/{id} dettaglio regola
PUT /routing-rules/{id} aggiorna regola (admin)
DELETE /routing-rules/{id} elimina regola (admin)
POST /routing-rules/{id}/toggle abilita/disabilita regola (admin)
"""
import uuid
from fastapi import APIRouter, status
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.routing_rule import (
RoutingRuleCreate,
RoutingRuleListResponse,
RoutingRuleResponse,
RoutingRuleUpdate,
)
from app.services.routing_rule_service import RoutingRuleService
router = APIRouter(tags=["Routing Rules"])
@router.get("/routing-rules", response_model=RoutingRuleListResponse)
async def list_rules(
current_user: CurrentUser,
db: DB,
) -> RoutingRuleListResponse:
"""Elenca le regole di smistamento del tenant."""
svc = RoutingRuleService(db)
items, total = await svc.list_rules(current_user.tenant_id)
return RoutingRuleListResponse(
items=[RoutingRuleResponse.model_validate(r) for r in items],
total=total,
)
@router.post("/routing-rules", response_model=RoutingRuleResponse, status_code=status.HTTP_201_CREATED)
async def create_rule(
data: RoutingRuleCreate,
current_user: AdminUser,
db: DB,
) -> RoutingRuleResponse:
"""Crea una nuova regola di smistamento (solo admin)."""
svc = RoutingRuleService(db)
rule = await svc.create_rule(current_user.tenant_id, data, created_by=current_user.id)
return RoutingRuleResponse.model_validate(rule)
@router.get("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
async def get_rule(
rule_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> RoutingRuleResponse:
"""Restituisce il dettaglio di una regola."""
svc = RoutingRuleService(db)
rule = await svc.get_rule(current_user.tenant_id, rule_id)
return RoutingRuleResponse.model_validate(rule)
@router.put("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
async def update_rule(
rule_id: uuid.UUID,
data: RoutingRuleUpdate,
current_user: AdminUser,
db: DB,
) -> RoutingRuleResponse:
"""Aggiorna una regola di smistamento (solo admin)."""
svc = RoutingRuleService(db)
rule = await svc.update_rule(current_user.tenant_id, rule_id, data)
return RoutingRuleResponse.model_validate(rule)
@router.delete("/routing-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina una regola di smistamento (solo admin)."""
svc = RoutingRuleService(db)
await svc.delete_rule(current_user.tenant_id, rule_id)
@router.post("/routing-rules/{rule_id}/toggle", response_model=RoutingRuleResponse)
async def toggle_rule(
rule_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> RoutingRuleResponse:
"""Abilita o disabilita una regola di smistamento (admin)."""
svc = RoutingRuleService(db)
rule = await svc.get_rule(current_user.tenant_id, rule_id)
from app.schemas.routing_rule import RoutingRuleUpdate
updated = await svc.update_rule(
current_user.tenant_id,
rule_id,
RoutingRuleUpdate(is_active=not rule.is_active),
)
return RoutingRuleResponse.model_validate(updated)
+83
View File
@@ -0,0 +1,83 @@
"""
Router template messaggi (Feature 1).
Endpoint:
GET /templates lista template del tenant
POST /templates crea template (admin)
GET /templates/{id} dettaglio template
PUT /templates/{id} aggiorna template (admin)
DELETE /templates/{id} elimina template (admin)
"""
import uuid
from fastapi import APIRouter, Query, status
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.template import TemplateCreate, TemplateListResponse, TemplateResponse, TemplateUpdate
from app.services.template_service import TemplateService
router = APIRouter(tags=["Templates"])
@router.get("/templates", response_model=TemplateListResponse)
async def list_templates(
current_user: CurrentUser,
db: DB,
q: str | None = Query(None, description="Filtro per nome"),
) -> TemplateListResponse:
"""Elenca i template del tenant corrente."""
svc = TemplateService(db)
items, total = await svc.list_templates(current_user.tenant_id, q=q)
return TemplateListResponse(
items=[TemplateResponse.model_validate(t) for t in items],
total=total,
)
@router.post("/templates", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
async def create_template(
data: TemplateCreate,
current_user: AdminUser,
db: DB,
) -> TemplateResponse:
"""Crea un nuovo template (solo admin)."""
svc = TemplateService(db)
template = await svc.create_template(current_user.tenant_id, data, created_by=current_user.id)
return TemplateResponse.model_validate(template)
@router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template(
template_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> TemplateResponse:
"""Restituisce il dettaglio di un template."""
svc = TemplateService(db)
template = await svc.get_template(current_user.tenant_id, template_id)
return TemplateResponse.model_validate(template)
@router.put("/templates/{template_id}", response_model=TemplateResponse)
async def update_template(
template_id: uuid.UUID,
data: TemplateUpdate,
current_user: AdminUser,
db: DB,
) -> TemplateResponse:
"""Aggiorna un template esistente (solo admin)."""
svc = TemplateService(db)
template = await svc.update_template(current_user.tenant_id, template_id, data)
return TemplateResponse.model_validate(template)
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina un template (solo admin)."""
svc = TemplateService(db)
await svc.delete_template(current_user.tenant_id, template_id)
+5 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import audit_log, auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, tenants, templates, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
@@ -98,6 +98,10 @@ app.include_router(labels.router, prefix=API_PREFIX)
app.include_router(settings_router.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(audit_log.router, prefix=API_PREFIX)
app.include_router(templates.router, prefix=API_PREFIX)
app.include_router(routing_rules.router, prefix=API_PREFIX)
app.include_router(contacts.router, prefix=API_PREFIX)
app.include_router(deadlines.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
+3
View File
@@ -10,3 +10,6 @@ from app.models.permission import MailboxPermission # noqa: F401
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
from app.models.tenant_settings import TenantSettings # noqa: F401
from app.models.template import MessageTemplate # noqa: F401
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
from app.models.pec_contact import PecContact # noqa: F401
+6
View File
@@ -95,6 +95,10 @@ class Message(Base):
is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Scadenzario (Feature 4)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deadline_note: Mapped[str | None] = mapped_column(Text, nullable=True)
# Conservazione
is_pending_conservation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
pending_conservation_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -194,6 +198,8 @@ class SendJob(Base):
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Invio differito (Feature 5): se impostato, il job non viene processato prima di questa data
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+49
View File
@@ -0,0 +1,49 @@
"""
Modello PecContact rubrica indirizzi PEC del tenant.
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class PecContact(Base):
__tablename__ = "pec_contacts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
organization: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# auto_saved=True → salvato automaticamente dalla sincronizzazione IMAP
# auto_saved=False → aggiunto manualmente dall'utente
auto_saved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_pec_contact_email_tenant"),
Index("idx_pec_contacts_tenant", "tenant_id"),
Index("idx_pec_contacts_email", "tenant_id", "email"),
)
def __repr__(self) -> str:
return f"<PecContact {self.email!r}>"
+114
View File
@@ -0,0 +1,114 @@
"""
Modelli RoutingRule, RoutingRuleCondition, RoutingRuleAction.
Le regole di smistamento automatico vengono valutate a ogni messaggio in arrivo:
1. Si caricano tutte le regole attive del tenant, ordinate per priority ASC
2. Per ogni regola si valutano le condizioni (AND tra le condizioni della stessa regola)
3. Se tutte le condizioni sono soddisfatte, si eseguono le azioni
4. Se stop_processing=True, si ferma l'elaborazione (non vengono valutate regole successive)
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class RoutingRule(Base):
__tablename__ = "routing_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
# Se True, le regole successive non vengono valutate una volta che questa fa match
stop_processing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
conditions: Mapped[list["RoutingRuleCondition"]] = relationship(
"RoutingRuleCondition", back_populates="rule", cascade="all, delete-orphan"
)
actions: Mapped[list["RoutingRuleAction"]] = relationship(
"RoutingRuleAction", back_populates="rule", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_routing_rules_tenant", "tenant_id"),
Index("idx_routing_rules_active", "tenant_id", "priority"),
)
def __repr__(self) -> str:
return f"<RoutingRule {self.name!r} priority={self.priority}>"
class RoutingRuleCondition(Base):
"""
Singola condizione di una regola.
field : from_address | to_address | subject | mailbox_id | pec_type
operator : contains | equals | starts_with | ends_with | regex | not_contains
value : valore da confrontare (stringa)
"""
__tablename__ = "routing_rule_conditions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
rule_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
)
field: Mapped[str] = mapped_column(String(50), nullable=False)
operator: Mapped[str] = mapped_column(String(30), nullable=False, default="contains")
value: Mapped[str] = mapped_column(Text, nullable=False)
rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="conditions")
__table_args__ = (
Index("idx_routing_conditions_rule", "rule_id"),
)
class RoutingRuleAction(Base):
"""
Singola azione di una regola.
action_type : apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
action_value : UUID della label/vbox, o URL del webhook
"""
__tablename__ = "routing_rule_actions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
rule_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
)
action_type: Mapped[str] = mapped_column(String(50), nullable=False)
action_value: Mapped[str | None] = mapped_column(Text, nullable=True)
rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="actions")
__table_args__ = (
Index("idx_routing_actions_rule", "rule_id"),
)
+45
View File
@@ -0,0 +1,45 @@
"""
Modello MessageTemplate template riutilizzabili per la composizione PEC.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MessageTemplate(Base):
__tablename__ = "message_templates"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
subject: Mapped[str] = mapped_column(Text, nullable=False, default="")
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_template_name_tenant"),
Index("idx_templates_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<MessageTemplate {self.name!r}>"
+56
View File
@@ -0,0 +1,56 @@
"""
Schemi Pydantic per PecContact (Feature 6 Rubrica indirizzi PEC).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
class PecContactCreate(BaseModel):
email: EmailStr
name: str | None = None
organization: str | None = None
notes: str | None = None
is_favorite: bool = False
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
return v.lower().strip()
class PecContactUpdate(BaseModel):
name: str | None = None
organization: str | None = None
notes: str | None = None
is_favorite: bool | None = None
class PecContactResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
email: str
name: str | None = None
organization: str | None = None
notes: str | None = None
is_favorite: bool
auto_saved: bool
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
class PecContactListResponse(BaseModel):
items: list[PecContactResponse]
total: int
class PecContactImportResult(BaseModel):
created: int
updated: int
skipped: int
errors: list[str] = []
+113
View File
@@ -0,0 +1,113 @@
"""
Schemi Pydantic per RoutingRule (Feature 2 Regole di smistamento automatico).
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, field_validator
# Valori validi per field nelle condizioni
CONDITION_FIELDS = Literal[
"from_address", "to_address", "subject", "mailbox_id", "pec_type"
]
# Operatori supportati
CONDITION_OPERATORS = Literal[
"contains", "equals", "starts_with", "ends_with", "regex", "not_contains"
]
# Tipi di azione
ACTION_TYPES = Literal[
"apply_label", "assign_vbox", "mark_read", "mark_starred", "notify_webhook"
]
class RoutingRuleConditionCreate(BaseModel):
field: CONDITION_FIELDS
operator: CONDITION_OPERATORS = "contains"
value: str
@field_validator("value")
@classmethod
def value_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il valore della condizione non puo' essere vuoto")
return v.strip()
class RoutingRuleActionCreate(BaseModel):
action_type: ACTION_TYPES
action_value: str | None = None
class RoutingRuleCreate(BaseModel):
name: str
description: str | None = None
is_active: bool = True
priority: int = 100
stop_processing: bool = True
conditions: list[RoutingRuleConditionCreate] = []
actions: list[RoutingRuleActionCreate] = []
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il nome della regola non puo' essere vuoto")
return v.strip()
@field_validator("priority")
@classmethod
def priority_positive(cls, v: int) -> int:
if v < 1:
raise ValueError("La priorita' deve essere >= 1")
return v
class RoutingRuleUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_active: bool | None = None
priority: int | None = None
stop_processing: bool | None = None
conditions: list[RoutingRuleConditionCreate] | None = None
actions: list[RoutingRuleActionCreate] | None = None
class RoutingRuleConditionResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
field: str
operator: str
value: str
class RoutingRuleActionResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
action_type: str
action_value: str | None = None
class RoutingRuleResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None = None
is_active: bool
priority: int
stop_processing: bool
conditions: list[RoutingRuleConditionResponse] = []
actions: list[RoutingRuleActionResponse] = []
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
class RoutingRuleListResponse(BaseModel):
items: list[RoutingRuleResponse]
total: int
+4
View File
@@ -37,6 +37,9 @@ class SendPecRequest(BaseModel):
reply_to_message_id: uuid.UUID | None = None
"""UUID del messaggio a cui si risponde (per threading, opzionale)."""
scheduled_at: datetime | None = None
"""Data/ora di invio differito (Feature 5). Se None, invio immediato."""
@field_validator("to_addresses")
@classmethod
def at_least_one_recipient(cls, v: list[EmailStr]) -> list[EmailStr]:
@@ -67,6 +70,7 @@ class SendJobResponse(BaseModel):
max_attempts: int
next_retry_at: datetime | None = None
last_error: str | None = None
scheduled_at: datetime | None = None
queued_at: datetime
sent_at: datetime | None = None
created_by: uuid.UUID | None = None
+51
View File
@@ -0,0 +1,51 @@
"""
Schemi Pydantic per MessageTemplate (Feature 1 Template messaggi).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, field_validator
class TemplateCreate(BaseModel):
name: str
description: str | None = None
subject: str = ""
body_text: str | None = None
body_html: str | None = None
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il nome del template non puo' essere vuoto")
return v.strip()
class TemplateUpdate(BaseModel):
name: str | None = None
description: str | None = None
subject: str | None = None
body_text: str | None = None
body_html: str | None = None
class TemplateResponse(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None = None
subject: str
body_text: str | None = None
body_html: str | None = None
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
class TemplateListResponse(BaseModel):
items: list[TemplateResponse]
total: int
+240
View File
@@ -0,0 +1,240 @@
"""
Service per la gestione della rubrica indirizzi PEC (Feature 6).
"""
import csv
import io
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.pec_contact import PecContact
from app.schemas.pec_contact import (
PecContactCreate,
PecContactImportResult,
PecContactUpdate,
)
class PecContactService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_contacts(
self,
tenant_id: uuid.UUID,
q: str | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[PecContact], int]:
query = select(PecContact).where(PecContact.tenant_id == tenant_id)
if q:
pattern = f"%{q.lower()}%"
query = query.where(
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
PecContact.organization.ilike(pattern),
)
)
query = query.order_by(PecContact.is_favorite.desc(), PecContact.name, PecContact.email)
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> PecContact:
result = await self.db.execute(
select(PecContact).where(
PecContact.id == contact_id,
PecContact.tenant_id == tenant_id,
)
)
contact = result.scalar_one_or_none()
if not contact:
raise NotFoundError(f"Contatto {contact_id} non trovato")
return contact
async def create_contact(
self,
tenant_id: uuid.UUID,
data: PecContactCreate,
created_by: uuid.UUID | None = None,
) -> PecContact:
email = data.email.lower().strip()
# Verifica unicita'
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Contatto '{email}' gia' esistente")
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=data.name,
organization=data.organization,
notes=data.notes,
is_favorite=data.is_favorite,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
await self.db.commit()
await self.db.refresh(contact)
return contact
async def update_contact(
self,
tenant_id: uuid.UUID,
contact_id: uuid.UUID,
data: PecContactUpdate,
) -> PecContact:
contact = await self.get_contact(tenant_id, contact_id)
if data.name is not None:
contact.name = data.name
if data.organization is not None:
contact.organization = data.organization
if data.notes is not None:
contact.notes = data.notes
if data.is_favorite is not None:
contact.is_favorite = data.is_favorite
await self.db.commit()
await self.db.refresh(contact)
return contact
async def delete_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> None:
contact = await self.get_contact(tenant_id, contact_id)
await self.db.delete(contact)
await self.db.commit()
async def auto_save_sender(
self,
tenant_id: uuid.UUID,
email: str,
) -> None:
"""
Salva automaticamente il mittente nella rubrica se non esiste ancora.
Operazione non bloccante: gli errori vengono ignorati silenziosamente.
Usata dal worker IMAP durante la sincronizzazione dei messaggi inbound.
"""
if not email:
return
email = email.lower().strip()
try:
# Upsert: inserisce solo se non esiste
stmt = insert(PecContact).values(
id=uuid.uuid4(),
tenant_id=tenant_id,
email=email,
auto_saved=True,
).on_conflict_do_nothing(
constraint="uq_pec_contact_email_tenant"
)
await self.db.execute(stmt)
await self.db.commit()
except Exception:
await self.db.rollback()
async def import_csv(
self,
tenant_id: uuid.UUID,
csv_content: str,
created_by: uuid.UUID | None = None,
) -> PecContactImportResult:
"""
Importa contatti da un CSV con colonne: email, name, organization.
Aggiorna i record esistenti con name/organization se forniti.
"""
result = PecContactImportResult(created=0, updated=0, skipped=0)
reader = csv.DictReader(io.StringIO(csv_content))
for row_num, row in enumerate(reader, start=2):
email = row.get("email", "").strip().lower()
if not email or "@" not in email:
result.skipped += 1
result.errors.append(f"Riga {row_num}: email non valida '{email}'")
continue
name = row.get("name", "").strip() or None
organization = row.get("organization", "").strip() or None
try:
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
contact = existing.scalar_one_or_none()
if contact:
# Aggiorna solo se i campi erano vuoti (non sovrascrive dati manuali)
updated = False
if name and not contact.name:
contact.name = name
updated = True
if organization and not contact.organization:
contact.organization = organization
updated = True
if updated:
result.updated += 1
else:
result.skipped += 1
else:
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=name,
organization=organization,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
result.created += 1
except Exception as e:
result.errors.append(f"Riga {row_num} ({email}): {e}")
result.skipped += 1
await self.db.commit()
return result
async def search_for_autocomplete(
self,
tenant_id: uuid.UUID,
q: str,
limit: int = 10,
) -> list[PecContact]:
"""Ricerca veloce per autocomplete nel compose."""
if not q or len(q) < 2:
return []
pattern = f"%{q.lower()}%"
result = await self.db.execute(
select(PecContact)
.where(
PecContact.tenant_id == tenant_id,
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
)
)
.order_by(PecContact.is_favorite.desc(), PecContact.email)
.limit(limit)
)
return list(result.scalars().all())
@@ -0,0 +1,296 @@
"""
Service per la gestione delle regole di smistamento automatico (Feature 2).
Il metodo evaluate_rules() viene chiamato dal worker dopo ogni messaggio inbound.
"""
import re
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.exceptions import NotFoundError
from app.models.label import Label, MessageLabel
from app.models.message import Message
from app.models.routing_rule import RoutingRule, RoutingRuleAction, RoutingRuleCondition
from app.schemas.routing_rule import RoutingRuleCreate, RoutingRuleUpdate
class RoutingRuleService:
def __init__(self, db: AsyncSession):
self.db = db
# ─── CRUD ─────────────────────────────────────────────────────────────────
async def list_rules(
self,
tenant_id: uuid.UUID,
) -> tuple[list[RoutingRule], int]:
query = (
select(RoutingRule)
.where(RoutingRule.tenant_id == tenant_id)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
.order_by(RoutingRule.priority)
)
count_q = select(func.count()).select_from(
select(RoutingRule).where(RoutingRule.tenant_id == tenant_id).subquery()
)
total = (await self.db.execute(count_q)).scalar_one()
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_rule(
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
) -> RoutingRule:
result = await self.db.execute(
select(RoutingRule)
.where(RoutingRule.id == rule_id, RoutingRule.tenant_id == tenant_id)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
)
rule = result.scalar_one_or_none()
if not rule:
raise NotFoundError(f"Regola {rule_id} non trovata")
return rule
async def create_rule(
self,
tenant_id: uuid.UUID,
data: RoutingRuleCreate,
created_by: uuid.UUID | None = None,
) -> RoutingRule:
rule = RoutingRule(
tenant_id=tenant_id,
name=data.name,
description=data.description,
is_active=data.is_active,
priority=data.priority,
stop_processing=data.stop_processing,
created_by=created_by,
)
self.db.add(rule)
await self.db.flush()
for cond in data.conditions:
self.db.add(RoutingRuleCondition(
rule_id=rule.id,
field=cond.field,
operator=cond.operator,
value=cond.value,
))
for action in data.actions:
self.db.add(RoutingRuleAction(
rule_id=rule.id,
action_type=action.action_type,
action_value=action.action_value,
))
await self.db.commit()
return await self.get_rule(tenant_id, rule.id)
async def update_rule(
self,
tenant_id: uuid.UUID,
rule_id: uuid.UUID,
data: RoutingRuleUpdate,
) -> RoutingRule:
rule = await self.get_rule(tenant_id, rule_id)
if data.name is not None:
rule.name = data.name
if data.description is not None:
rule.description = data.description
if data.is_active is not None:
rule.is_active = data.is_active
if data.priority is not None:
rule.priority = data.priority
if data.stop_processing is not None:
rule.stop_processing = data.stop_processing
# Se condizioni o azioni vengono aggiornate, le sostituisce completamente
if data.conditions is not None:
await self.db.execute(
delete(RoutingRuleCondition).where(RoutingRuleCondition.rule_id == rule_id)
)
for cond in data.conditions:
self.db.add(RoutingRuleCondition(
rule_id=rule_id,
field=cond.field,
operator=cond.operator,
value=cond.value,
))
if data.actions is not None:
await self.db.execute(
delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule_id)
)
for action in data.actions:
self.db.add(RoutingRuleAction(
rule_id=rule_id,
action_type=action.action_type,
action_value=action.action_value,
))
await self.db.commit()
return await self.get_rule(tenant_id, rule_id)
async def delete_rule(
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
) -> None:
rule = await self.get_rule(tenant_id, rule_id)
await self.db.delete(rule)
await self.db.commit()
# ─── Motore di valutazione ────────────────────────────────────────────────
async def evaluate_and_apply(
self,
message: Message,
) -> int:
"""
Valuta le regole attive del tenant e applica le azioni su message.
Returns:
Numero di regole che hanno prodotto match.
"""
# Carica regole attive ordinate per priority
result = await self.db.execute(
select(RoutingRule)
.where(
RoutingRule.tenant_id == message.tenant_id,
RoutingRule.is_active == True, # noqa: E712
)
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
.order_by(RoutingRule.priority)
)
rules = list(result.scalars().all())
matched_count = 0
for rule in rules:
if await self._matches(message, rule.conditions):
matched_count += 1
await self._apply_actions(message, rule.actions)
if rule.stop_processing:
break
if matched_count > 0:
await self.db.flush()
return matched_count
async def _matches(
self,
message: Message,
conditions: list[RoutingRuleCondition],
) -> bool:
"""Restituisce True se tutte le condizioni (AND) sono soddisfatte."""
if not conditions:
# Una regola senza condizioni non fa mai match (comportamento sicuro)
return False
for cond in conditions:
field_value = self._get_field_value(message, cond.field)
if not self._evaluate_condition(field_value, cond.operator, cond.value):
return False
return True
def _get_field_value(self, message: Message, field: str) -> str:
"""Estrae il valore del campo dal messaggio come stringa per il confronto."""
if field == "from_address":
return (message.from_address or "").lower()
elif field == "to_address":
return " ".join(message.to_addresses or []).lower()
elif field == "subject":
return (message.subject or "").lower()
elif field == "mailbox_id":
return str(message.mailbox_id)
elif field == "pec_type":
return message.pec_type or ""
return ""
def _evaluate_condition(
self, field_value: str, operator: str, value: str
) -> bool:
v = value.lower()
fv = field_value.lower()
if operator == "contains":
return v in fv
elif operator == "not_contains":
return v not in fv
elif operator == "equals":
return fv == v
elif operator == "starts_with":
return fv.startswith(v)
elif operator == "ends_with":
return fv.endswith(v)
elif operator == "regex":
try:
return bool(re.search(value, field_value, re.IGNORECASE))
except re.error:
return False
return False
async def _apply_actions(
self,
message: Message,
actions: list[RoutingRuleAction],
) -> None:
"""Esegue le azioni sulla regola che ha fatto match."""
for action in actions:
try:
if action.action_type == "apply_label" and action.action_value:
await self._action_apply_label(message, uuid.UUID(action.action_value))
elif action.action_type == "mark_read":
message.is_read = True
elif action.action_type == "mark_starred":
message.is_starred = True
elif action.action_type == "notify_webhook" and action.action_value:
await self._action_notify_webhook(message, action.action_value)
except Exception:
# Le azioni non devono interrompere il flusso principale
pass
async def _action_apply_label(
self, message: Message, label_id: uuid.UUID
) -> None:
"""Applica un'etichetta al messaggio (se non gia' presente)."""
# Verifica che la label appartenga al tenant
label = await self.db.execute(
select(Label).where(
Label.id == label_id,
Label.tenant_id == message.tenant_id,
)
)
if not label.scalar_one_or_none():
return
# Verifica che non sia gia' applicata
existing = await self.db.execute(
select(MessageLabel).where(
MessageLabel.message_id == message.id,
MessageLabel.label_id == label_id,
)
)
if not existing.scalar_one_or_none():
self.db.add(MessageLabel(message_id=message.id, label_id=label_id))
async def _action_notify_webhook(self, message: Message, url: str) -> None:
"""Invia una notifica webhook per il messaggio."""
import aiohttp
import json
payload = {
"event": "routing_rule_match",
"message_id": str(message.id),
"subject": message.subject,
"from_address": message.from_address,
"pec_type": message.pec_type,
}
try:
async with aiohttp.ClientSession() as session:
await session.post(
url,
json=payload,
timeout=aiohttp.ClientTimeout(total=5),
)
except Exception:
pass
+15 -2
View File
@@ -166,12 +166,16 @@ class SendService:
now = datetime.now(tz=timezone.utc)
has_files = bool(attachments)
# Invio differito: il messaggio parte in stato 'draft' se programmato
scheduled_at = getattr(data, "scheduled_at", None)
is_scheduled = scheduled_at is not None and scheduled_at > now
message = Message(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
direction="outbound",
pec_type="posta_certificata",
state="queued",
state="draft" if is_scheduled else "queued",
subject=data.subject,
from_address=mailbox.email_address,
to_addresses=[str(a) for a in data.to_addresses],
@@ -211,6 +215,7 @@ class SendService:
max_attempts=5,
created_by=current_user.id,
queued_at=now,
scheduled_at=scheduled_at if is_scheduled else None,
)
self.db.add(job)
await self.db.flush()
@@ -218,7 +223,15 @@ class SendService:
# ── Enqueue job arq ───────────────────────────────────────────────────
try:
arq_pool = await _get_arq_pool()
await arq_pool.enqueue_job("send_pec", str(job.id))
if is_scheduled and scheduled_at:
# Invio differito: defer_until = scheduled_at
await arq_pool.enqueue_job(
"send_pec",
str(job.id),
_defer_until=scheduled_at,
)
else:
await arq_pool.enqueue_job("send_pec", str(job.id))
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
+116
View File
@@ -0,0 +1,116 @@
"""
Service per la gestione dei template messaggi (Feature 1).
"""
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.template import MessageTemplate
from app.schemas.template import TemplateCreate, TemplateUpdate
class TemplateService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_templates(
self,
tenant_id: uuid.UUID,
q: str | None = None,
) -> tuple[list[MessageTemplate], int]:
query = select(MessageTemplate).where(MessageTemplate.tenant_id == tenant_id)
if q:
query = query.where(MessageTemplate.name.ilike(f"%{q}%"))
query = query.order_by(MessageTemplate.name)
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar_one()
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_template(
self, tenant_id: uuid.UUID, template_id: uuid.UUID
) -> MessageTemplate:
result = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.id == template_id,
MessageTemplate.tenant_id == tenant_id,
)
)
template = result.scalar_one_or_none()
if not template:
raise NotFoundError(f"Template {template_id} non trovato")
return template
async def create_template(
self,
tenant_id: uuid.UUID,
data: TemplateCreate,
created_by: uuid.UUID | None = None,
) -> MessageTemplate:
# Verifica unicita' nome
existing = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.tenant_id == tenant_id,
MessageTemplate.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Template '{data.name}' gia' esistente")
template = MessageTemplate(
tenant_id=tenant_id,
name=data.name,
description=data.description,
subject=data.subject,
body_text=data.body_text,
body_html=data.body_html,
created_by=created_by,
)
self.db.add(template)
await self.db.commit()
await self.db.refresh(template)
return template
async def update_template(
self,
tenant_id: uuid.UUID,
template_id: uuid.UUID,
data: TemplateUpdate,
) -> MessageTemplate:
template = await self.get_template(tenant_id, template_id)
if data.name is not None:
existing = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.tenant_id == tenant_id,
MessageTemplate.name == data.name,
MessageTemplate.id != template_id,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Template '{data.name}' gia' esistente")
template.name = data.name
if data.description is not None:
template.description = data.description
if data.subject is not None:
template.subject = data.subject
if data.body_text is not None:
template.body_text = data.body_text
if data.body_html is not None:
template.body_html = data.body_html
await self.db.commit()
await self.db.refresh(template)
return template
async def delete_template(
self, tenant_id: uuid.UUID, template_id: uuid.UUID
) -> None:
template = await self.get_template(tenant_id, template_id)
await self.db.delete(template)
await self.db.commit()