mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
fase 5 fix
This commit is contained in:
@@ -47,4 +47,11 @@ SSL: Sì
|
|||||||
|
|
||||||
Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it
|
Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it
|
||||||
|
|
||||||
Tutto il frontend deve essere in italiano
|
Tutto il frontend deve essere in italiano
|
||||||
|
|
||||||
|
Credenziali admin
|
||||||
|
Ruolo Email Password
|
||||||
|
Super Admin superadmin@pecflow.it SuperAdmin@PecFlow2026!
|
||||||
|
Admin (tenant demo) admin@demo.pecflow.it Demo@PecFlow2026!
|
||||||
|
Operator (tenant demo) operator@demo.pecflow.it Oper@PecFlow2026!
|
||||||
|
Per accedere all'applicazione usa le credenziali Admin del tenant demo.
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
Router messaggi PEC.
|
||||||
|
|
||||||
|
Fornisce:
|
||||||
|
- GET /messages – lista messaggi con filtri (inbox/sent/search/...)
|
||||||
|
- GET /messages/{id} – singolo messaggio
|
||||||
|
- PATCH /messages/{id} – aggiorna flags (is_read, is_starred, is_archived)
|
||||||
|
- GET /messages/{id}/attachments – lista allegati
|
||||||
|
- GET /messages/{id}/attachments/{att_id}/download – scarica allegato da MinIO
|
||||||
|
- GET /messages/{id}/receipts – ricevute (messaggi figlio)
|
||||||
|
|
||||||
|
Permessi:
|
||||||
|
- Admin: accede a tutti i messaggi del proprio tenant.
|
||||||
|
- Operator/Supervisor/Readonly: solo i messaggi delle caselle su cui
|
||||||
|
hanno almeno il permesso can_read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import CurrentUser, DB
|
||||||
|
from app.models.message import Attachment, Message
|
||||||
|
from app.schemas.message import (
|
||||||
|
AttachmentResponse,
|
||||||
|
MessageListResponse,
|
||||||
|
MessageResponse,
|
||||||
|
MessageUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/messages", tags=["Messages"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_visible_mailbox_ids(
|
||||||
|
user, db: AsyncSession
|
||||||
|
) -> Optional[list[uuid.UUID]]:
|
||||||
|
"""
|
||||||
|
Per utenti non-admin restituisce la lista di mailbox_id accessibili.
|
||||||
|
Restituisce None se l'utente è admin (accesso illimitato al tenant).
|
||||||
|
"""
|
||||||
|
if user.is_admin:
|
||||||
|
return None # nessun filtro per admin
|
||||||
|
|
||||||
|
from app.services.permission_service import PermissionService
|
||||||
|
perm_svc = PermissionService(db)
|
||||||
|
return await perm_svc.get_visible_mailboxes(user)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_message(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
current_user,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Message:
|
||||||
|
"""Carica il messaggio e verifica i permessi di accesso."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Message).where(
|
||||||
|
Message.id == message_id,
|
||||||
|
Message.tenant_id == current_user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
if not message:
|
||||||
|
raise NotFoundError(f"Messaggio {message_id} non trovato")
|
||||||
|
|
||||||
|
if not current_user.is_admin:
|
||||||
|
from app.services.permission_service import PermissionService
|
||||||
|
perm_svc = PermissionService(db)
|
||||||
|
if not await perm_svc.check_can_read(current_user, message.mailbox_id):
|
||||||
|
raise ForbiddenError("Accesso al messaggio non autorizzato")
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("", response_model=MessageListResponse)
|
||||||
|
async def list_messages(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DB,
|
||||||
|
# Filtri
|
||||||
|
mailbox_id: Optional[uuid.UUID] = Query(None),
|
||||||
|
direction: Optional[str] = Query(None, pattern="^(inbound|outbound)$"),
|
||||||
|
state: Optional[str] = Query(None),
|
||||||
|
is_read: Optional[bool] = Query(None),
|
||||||
|
is_starred: Optional[bool] = Query(None),
|
||||||
|
is_archived: Optional[bool] = Query(False),
|
||||||
|
search: Optional[str] = Query(None, max_length=200),
|
||||||
|
pec_type: Optional[str] = Query(None),
|
||||||
|
# Paginazione
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
) -> MessageListResponse:
|
||||||
|
"""
|
||||||
|
Elenca i messaggi PEC con filtri opzionali.
|
||||||
|
|
||||||
|
- `is_archived=False` (default) esclude i messaggi archiviati.
|
||||||
|
- `search` cerca su subject, from_address, to_addresses.
|
||||||
|
"""
|
||||||
|
# Determinare le caselle visibili
|
||||||
|
visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
|
||||||
|
|
||||||
|
# Query base
|
||||||
|
q = select(Message).where(
|
||||||
|
Message.tenant_id == current_user.tenant_id,
|
||||||
|
Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtro caselle visibili per non-admin
|
||||||
|
if visible_mailbox_ids is not None:
|
||||||
|
if not visible_mailbox_ids:
|
||||||
|
# Nessuna casella accessibile → lista vuota
|
||||||
|
return MessageListResponse(items=[], total=0, page=page, page_size=page_size)
|
||||||
|
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
|
||||||
|
|
||||||
|
# Filtri opzionali
|
||||||
|
if mailbox_id is not None:
|
||||||
|
# Verifica che l'utente abbia accesso a questa casella specifica
|
||||||
|
if visible_mailbox_ids is not None and mailbox_id not in visible_mailbox_ids:
|
||||||
|
raise ForbiddenError("Accesso alla casella non autorizzato")
|
||||||
|
q = q.where(Message.mailbox_id == mailbox_id)
|
||||||
|
|
||||||
|
if direction is not None:
|
||||||
|
q = q.where(Message.direction == direction)
|
||||||
|
|
||||||
|
if state is not None:
|
||||||
|
q = q.where(Message.state == state)
|
||||||
|
|
||||||
|
if pec_type is not None:
|
||||||
|
q = q.where(Message.pec_type == pec_type)
|
||||||
|
|
||||||
|
if is_read is not None:
|
||||||
|
q = q.where(Message.is_read == is_read)
|
||||||
|
|
||||||
|
if is_starred is not None:
|
||||||
|
q = q.where(Message.is_starred == is_starred)
|
||||||
|
|
||||||
|
if is_archived is not None:
|
||||||
|
q = q.where(Message.is_archived == is_archived)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
term = f"%{search}%"
|
||||||
|
q = q.where(
|
||||||
|
or_(
|
||||||
|
Message.subject.ilike(term),
|
||||||
|
Message.from_address.ilike(term),
|
||||||
|
Message.body_text.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Conteggio totale
|
||||||
|
count_q = select(func.count()).select_from(q.subquery())
|
||||||
|
total = (await db.execute(count_q)).scalar_one()
|
||||||
|
|
||||||
|
# Ordinamento e paginazione
|
||||||
|
q = (
|
||||||
|
q.order_by(
|
||||||
|
Message.received_at.desc().nullslast(),
|
||||||
|
Message.created_at.desc(),
|
||||||
|
)
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(q)
|
||||||
|
items = list(result.scalars().all())
|
||||||
|
|
||||||
|
return MessageListResponse(
|
||||||
|
items=[MessageResponse.model_validate(m) for m in items],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{message_id}", response_model=MessageResponse)
|
||||||
|
async def get_message(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DB,
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""Carica un messaggio per ID."""
|
||||||
|
message = await _resolve_message(message_id, current_user, db)
|
||||||
|
return MessageResponse.model_validate(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{message_id}", response_model=MessageResponse)
|
||||||
|
async def update_message(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
data: MessageUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DB,
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""
|
||||||
|
Aggiorna i flag operativi di un messaggio:
|
||||||
|
is_read, is_starred, is_archived.
|
||||||
|
"""
|
||||||
|
message = await _resolve_message(message_id, current_user, db)
|
||||||
|
|
||||||
|
if data.is_read is not None:
|
||||||
|
message.is_read = data.is_read
|
||||||
|
if data.is_starred is not None:
|
||||||
|
message.is_starred = data.is_starred
|
||||||
|
if data.is_archived is not None:
|
||||||
|
message.is_archived = data.is_archived
|
||||||
|
if data.is_archived and not message.archived_at:
|
||||||
|
message.archived_at = datetime.now(timezone.utc)
|
||||||
|
elif not data.is_archived:
|
||||||
|
message.archived_at = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(message)
|
||||||
|
return MessageResponse.model_validate(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{message_id}/attachments", response_model=list[AttachmentResponse])
|
||||||
|
async def list_attachments(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DB,
|
||||||
|
) -> list[AttachmentResponse]:
|
||||||
|
"""Elenca gli allegati di un messaggio."""
|
||||||
|
message = await _resolve_message(message_id, current_user, db)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Attachment)
|
||||||
|
.where(Attachment.message_id == message.id)
|
||||||
|
.order_by(Attachment.created_at)
|
||||||
|
)
|
||||||
|
attachments = list(result.scalars().all())
|
||||||
|
return [AttachmentResponse.model_validate(a) for a in attachments]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{message_id}/attachments/{attachment_id}/download")
|
||||||
|
async def download_attachment(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DB,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Scarica un allegato direttamente da MinIO.
|
||||||
|
Il file viene streamato al client con i header Content-Disposition corretti.
|
||||||
|
"""
|
||||||
|
# Verifica accesso al messaggio
|
||||||
|
await _resolve_message(message_id, current_user, db)
|
||||||
|
|
||||||
|
# Carica allegato
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Stream da MinIO
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# storage_path è del tipo "tenant_id/attachments/filename"
|
||||||
|
storage_path = attachment.storage_path
|
||||||
|
response = await client.get_object(settings.minio_bucket, storage_path)
|
||||||
|
|
||||||
|
content_type = attachment.content_type or "application/octet-stream"
|
||||||
|
filename = attachment.filename.replace('"', "'")
|
||||||
|
|
||||||
|
async def _stream():
|
||||||
|
async for chunk in response.content.iter_chunked(65536):
|
||||||
|
yield chunk
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream(),
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Content-Length": str(attachment.size_bytes or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.error(f"Errore download allegato {attachment_id}: {e}")
|
||||||
|
raise NotFoundError("File non disponibile al momento")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{message_id}/receipts", response_model=list[MessageResponse])
|
||||||
|
async def list_receipts(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DB,
|
||||||
|
) -> list[MessageResponse]:
|
||||||
|
"""
|
||||||
|
Elenca le ricevute associate a un messaggio outbound
|
||||||
|
(messaggi con parent_message_id = message_id).
|
||||||
|
"""
|
||||||
|
# Verifica accesso al messaggio padre
|
||||||
|
await _resolve_message(message_id, current_user, db)
|
||||||
|
|
||||||
|
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(result.scalars().all())
|
||||||
|
return [MessageResponse.model_validate(r) for r in receipts]
|
||||||
+2
-1
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from slowapi.middleware import SlowAPIMiddleware
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from app.api.v1 import auth, mailboxes, permissions, send, tenants, users, ws
|
from app.api.v1 import auth, mailboxes, messages, permissions, send, tenants, users, ws
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.logging import get_logger, setup_logging
|
from app.core.logging import get_logger, setup_logging
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
@@ -88,6 +88,7 @@ app.include_router(users.router, prefix=API_PREFIX)
|
|||||||
app.include_router(tenants.router, prefix=API_PREFIX)
|
app.include_router(tenants.router, prefix=API_PREFIX)
|
||||||
app.include_router(permissions.router, prefix=API_PREFIX)
|
app.include_router(permissions.router, prefix=API_PREFIX)
|
||||||
app.include_router(mailboxes.router, prefix=API_PREFIX)
|
app.include_router(mailboxes.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(messages.router, prefix=API_PREFIX)
|
||||||
app.include_router(send.router, prefix=API_PREFIX)
|
app.include_router(send.router, prefix=API_PREFIX)
|
||||||
app.include_router(ws.router, prefix=API_PREFIX)
|
app.include_router(ws.router, prefix=API_PREFIX)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Schemi Pydantic per Message, Attachment e operazioni correlate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
message_id: uuid.UUID
|
||||||
|
filename: str
|
||||||
|
content_type: Optional[str] = None
|
||||||
|
size_bytes: Optional[int] = None
|
||||||
|
storage_path: str
|
||||||
|
checksum_sha256: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
mailbox_id: uuid.UUID
|
||||||
|
message_id_header: Optional[str] = None
|
||||||
|
imap_uid: Optional[int] = None
|
||||||
|
imap_folder: str
|
||||||
|
direction: str
|
||||||
|
pec_type: str
|
||||||
|
state: str
|
||||||
|
subject: Optional[str] = None
|
||||||
|
from_address: Optional[str] = None
|
||||||
|
to_addresses: list[str] = []
|
||||||
|
cc_addresses: list[str] = []
|
||||||
|
sent_at: Optional[datetime] = None
|
||||||
|
received_at: Optional[datetime] = None
|
||||||
|
size_bytes: Optional[int] = None
|
||||||
|
body_text: Optional[str] = None
|
||||||
|
body_html: Optional[str] = None
|
||||||
|
has_attachments: bool = False
|
||||||
|
parent_message_id: Optional[uuid.UUID] = None
|
||||||
|
is_read: bool = False
|
||||||
|
is_starred: bool = False
|
||||||
|
is_archived: bool = False
|
||||||
|
archived_at: Optional[datetime] = None
|
||||||
|
raw_eml_path: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def coerce_arrays(cls, data: object) -> object:
|
||||||
|
"""Normalizza i campi array a liste vuote se None (da ORM)."""
|
||||||
|
if hasattr(data, "__dict__"):
|
||||||
|
# ORM object
|
||||||
|
for field in ("to_addresses", "cc_addresses"):
|
||||||
|
val = getattr(data, field, None)
|
||||||
|
if val is None:
|
||||||
|
object.__setattr__(data, field, [])
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
for field in ("to_addresses", "cc_addresses"):
|
||||||
|
if data.get(field) is None:
|
||||||
|
data[field] = []
|
||||||
|
return data
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MessageListResponse(BaseModel):
|
||||||
|
items: list[MessageResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class MessageUpdateRequest(BaseModel):
|
||||||
|
is_read: Optional[bool] = None
|
||||||
|
is_starred: Optional[bool] = None
|
||||||
|
is_archived: Optional[bool] = None
|
||||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://backend:8000',
|
target: 'http://backend:8000',
|
||||||
|
|||||||
Reference in New Issue
Block a user