""" IMAP Email Inbox API endpoints for settings and sync control. """ import logging from decimal import Decimal from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from auth import get_current_user, require_cap from database import get_db from models.user import User from models.settings import KitchenSettings from models.email_processing import EmailProcessingLog from services.imap_sync import ImapSyncService router = APIRouter(prefix="/imap", tags=["IMAP"]) logger = logging.getLogger(__name__) # ============ Pydantic Schemas ============ class ImapSettingsResponse(BaseModel): imap_host: Optional[str] imap_port: Optional[int] imap_use_ssl: bool imap_username: Optional[str] imap_password_set: bool # Don't expose actual password imap_folder: Optional[str] imap_poll_interval: int imap_enabled: bool imap_confidence_threshold: Optional[float] imap_last_sync: Optional[str] class ImapSettingsUpdate(BaseModel): imap_host: Optional[str] = None imap_port: Optional[int] = None imap_use_ssl: Optional[bool] = None imap_username: Optional[str] = None imap_password: Optional[str] = None # Only set if provided imap_folder: Optional[str] = None imap_poll_interval: Optional[int] = None imap_enabled: Optional[bool] = None imap_confidence_threshold: Optional[float] = None class ImapTestRequest(BaseModel): imap_host: Optional[str] = None imap_port: Optional[int] = None imap_use_ssl: Optional[bool] = None imap_username: Optional[str] = None imap_password: Optional[str] = None class EmailLogResponse(BaseModel): id: int message_id: str email_subject: Optional[str] email_from: Optional[str] email_date: Optional[str] attachments_count: int invoices_created: int confident_invoices: int marked_as_read: bool processing_status: str error_message: Optional[str] invoice_ids: Optional[list[int]] processed_at: str # ============ Settings Endpoints ============ @router.get("/settings", response_model=ImapSettingsResponse) async def get_imap_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get IMAP settings (password masked)""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") return ImapSettingsResponse( imap_host=settings.imap_host, imap_port=settings.imap_port, imap_use_ssl=settings.imap_use_ssl, imap_username=settings.imap_username, imap_password_set=bool(settings.imap_password), imap_folder=settings.imap_folder, imap_poll_interval=settings.imap_poll_interval, imap_enabled=settings.imap_enabled, imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None, imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None ) @router.patch("/settings", response_model=ImapSettingsResponse) async def update_imap_settings( update: ImapSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update IMAP settings (admin only)""" if not current_user.is_admin: raise HTTPException(status_code=403, detail="Admin access required") result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Update only provided fields if update.imap_host is not None: settings.imap_host = update.imap_host if update.imap_port is not None: settings.imap_port = update.imap_port if update.imap_use_ssl is not None: settings.imap_use_ssl = update.imap_use_ssl if update.imap_username is not None: settings.imap_username = update.imap_username if update.imap_password is not None and update.imap_password: settings.imap_password = update.imap_password if update.imap_folder is not None: settings.imap_folder = update.imap_folder if update.imap_poll_interval is not None: settings.imap_poll_interval = update.imap_poll_interval if update.imap_enabled is not None: settings.imap_enabled = update.imap_enabled if update.imap_confidence_threshold is not None: settings.imap_confidence_threshold = Decimal(str(update.imap_confidence_threshold)) await db.commit() return ImapSettingsResponse( imap_host=settings.imap_host, imap_port=settings.imap_port, imap_use_ssl=settings.imap_use_ssl, imap_username=settings.imap_username, imap_password_set=bool(settings.imap_password), imap_folder=settings.imap_folder, imap_poll_interval=settings.imap_poll_interval, imap_enabled=settings.imap_enabled, imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None, imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None ) @router.post("/test-connection") async def test_imap_connection( request: ImapTestRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Test IMAP connection with provided or saved settings""" if not current_user.is_admin: raise HTTPException(status_code=403, detail="Admin access required") # Get current settings result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Use provided values or fall back to saved settings test_settings = KitchenSettings( kitchen_id=current_user.kitchen_id, imap_host=request.imap_host or settings.imap_host, imap_port=request.imap_port or settings.imap_port or 993, imap_use_ssl=request.imap_use_ssl if request.imap_use_ssl is not None else settings.imap_use_ssl, imap_username=request.imap_username or settings.imap_username, imap_password=request.imap_password or settings.imap_password ) if not test_settings.imap_host or not test_settings.imap_password: return {"success": False, "error": "IMAP host and password are required"} # Test connection sync_service = ImapSyncService(current_user.kitchen_id, db) sync_service._settings = test_settings result = await sync_service.test_connection() return result @router.post("/sync-now") async def trigger_manual_sync( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Manually trigger inbox sync (admin only)""" if not current_user.is_admin: raise HTTPException(status_code=403, detail="Admin access required") # Get settings result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not settings.imap_host or not settings.imap_password: raise HTTPException(status_code=400, detail="IMAP settings not configured") # Run sync try: sync_service = ImapSyncService(current_user.kitchen_id, db) results = await sync_service.process_inbox() return { "success": True, "results": results } except Exception as e: logger.error(f"Manual IMAP sync failed: {e}") return { "success": False, "error": str(e) } # ============ Log Endpoints ============ @router.get("/logs", response_model=list[EmailLogResponse]) async def get_processing_logs( limit: int = 50, offset: int = 0, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get recent email processing logs""" result = await db.execute( select(EmailProcessingLog) .where(EmailProcessingLog.kitchen_id == current_user.kitchen_id) .order_by(desc(EmailProcessingLog.processed_at)) .offset(offset) .limit(limit) ) logs = result.scalars().all() return [ EmailLogResponse( id=log.id, message_id=log.message_id, email_subject=log.email_subject, email_from=log.email_from, email_date=log.email_date.isoformat() if log.email_date else None, attachments_count=log.attachments_count, invoices_created=log.invoices_created, confident_invoices=log.confident_invoices, marked_as_read=log.marked_as_read, processing_status=log.processing_status, error_message=log.error_message, invoice_ids=log.invoice_ids, processed_at=log.processed_at.isoformat() ) for log in logs ] @router.get("/logs/stats") async def get_sync_stats( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get sync statistics""" from sqlalchemy import func from datetime import datetime, timedelta now = datetime.utcnow() # Last 24 hours result_24h = await db.execute( select( func.count(EmailProcessingLog.id), func.sum(EmailProcessingLog.invoices_created), func.sum(EmailProcessingLog.confident_invoices) ).where( EmailProcessingLog.kitchen_id == current_user.kitchen_id, EmailProcessingLog.processed_at >= now - timedelta(hours=24) ) ) stats_24h = result_24h.one() # Last 7 days result_7d = await db.execute( select( func.count(EmailProcessingLog.id), func.sum(EmailProcessingLog.invoices_created), func.sum(EmailProcessingLog.confident_invoices) ).where( EmailProcessingLog.kitchen_id == current_user.kitchen_id, EmailProcessingLog.processed_at >= now - timedelta(days=7) ) ) stats_7d = result_7d.one() # Last 30 days result_30d = await db.execute( select( func.count(EmailProcessingLog.id), func.sum(EmailProcessingLog.invoices_created), func.sum(EmailProcessingLog.confident_invoices) ).where( EmailProcessingLog.kitchen_id == current_user.kitchen_id, EmailProcessingLog.processed_at >= now - timedelta(days=30) ) ) stats_30d = result_30d.one() return { "last_24h": { "emails_processed": stats_24h[0] or 0, "invoices_created": int(stats_24h[1] or 0), "confident_invoices": int(stats_24h[2] or 0) }, "last_7d": { "emails_processed": stats_7d[0] or 0, "invoices_created": int(stats_7d[1] or 0), "confident_invoices": int(stats_7d[2] or 0) }, "last_30d": { "emails_processed": stats_30d[0] or 0, "invoices_created": int(stats_30d[1] or 0), "confident_invoices": int(stats_30d[2] or 0) } }