from contextlib import asynccontextmanager from fastapi import FastAPI, Response, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from app.config import get_settings from app.database import SessionFactory, engine, mongo_client, mongo_db from app.models import Base from app.routers import ( aliases, auth, dns_setup, domains, emails, groups, org, people, sections, sticky_notes, ) from app.services.domain_provisioning import ensure_platform_domain from app.services.inbound_smtp import InboundSMTPServer settings = get_settings() inbound_server = InboundSMTPServer() async def migrate_mail_group_columns(connection) -> None: await connection.execute( text( "ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS owner_id UUID " "REFERENCES users(id)" ) ) await connection.execute( text( "ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS send_permission " "VARCHAR(30) DEFAULT 'org_members'" ) ) await connection.execute( text("ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS description TEXT") ) async def migrate_user_org_columns(connection) -> None: await connection.execute( text("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_role VARCHAR(20)") ) await connection.execute( text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS member_status VARCHAR(20)" ) ) await connection.execute( text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by_id UUID " "REFERENCES users(id)" ) ) await connection.execute( text("UPDATE users SET org_role = 'user' WHERE org_role IS NULL") ) await connection.execute( text("UPDATE users SET member_status = 'active' WHERE member_status IS NULL") ) await connection.execute( text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS invitation_email VARCHAR(320)" ) ) await connection.execute( text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN " "DEFAULT FALSE" ) ) await connection.execute( text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS section_id UUID " "REFERENCES org_sections(id)" ) ) async def migrate_legacy_user_tables(connection) -> None: """Promote the former auth table to users without discarding old profiles.""" tables = ( await connection.execute( text( "SELECT to_regclass('public.accounts'), " "to_regclass('public.users'), " "to_regclass('public.legacy_users')" ) ) ).one() accounts_table, users_table, legacy_users_table = tables if accounts_table is None: return if users_table is not None: if legacy_users_table is not None: raise RuntimeError( "Cannot migrate accounts: both users and legacy_users already exist." ) await connection.execute(text("ALTER TABLE users RENAME TO legacy_users")) await connection.execute(text("ALTER TABLE accounts RENAME TO users")) @asynccontextmanager async def lifespan(_: FastAPI): async with engine.begin() as connection: await migrate_legacy_user_tables(connection) await connection.run_sync(Base.metadata.create_all) await migrate_user_org_columns(connection) await migrate_mail_group_columns(connection) async with SessionFactory() as session: await ensure_platform_domain(session) await session.commit() await mongo_db.emails.create_index([("recipients", 1), ("created_at", -1)]) await mongo_db.emails.create_index([("cc", 1), ("created_at", -1)]) await mongo_db.emails.create_index([("bcc", 1), ("created_at", -1)]) await mongo_db.emails.create_index([("sender", 1), ("created_at", -1)]) await mongo_db.emails.create_index([("account_id", 1), ("created_at", -1)]) await mongo_db.messages.create_index( [("delivery_key", 1)], unique=True, sparse=True, ) await mongo_db.message_tombstones.create_index( [("delivery_key", 1)], unique=True, ) await mongo_db.messages.create_index( [("subject", "text"), ("snippet", "text"), ("sender.name", "text")] ) await mongo_db.mailbox_entries.create_index( [ ("account_id", 1), ("mailbox", 1), ("trashed_at", 1), ("deletion_state", 1), ("created_at", -1), ("_id", -1), ] ) await mongo_db.mailbox_entries.create_index( [("account_id", 1), ("is_read", 1), ("created_at", -1), ("_id", -1)] ) await mongo_db.mailbox_entries.create_index( [("account_id", 1), ("message_id", 1), ("mailbox", 1)], unique=True, ) await mongo_db.mailbox_entries.create_index( [("account_id", 1), ("is_starred", 1), ("created_at", -1), ("_id", -1)] ) await mongo_db.sticky_notes.create_index( [("account_id", 1), ("updated_at", -1)] ) await mongo_db.mailbox_entries.create_index( [ ("account_id", 1), ("sender.name", "text"), ("sender.address", "text"), ("subject", "text"), ("snippet", "text"), ], name="mailbox_search", ) if settings.inbound_smtp_enabled: await inbound_server.start() try: yield finally: await inbound_server.stop() await engine.dispose() await mongo_client.close() app = FastAPI( title=settings.app_name, version="0.1.0", description="The API for Agmail — email for people and their AI agents.", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(emails.router, prefix=settings.api_prefix) app.include_router(sticky_notes.router, prefix=settings.api_prefix) app.include_router(auth.router, prefix=settings.api_prefix) app.include_router(domains.router, prefix=settings.api_prefix) app.include_router(people.router, prefix=settings.api_prefix) app.include_router(sections.router, prefix=settings.api_prefix) app.include_router(groups.router, prefix=settings.api_prefix) app.include_router(aliases.router, prefix=settings.api_prefix) app.include_router(org.router, prefix=settings.api_prefix) app.include_router(dns_setup.router, prefix=settings.api_prefix) @app.get("/") async def root(): return { "name": settings.app_name, "docs": "/docs", "version": "0.1.0", } @app.get("/health") async def health(response: Response): checks = { "postgres": False, "mongodb": False, "smtp": False, "inbound_smtp": ( not settings.inbound_smtp_enabled or ( inbound_server.server is not None and inbound_server.server.is_serving() ) ), } try: async with SessionFactory() as session: await session.execute(text("SELECT 1")) checks["postgres"] = True except Exception: pass try: await mongo_db.command("ping") checks["mongodb"] = True except Exception: pass try: import socket with socket.create_connection( (settings.smtp_host, settings.smtp_port), timeout=3, ): checks["smtp"] = True except Exception: pass healthy = checks["postgres"] and checks["mongodb"] if settings.inbound_smtp_enabled: healthy = healthy and checks["inbound_smtp"] if settings.smtp_health_required: healthy = healthy and checks["smtp"] if not healthy: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return {"status": "healthy" if healthy else "degraded", "checks": checks}