from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import get_settings from app.database import get_postgres_session from app.models import Account, MailDomain from app.routers.auth import get_current_account from app.schemas import ( DnsVerificationResponse, DomainCreate, DomainNameCreate, MailDomainRead, SmtpCredentialCreate, SmtpCredentialCreated, SmtpCredentialRead, ) from app.services.dns_verification import verify_company_dns from app.services.domain_policy import assert_company_domain_allowed from app.services.domain_provisioning import ( account_is_domain_owner, activate_company_domain, create_smtp_credential, ensure_ownership_dns_record, ensure_platform_domain, get_account_company_domain, get_owned_domain, list_owned_domains, provision_mail_domain, validate_domain, ) router = APIRouter(prefix="/domains", tags=["domains"]) async def require_domain_owner( account: Account, session: AsyncSession, ) -> None: if account.account_type != "company": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Company accounts only.", ) if not await account_is_domain_owner(session, account.id, account.company_domain): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the domain owner can manage domains.", ) async def verify_owned_domain_dns( session: AsyncSession, domain: MailDomain, ) -> DnsVerificationResponse: settings = get_settings() if domain.status == "active": return DnsVerificationResponse( domain=domain.domain_name, status=domain.status, verified=True, checks=[], ) verification_token = await ensure_ownership_dns_record(session, domain) checks = verify_company_dns( domain_name=domain.domain_name, shared_mail_host=settings.smtp_public_host, dkim_selector=domain.dkim_selector, dkim_public_key=domain.dkim_public_key, platform_spf_include=settings.platform_spf_include, verification_token=verification_token, ) verified = all(check["verified"] for check in checks) if verified: await activate_company_domain(session, domain) await session.commit() return DnsVerificationResponse( domain=domain.domain_name, status=domain.status if not verified else "active", verified=verified, checks=checks, ) async def get_domain_or_404( domain_id: UUID, session: AsyncSession, ) -> MailDomain: domain = await session.scalar( select(MailDomain) .options( selectinload(MailDomain.dns_records), selectinload(MailDomain.smtp_credentials), ) .where(MailDomain.id == domain_id) ) if domain is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found.", ) return domain @router.get("/me", response_model=MailDomainRead) async def get_my_company_domain( account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> MailDomain: if account.account_type != "company" or not account.company_domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No company domain is linked to this account.", ) domain = await get_account_company_domain( session, account.id, account.company_domain, ) if domain is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Company domain has not been provisioned yet.", ) return domain @router.post("/me/verify-dns", response_model=DnsVerificationResponse) async def verify_my_company_dns( account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> DnsVerificationResponse: settings = get_settings() if account.account_type != "company" or not account.company_domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No company domain is linked to this account.", ) domain = await get_account_company_domain( session, account.id, account.company_domain, ) if domain is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Company domain has not been provisioned yet.", ) if domain.status == "active": return DnsVerificationResponse( domain=domain.domain_name, status=domain.status, verified=True, checks=[], ) return await verify_owned_domain_dns(session, domain) @router.get("/me/domains", response_model=list[MailDomainRead]) async def list_my_domains( account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> list[MailDomain]: await require_domain_owner(account, session) return await list_owned_domains(session, account.id) @router.post("/me/domains", response_model=MailDomainRead, status_code=status.HTTP_201_CREATED) async def add_my_domain( payload: DomainNameCreate, account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> MailDomain: settings = get_settings() await require_domain_owner(account, session) try: normalized = assert_company_domain_allowed( payload.domain_name, settings.platform_domain, ) except ValueError as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error), ) from error existing = await session.scalar( select(MailDomain).where(MailDomain.domain_name == normalized) ) if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="That domain is already registered.", ) domain = await provision_mail_domain( session, domain_name=normalized, domain_type="company", account_id=account.id, ) await session.commit() return await get_owned_domain(session, domain.id, account.id) @router.get("/me/domains/{domain_id}", response_model=MailDomainRead) async def get_my_domain( domain_id: UUID, account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> MailDomain: await require_domain_owner(account, session) domain = await get_owned_domain(session, domain_id, account.id) if domain is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found.", ) return domain @router.post("/me/domains/{domain_id}/verify-dns", response_model=DnsVerificationResponse) async def verify_my_domain_dns( domain_id: UUID, account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> DnsVerificationResponse: await require_domain_owner(account, session) domain = await get_owned_domain(session, domain_id, account.id) if domain is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found.", ) return await verify_owned_domain_dns(session, domain) @router.get("/platform", response_model=MailDomainRead) async def get_platform_domain( session: AsyncSession = Depends(get_postgres_session), ) -> MailDomain: domain = await ensure_platform_domain(session) await session.commit() return await get_domain_or_404(domain.id, session) @router.get("", response_model=list[MailDomainRead]) async def list_domains( session: AsyncSession = Depends(get_postgres_session), ) -> list[MailDomain]: await ensure_platform_domain(session) await session.commit() result = await session.scalars( select(MailDomain) .options(selectinload(MailDomain.dns_records)) .order_by(MailDomain.created_at.desc()) ) return list(result.all()) @router.post("", response_model=MailDomainRead, status_code=status.HTTP_201_CREATED) async def register_company_domain( payload: DomainCreate, session: AsyncSession = Depends(get_postgres_session), ) -> MailDomain: settings = get_settings() try: normalized = assert_company_domain_allowed( payload.domain_name, settings.platform_domain, ) except ValueError as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error), ) from error existing = await session.scalar( select(MailDomain).where(MailDomain.domain_name == normalized) ) if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="That domain is already registered.", ) domain = await provision_mail_domain( session, domain_name=normalized, domain_type="company", account_id=payload.account_id, ) await session.commit() return await get_domain_or_404(domain.id, session) @router.get("/{domain_id}", response_model=MailDomainRead) async def get_domain( domain_id: UUID, session: AsyncSession = Depends(get_postgres_session), ) -> MailDomain: return await get_domain_or_404(domain_id, session) @router.get("/{domain_id}/dns-records", response_model=list) async def list_dns_records( domain_id: UUID, session: AsyncSession = Depends(get_postgres_session), ): domain = await get_domain_or_404(domain_id, session) return [ { "type": record.record_type, "host": record.host, "value": record.value, "purpose": record.purpose, "ttl": record.ttl, } for record in domain.dns_records ] @router.get("/{domain_id}/smtp-credentials", response_model=list[SmtpCredentialRead]) async def list_smtp_credentials( domain_id: UUID, session: AsyncSession = Depends(get_postgres_session), ): domain = await get_domain_or_404(domain_id, session) return domain.smtp_credentials @router.post( "/{domain_id}/smtp-credentials", response_model=SmtpCredentialCreated, status_code=status.HTTP_201_CREATED, ) async def create_domain_smtp_credential( domain_id: UUID, payload: SmtpCredentialCreate, session: AsyncSession = Depends(get_postgres_session), ): settings = get_settings() domain = await get_domain_or_404(domain_id, session) credential, plain_password = await create_smtp_credential( session, domain, label=payload.label, ) await session.commit() return SmtpCredentialCreated( id=credential.id, username=credential.username, label=credential.label, is_active=credential.is_active, created_at=credential.created_at, password=plain_password, smtp_host=settings.smtp_public_host, smtp_port=settings.smtp_port, use_tls=settings.smtp_use_tls, )