kitchen/backend/services/imap_sync.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

503 lines
20 KiB
Python

"""
IMAP Email Inbox Sync Service
Monitors email inbox for invoice attachments and processes them through
the existing Azure OCR pipeline.
"""
import asyncio
import email
import imaplib
import logging
import os
import uuid
from datetime import datetime
from email.header import decode_header
from email.utils import parsedate_to_datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.settings import KitchenSettings
from models.invoice import Invoice, InvoiceStatus
from models.email_processing import EmailProcessingLog
logger = logging.getLogger(__name__)
class ImapSyncService:
"""Service for syncing invoice emails from IMAP inbox"""
# Only process PDFs to avoid logos and other images getting mixed in
SUPPORTED_EXTENSIONS = {'.pdf'}
SUPPORTED_CONTENT_TYPES = {
'application/pdf'
}
def __init__(self, kitchen_id: int, db: AsyncSession):
self.kitchen_id = kitchen_id
self.db = db
self._settings: Optional[KitchenSettings] = None
async def _get_settings(self) -> KitchenSettings:
"""Fetch and cache kitchen settings"""
if self._settings is None:
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
self._settings = result.scalar_one_or_none()
if not self._settings:
raise ValueError(f"No settings found for kitchen {self.kitchen_id}")
return self._settings
def _connect_imap(self, settings: KitchenSettings) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
"""Create authenticated IMAP connection"""
if settings.imap_use_ssl:
conn = imaplib.IMAP4_SSL(settings.imap_host, settings.imap_port or 993)
else:
conn = imaplib.IMAP4(settings.imap_host, settings.imap_port or 143)
conn.starttls()
conn.login(settings.imap_username, settings.imap_password)
return conn
def _decode_header_value(self, value: str) -> str:
"""Decode email header value"""
if value is None:
return ""
decoded_parts = decode_header(value)
result = []
for part, encoding in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(encoding or 'utf-8', errors='replace'))
else:
result.append(part)
return ''.join(result)
def _get_message_id(self, msg: email.message.Message) -> str:
"""Extract Message-ID from email"""
message_id = msg.get('Message-ID', '')
if not message_id:
# Generate a fallback ID using date and subject
date = msg.get('Date', '')
subject = msg.get('Subject', '')
message_id = f"<fallback-{hash(date + subject)}>"
return message_id.strip()
def _is_supported_attachment(self, filename: str, content_type: str) -> bool:
"""Check if attachment is a supported invoice format"""
if not filename:
return False
ext = os.path.splitext(filename.lower())[1]
if ext in self.SUPPORTED_EXTENSIONS:
return True
content_type_lower = content_type.lower() if content_type else ''
return content_type_lower in self.SUPPORTED_CONTENT_TYPES
def _extract_attachments(self, msg: email.message.Message) -> list[tuple[str, bytes, str]]:
"""
Extract supported attachments from email.
Returns list of (filename, content, content_type)
"""
attachments = []
if msg.is_multipart():
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
if "attachment" in content_disposition or part.get_filename():
filename = part.get_filename()
if filename:
filename = self._decode_header_value(filename)
content_type = part.get_content_type()
if self._is_supported_attachment(filename, content_type):
content = part.get_payload(decode=True)
if content:
attachments.append((filename, content, content_type))
else:
# Single part email - unlikely to have attachment but check anyway
filename = msg.get_filename()
if filename:
filename = self._decode_header_value(filename)
content_type = msg.get_content_type()
if self._is_supported_attachment(filename, content_type):
content = msg.get_payload(decode=True)
if content:
attachments.append((filename, content, content_type))
return attachments
async def _save_attachment(self, content: bytes, filename: str) -> str:
"""Save attachment to filesystem and return the path"""
# Get file extension
ext = os.path.splitext(filename)[1].lower()
if not ext:
ext = '.pdf' # Default to PDF if no extension
# Generate unique filename
unique_filename = f"{uuid.uuid4()}{ext}"
data_dir = f"/app/data/{self.kitchen_id}"
os.makedirs(data_dir, exist_ok=True)
file_path = os.path.join(data_dir, unique_filename)
# Write file asynchronously
await asyncio.to_thread(self._write_file, file_path, content)
return file_path
def _write_file(self, path: str, content: bytes):
"""Write content to file (blocking operation)"""
with open(path, 'wb') as f:
f.write(content)
async def _process_attachment(
self,
file_path: str,
email_subject: str
) -> tuple[int, float]:
"""
Process attachment through the same pipeline as manual uploads.
Creates invoice record, then runs full OCR processing including
line items, product definitions, and duplicate detection.
Returns (invoice_id, confidence)
"""
from api.invoices import process_invoice_background
# Create invoice record with source tracking
invoice = Invoice(
kitchen_id=self.kitchen_id,
image_path=file_path,
status=InvoiceStatus.PENDING,
source="email",
source_reference=email_subject[:255] if email_subject else None
)
self.db.add(invoice)
await self.db.commit()
await self.db.refresh(invoice)
invoice_id = invoice.id
try:
# Process through the same flow as manual uploads
# This handles OCR, line items, product definitions, and duplicate detection
await process_invoice_background(invoice_id, file_path, self.kitchen_id)
# Use a fresh session to fetch updated invoice data
# (process_invoice_background uses its own session, so we need fresh data)
from database import AsyncSessionLocal
async with AsyncSessionLocal() as fresh_db:
result = await fresh_db.execute(
select(Invoice).where(Invoice.id == invoice_id)
)
processed_invoice = result.scalar_one_or_none()
if processed_invoice:
confidence = float(processed_invoice.ocr_confidence) if processed_invoice.ocr_confidence else 0.0
logger.info(f"Invoice {invoice_id} processed with confidence {confidence}")
return invoice_id, confidence
else:
logger.warning(f"Invoice {invoice_id} not found after processing")
return invoice_id, 0.0
except Exception as e:
logger.error(f"Processing failed for invoice {invoice_id}: {e}")
# Update the invoice to mark it as having an error using fresh session
from database import AsyncSessionLocal
async with AsyncSessionLocal() as error_db:
result = await error_db.execute(
select(Invoice).where(Invoice.id == invoice_id)
)
invoice = result.scalar_one_or_none()
if invoice:
invoice.status = InvoiceStatus.PROCESSED
invoice.ocr_raw_text = f"Processing Error: {str(e)}"
await error_db.commit()
return invoice_id, 0.0
async def _should_mark_read(self, attachment_results: list[tuple[int, float]]) -> bool:
"""
Determine if email should be marked as read.
Returns True if ANY attachment has confidence >= threshold.
"""
settings = await self._get_settings()
threshold = float(settings.imap_confidence_threshold or 0.5)
logger.info(f"Checking mark-as-read: threshold={threshold}, results={attachment_results}")
for invoice_id, confidence in attachment_results:
if confidence is not None and confidence >= threshold:
logger.info(f"Invoice {invoice_id} meets threshold ({confidence} >= {threshold}), will mark as read")
return True
logger.info(f"No invoices met threshold, will NOT mark as read")
return False
def _mark_email_read(self, conn: imaplib.IMAP4, uid: bytes):
"""Mark email as read (add SEEN flag)"""
logger.info(f"Marking email UID {uid} as read")
result = conn.uid('STORE', uid, '+FLAGS', '\\Seen')
logger.info(f"Mark as read result: {result}")
async def _is_already_processed(self, message_id: str) -> bool:
"""Check if email was already processed"""
result = await self.db.execute(
select(EmailProcessingLog).where(
EmailProcessingLog.kitchen_id == self.kitchen_id,
EmailProcessingLog.message_id == message_id
)
)
return result.scalar_one_or_none() is not None
async def _log_processing(
self,
message_id: str,
email_subject: str,
email_from: str,
email_date: datetime,
attachments_count: int,
invoices_created: int,
confident_invoices: int,
marked_as_read: bool,
invoice_ids: list[int],
status: str = "success",
error: str = None
) -> EmailProcessingLog:
"""Create processing log entry"""
# Convert timezone-aware datetime to naive UTC datetime for database storage
naive_email_date = None
if email_date:
if email_date.tzinfo is not None:
# Convert to UTC and remove timezone info
from datetime import timezone
naive_email_date = email_date.astimezone(timezone.utc).replace(tzinfo=None)
else:
naive_email_date = email_date
log = EmailProcessingLog(
kitchen_id=self.kitchen_id,
message_id=message_id,
email_subject=email_subject[:500] if email_subject else None,
email_from=email_from[:255] if email_from else None,
email_date=naive_email_date,
attachments_count=attachments_count,
invoices_created=invoices_created,
confident_invoices=confident_invoices,
marked_as_read=marked_as_read,
processing_status=status,
error_message=error,
invoice_ids=invoice_ids if invoice_ids else None
)
self.db.add(log)
await self.db.commit()
return log
async def process_inbox(self) -> dict:
"""
Main sync method - process all unread emails.
Returns:
dict with:
- emails_checked: int
- emails_processed: int
- emails_skipped: int
- attachments_processed: int
- invoices_created: int
- confident_invoices: int
- emails_marked_read: int
- errors: list[str]
"""
settings = await self._get_settings()
if not settings.imap_host or not settings.imap_password:
raise ValueError("IMAP settings not configured")
results = {
"emails_checked": 0,
"emails_processed": 0,
"emails_skipped": 0,
"attachments_processed": 0,
"invoices_created": 0,
"confident_invoices": 0,
"emails_marked_read": 0,
"errors": []
}
conn = None
try:
# Connect to IMAP in thread pool (blocking operation)
conn = await asyncio.to_thread(self._connect_imap, settings)
# Select folder
folder = settings.imap_folder or "INBOX"
status, _ = await asyncio.to_thread(conn.select, folder)
if status != "OK":
raise ValueError(f"Could not select folder: {folder}")
# Search for unread emails
status, messages = await asyncio.to_thread(conn.uid, 'SEARCH', None, 'UNSEEN')
if status != "OK":
raise ValueError("Could not search for unread emails")
email_uids = messages[0].split()
results["emails_checked"] = len(email_uids)
for uid in email_uids:
try:
# Fetch email
status, msg_data = await asyncio.to_thread(
conn.uid, 'FETCH', uid, '(RFC822)'
)
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Extract metadata
message_id = self._get_message_id(msg)
email_subject = self._decode_header_value(msg.get('Subject', ''))
email_from = self._decode_header_value(msg.get('From', ''))
# Parse date
date_str = msg.get('Date')
email_date = None
if date_str:
try:
email_date = parsedate_to_datetime(date_str)
except Exception:
pass
# Check if already processed
if await self._is_already_processed(message_id):
results["emails_skipped"] += 1
continue
# Extract attachments
attachments = self._extract_attachments(msg)
if not attachments:
# No supported attachments - log and skip
await self._log_processing(
message_id=message_id,
email_subject=email_subject,
email_from=email_from,
email_date=email_date,
attachments_count=0,
invoices_created=0,
confident_invoices=0,
marked_as_read=False,
invoice_ids=[],
status="skipped",
error="No supported attachments found"
)
results["emails_skipped"] += 1
continue
# Process each attachment
attachment_results = []
invoice_ids = []
for filename, content, content_type in attachments:
try:
# Save attachment
file_path = await self._save_attachment(content, filename)
# Process through OCR
invoice_id, confidence = await self._process_attachment(
file_path, email_subject
)
attachment_results.append((invoice_id, confidence))
invoice_ids.append(invoice_id)
results["attachments_processed"] += 1
results["invoices_created"] += 1
# Count confident invoices
threshold = float(settings.imap_confidence_threshold or 0.5)
if confidence >= threshold:
results["confident_invoices"] += 1
except Exception as e:
logger.error(f"Failed to process attachment {filename}: {e}")
results["errors"].append(f"Attachment {filename}: {str(e)}")
# Determine if email should be marked as read
should_mark_read = await self._should_mark_read(attachment_results)
if should_mark_read:
await asyncio.to_thread(self._mark_email_read, conn, uid)
results["emails_marked_read"] += 1
# Log processing
await self._log_processing(
message_id=message_id,
email_subject=email_subject,
email_from=email_from,
email_date=email_date,
attachments_count=len(attachments),
invoices_created=len(invoice_ids),
confident_invoices=sum(1 for _, c in attachment_results if c >= float(settings.imap_confidence_threshold or 0.5)),
marked_as_read=should_mark_read,
invoice_ids=invoice_ids,
status="success"
)
results["emails_processed"] += 1
except Exception as e:
logger.error(f"Failed to process email UID {uid}: {e}")
results["errors"].append(f"Email UID {uid}: {str(e)}")
# Update last sync timestamp
settings.imap_last_sync = datetime.utcnow()
await self.db.commit()
finally:
if conn:
try:
await asyncio.to_thread(conn.close)
await asyncio.to_thread(conn.logout)
except Exception:
pass
return results
async def test_connection(self) -> dict:
"""
Test IMAP connection and return folder list.
Returns: {"success": True, "folders": [...]} or {"success": False, "error": "..."}
"""
settings = await self._get_settings()
if not settings.imap_host or not settings.imap_password:
return {"success": False, "error": "IMAP settings not configured"}
conn = None
try:
conn = await asyncio.to_thread(self._connect_imap, settings)
# List folders
status, folders_data = await asyncio.to_thread(conn.list)
if status != "OK":
return {"success": False, "error": "Could not list folders"}
folders = []
for folder_data in folders_data:
if isinstance(folder_data, bytes):
# Parse folder name from response like: (\HasNoChildren) "/" "INBOX"
parts = folder_data.decode().split(' "')
if len(parts) >= 2:
folder_name = parts[-1].strip('"')
folders.append(folder_name)
return {"success": True, "folders": folders}
except imaplib.IMAP4.error as e:
return {"success": False, "error": f"IMAP error: {str(e)}"}
except Exception as e:
return {"success": False, "error": str(e)}
finally:
if conn:
try:
await asyncio.to_thread(conn.logout)
except Exception:
pass