kitchen/backend/api/imap.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

341 lines
12 KiB
Python

"""
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)
}
}