360 lines
11 KiB
Python
360 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()
|
|
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 domain.status != "active" and verified:
|
|
await activate_company_domain(session, domain)
|
|
await session.commit()
|
|
status = "active"
|
|
else:
|
|
status = domain.status
|
|
return DnsVerificationResponse(
|
|
domain=domain.domain_name,
|
|
status=status,
|
|
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,
|
|
)
|