agentic.email/backend/app/routers/domains.py
2026-10-04 15:35:17 +05:30

365 lines
11 KiB
Python

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,
)