318 lines
10 KiB
Python
318 lines
10 KiB
Python
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 (
|
|
agent_handoff,
|
|
aliases,
|
|
auth,
|
|
dns_setup,
|
|
domains,
|
|
emails,
|
|
groups,
|
|
org,
|
|
people,
|
|
sections,
|
|
sticky_notes,
|
|
threads,
|
|
)
|
|
from app.services.thread_service import create_thread, message_event_at
|
|
from app.services.domain_provisioning import ensure_platform_domain
|
|
from app.services.inbound_smtp import InboundSMTPServer
|
|
|
|
settings = get_settings()
|
|
inbound_server = InboundSMTPServer()
|
|
|
|
|
|
async def backfill_missing_thread_ids(database) -> None:
|
|
cursor = database.mailbox_entries.find({"thread_id": None})
|
|
async for entry in cursor:
|
|
message = await database.messages.find_one({"_id": entry["message_id"]})
|
|
if message is None:
|
|
continue
|
|
thread_id = await create_thread(
|
|
database,
|
|
account_id=entry["account_id"],
|
|
subject=entry.get("subject") or message.get("subject", ""),
|
|
message_id=entry["message_id"],
|
|
event_at=entry.get("event_at") or message_event_at(message),
|
|
)
|
|
await database.mailbox_entries.update_one(
|
|
{"_id": entry["_id"]},
|
|
{"$set": {"thread_id": thread_id}},
|
|
)
|
|
|
|
|
|
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",
|
|
)
|
|
await mongo_db.messages.create_index(
|
|
[("message_id", 1)],
|
|
sparse=True,
|
|
)
|
|
await mongo_db.mailbox_entries.create_index(
|
|
[("account_id", 1), ("thread_id", 1), ("event_at", -1), ("_id", -1)]
|
|
)
|
|
await mongo_db.email_threads.create_index(
|
|
[("account_id", 1), ("last_message_at", -1), ("_id", -1)]
|
|
)
|
|
await mongo_db.human_handoff_rules.create_index(
|
|
[("account_id", 1), ("updated_at", -1)]
|
|
)
|
|
await mongo_db.human_handoffs.create_index(
|
|
[("account_id", 1), ("updated_at", -1)]
|
|
)
|
|
await mongo_db.human_handoffs.create_index(
|
|
[("account_id", 1), ("external_thread_id", 1)]
|
|
)
|
|
await mongo_db.human_handoffs.create_index(
|
|
[("account_id", 1), ("handoff_thread_id", 1)]
|
|
)
|
|
await mongo_db.handoff_message_links.create_index(
|
|
[("handoff_id", 1), ("created_at", -1)]
|
|
)
|
|
await mongo_db.mailbox_entries.create_index(
|
|
[("account_id", 1), ("message_scope", 1), ("mailbox", 1), ("event_at", -1)]
|
|
)
|
|
await mongo_db.messages.create_index(
|
|
[("message_id", 1), ("message_scope", 1)],
|
|
sparse=True,
|
|
)
|
|
await backfill_missing_thread_ids(mongo_db)
|
|
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(threads.router, prefix=settings.api_prefix)
|
|
app.include_router(agent_handoff.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}
|