341 lines
12 KiB
Python
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)
|
|
}
|
|
}
|