import logging from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from app.config import settings from app.database import init_db from app.lib.graph.auth import GraphAuthProvider from app.lib.graph.client import GraphClient from app.routes.health import router as health_router from app.routes.webhook import router as webhook_router logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator: """Minimal lifespan — sets up the DB and Graph client only. Subscription bootstrap and APScheduler jobs live in the standalone `app.scheduler_main` process so that the FastAPI server can run with multiple uvicorn workers without duplicating cron work or racing on subscription creation. The webhook handler reads the active subscription's clientState from the DB on each request — it does not need a live subscription_id on app.state. """ # --- Startup --- await init_db(settings.database_path) auth = GraphAuthProvider(settings) client = GraphClient(auth) app.state.client = client app.state.settings = settings logger.info("FastAPI ready — webhook receiver online") yield # --- Shutdown --- await client.aclose() logger.info("Shutdown complete") app = FastAPI(title="RCMEmail", version="1.0.0", lifespan=lifespan) app.include_router(health_router) app.include_router(webhook_router) @app.exception_handler(Exception) async def _global_exception_handler(request: Request, exc: Exception) -> JSONResponse: logger.exception("Unhandled exception for %s %s", request.method, request.url.path) return JSONResponse(status_code=500, content={"detail": "Internal server error"})