import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session, selectinload from app.core.database import get_db from app.core.security import require_admin_auth from app.models.domain import Domain from app.models.report import TLSReport from app.services.tls_report_parser import MAX_TLS_REPORT_SIZE, TLSReportParser from app.services.tls_report_persistence import ( TLS_REPORT_PRIVACY_CONTROLS, save_tls_report, summarize_tls_reports, tls_report_to_dict, ) logger = logging.getLogger(__name__) router = APIRouter() class TLSFailureResponse(BaseModel): result_type: str failed_session_count: int sending_mta_ip: Optional[str] = None receiving_mx_hostname: Optional[str] = None receiving_mx_helo: Optional[str] = None receiving_ip: Optional[str] = None failure_reason_code: Optional[str] = None additional_information: Optional[str] = None class TLSReportResponse(BaseModel): id: int report_id: str domain: Optional[str] = None org_name: Optional[str] = None contact_info: Optional[str] = None policy_domain: str policy_type: Optional[str] = None begin_date: Optional[str] = None end_date: Optional[str] = None total_successful_sessions: int total_failure_sessions: int processed_at: Optional[str] = None failures: List[TLSFailureResponse] = Field(default_factory=list) class TLSReportListResponse(BaseModel): total: int page: int page_size: int total_pages: int reports: List[TLSReportResponse] privacy: Dict[str, Any] class TLSReportUploadResponse(BaseModel): success: bool report_id: str policies_created: int policies_skipped: int duplicate: bool = False message: str privacy: Dict[str, Any] class TLSSummaryResponse(BaseModel): domain: Optional[str] = None days: int totals: Dict[str, Any] trends: List[Dict[str, Any]] = Field(default_factory=list) top_failures: List[Dict[str, Any]] = Field(default_factory=list) affected_domains: List[Dict[str, Any]] = Field(default_factory=list) privacy: Dict[str, Any] def _validate_upload(file: UploadFile, content: bytes) -> None: filename = file.filename or "" if not filename: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required") if len(content) == 0: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty") if len(content) > MAX_TLS_REPORT_SIZE: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File too large", ) if not filename.lower().endswith((".json", ".json.gz", ".gzip", ".zip")): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. Upload a TLS report as .json, .json.gz, or .zip.", ) def _filtered_tls_query(db: Session, *, domain: Optional[str] = None): query = db.query(TLSReport).options( selectinload(TLSReport.domain), selectinload(TLSReport.failures) ) if domain: normalized = domain.lower().strip(".") query = query.outerjoin(Domain).filter( (Domain.name == normalized) | (TLSReport.policy_domain == normalized) ) return query @router.post("/upload", response_model=TLSReportUploadResponse) async def upload_tls_report( file: UploadFile = File(...), db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ): """Upload and store an SMTP TLS Reporting aggregate.""" try: content = await file.read() _validate_upload(file, content) parsed = TLSReportParser.parse_file(content, file.filename or "") result = save_tls_report(db, parsed) db.commit() return TLSReportUploadResponse( success=True, report_id=parsed["report_id"], policies_created=result["created"], policies_skipped=result["skipped"], duplicate=result["created"] == 0 and result["skipped"] > 0, message=( "TLS report imported." if result["created"] else "TLS report had already been imported." ), privacy=TLS_REPORT_PRIVACY_CONTROLS, ) except HTTPException: raise except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) or "Invalid TLS report format.", ) from exc except Exception as exc: # pylint: disable=broad-exception-caught logger.error("Unexpected TLS report upload failure for %s: %s", file.filename, exc) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error processing TLS report.", ) from exc @router.get("", response_model=TLSReportListResponse) async def list_tls_reports( domain: Optional[str] = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ): """List stored SMTP TLS reports, newest first.""" query = _filtered_tls_query(db, domain=domain) total = query.count() rows = ( query.order_by(TLSReport.begin_date.desc().nullslast(), TLSReport.id.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) total_pages = (total + page_size - 1) // page_size if total else 0 return TLSReportListResponse( total=total, page=page, page_size=page_size, total_pages=total_pages, reports=[TLSReportResponse(**tls_report_to_dict(row)) for row in rows], privacy=TLS_REPORT_PRIVACY_CONTROLS, ) @router.get("/summary", response_model=TLSSummaryResponse) async def tls_report_summary( domain: Optional[str] = Query(default=None), days: int = Query(default=30, ge=1, le=365), limit: int = Query(default=10, ge=1, le=50), db: Session = Depends(get_db), _auth: dict = Depends(require_admin_auth), ): """Summarize TLS reports into trends and top failure causes.""" return TLSSummaryResponse(**summarize_tls_reports(db, domain=domain, days=days, limit=limit))