company flows

This commit is contained in:
debu-2 2026-10-04 15:35:17 +05:30
parent c04a6ce6fe
commit a3a6464a99
15 changed files with 963 additions and 60 deletions

View file

@ -23,7 +23,7 @@ class Settings(BaseSettings):
platform_domain: str = "gentic.email"
platform_spf_include: str = "_spf.gentic.email"
app_public_host: str = "email.gentic.email"
app_public_host: str = "gentic.email"
dkim_selector: str = "agmail"
# Outbound via self-hosted Postfix (Docker service `smtp` by default)

View file

@ -12,6 +12,7 @@ from app.routers.auth import get_current_account
from app.schemas import (
DnsVerificationResponse,
DomainCreate,
DomainNameCreate,
MailDomainRead,
SmtpCredentialCreate,
SmtpCredentialCreated,
@ -20,11 +21,14 @@ from app.schemas import (
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,
)
@ -32,6 +36,56 @@ from app.services.domain_provisioning import (
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,
@ -104,25 +158,86 @@ async def verify_my_company_dns(
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,
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)
)
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,
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)

View file

@ -143,6 +143,10 @@ class DomainCreate(BaseModel):
account_id: UUID | None = None
class DomainNameCreate(BaseModel):
domain_name: str = Field(min_length=3, max_length=253)
class DnsRecordRead(BaseModel):
id: UUID
record_type: str

View file

@ -385,6 +385,37 @@ async def account_is_domain_owner(
return domain_id is not None
async def get_owned_domain(
session: AsyncSession,
domain_id,
account_id,
) -> MailDomain | None:
return await session.scalar(
select(MailDomain)
.options(
selectinload(MailDomain.dns_records),
selectinload(MailDomain.smtp_credentials),
)
.where(
MailDomain.id == domain_id,
MailDomain.account_id == account_id,
)
)
async def list_owned_domains(
session: AsyncSession,
account_id,
) -> list[MailDomain]:
result = await session.scalars(
select(MailDomain)
.options(selectinload(MailDomain.dns_records))
.where(MailDomain.account_id == account_id)
.order_by(MailDomain.created_at.asc())
)
return list(result.all())
async def get_account_company_domain(
session: AsyncSession,
account_id,

View file

@ -48,7 +48,7 @@ def test_deployment_sections_split_inbound_and_outbound():
mail_server_ip="203.0.113.10",
dkim_selector="agmail",
dkim_public_key="test-public-key",
app_public_host="email.gentic.email",
app_public_host="gentic.email",
)
inbound_purposes = [record["purpose"] for record in sections["inbound"]]