domain migration

This commit is contained in:
debu-2 2026-10-04 14:40:06 +05:30
parent 192b810eb4
commit c04a6ce6fe
26 changed files with 777 additions and 125 deletions

View file

@ -21,15 +21,15 @@ class Settings(BaseSettings):
jwt_secret: str = "replace-this-development-secret"
access_token_minutes: int = 60
platform_domain: str = "getall.help"
platform_spf_include: str = "_spf.getall.help"
app_public_host: str = "email.getall.help"
platform_domain: str = "gentic.email"
platform_spf_include: str = "_spf.gentic.email"
app_public_host: str = "email.gentic.email"
dkim_selector: str = "agmail"
# Outbound via self-hosted Postfix (Docker service `smtp` by default)
smtp_host: str = "smtp"
smtp_port: int = 25
smtp_public_host: str = "mail.getall.help"
smtp_public_host: str = "mail.gentic.email"
smtp_username: str = ""
smtp_password: str = ""
smtp_use_tls: bool = False

View file

@ -15,6 +15,8 @@ from app.config import get_settings
from app.database import get_postgres_session
from app.models import Account
from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest
from app.services.domain_policy import assert_company_domain_allowed
from app.services.domain_provisioning import account_is_domain_owner
from app.services.domain_provisioning import (
get_domain_by_name,
provision_mail_domain,
@ -50,7 +52,7 @@ def validate_password(value: str) -> None:
def build_email(payload: AccountRegister) -> tuple[str, str | None]:
domain = "getall.help"
domain = settings.platform_domain
company_domain = None
if payload.account_type == "company":
@ -59,12 +61,16 @@ def build_email(payload: AccountRegister) -> tuple[str, str | None]:
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Company domain is required for company accounts.",
)
domain = payload.company_domain.strip().lower().removeprefix("@")
if not domain_pattern.fullmatch(domain):
try:
domain = assert_company_domain_allowed(
payload.company_domain,
settings.platform_domain,
)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Enter a valid company domain, such as example.com.",
)
detail=str(error),
) from error
company_domain = domain
email = f"{payload.email_name.lower()}@{domain}"
@ -88,6 +94,21 @@ def create_access_token(account: Account) -> str:
)
async def account_read(
session: AsyncSession,
account: Account,
) -> AccountRead:
is_owner = await account_is_domain_owner(
session,
account.id,
account.company_domain,
)
return AccountRead.model_validate(
account,
update={"is_domain_owner": is_owner},
)
async def get_current_account(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: AsyncSession = Depends(get_postgres_session),
@ -180,7 +201,7 @@ async def register(
await session.refresh(account)
return AuthResponse(
access_token=create_access_token(account),
account=AccountRead.model_validate(account),
account=await account_read(session, account),
)
@ -203,10 +224,13 @@ async def login(
)
return AuthResponse(
access_token=create_access_token(account),
account=AccountRead.model_validate(account),
account=await account_read(session, account),
)
@router.get("/me", response_model=AccountRead)
async def get_me(account: Account = Depends(get_current_account)) -> Account:
return account
async def get_me(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> AccountRead:
return await account_read(session, account)

View file

@ -18,9 +18,11 @@ from app.schemas import (
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 (
activate_company_domain,
create_smtp_credential,
ensure_ownership_dns_record,
ensure_platform_domain,
get_account_company_domain,
provision_mail_domain,
@ -102,12 +104,14 @@ 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,
)
verified = all(check["verified"] for check in checks)
if verified:
@ -151,19 +155,16 @@ async def register_company_domain(
) -> MailDomain:
settings = get_settings()
try:
normalized = validate_domain(payload.domain_name)
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
if normalized == settings.platform_domain:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Use the platform domain endpoint for getall.help.",
)
existing = await session.scalar(
select(MailDomain).where(MailDomain.domain_name == normalized)
)

View file

@ -34,6 +34,7 @@ class AccountRead(BaseModel):
date_of_birth: date
phone_number: str
company_domain: str | None
is_domain_owner: bool = False
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View file

@ -5,6 +5,8 @@ from __future__ import annotations
import dns.exception
import dns.resolver
OWNERSHIP_PREFIX = "agmail-verify="
def _normalize_txt(value: str) -> str:
return " ".join(value.split()).strip().lower()
@ -51,6 +53,29 @@ def _lookup_mx(domain_name: str) -> list[tuple[int, str]]:
return hosts
def ownership_record_value(token: str) -> str:
return f"{OWNERSHIP_PREFIX}{token}"
def ownership_token_from_value(value: str) -> str:
normalized = value.strip()
if normalized.lower().startswith(OWNERSHIP_PREFIX):
return normalized[len(OWNERSHIP_PREFIX) :]
return normalized
def verify_ownership(domain_name: str, verification_token: str) -> tuple[bool, str]:
name = f"_agmail-verify.{domain_name}"
expected = _normalize_txt(ownership_record_value(verification_token))
records = [_normalize_txt(record) for record in _lookup_txt(name)]
if expected in records:
return True, "Domain ownership verified via TXT record."
return (
False,
f"Add TXT at _agmail-verify.{domain_name} with value {ownership_record_value(verification_token)}",
)
def verify_mx(domain_name: str, expected_host: str) -> tuple[bool, str]:
expected = expected_host.rstrip(".").lower()
hosts = _lookup_mx(domain_name)
@ -96,7 +121,7 @@ def verify_platform_spf(platform_spf_host: str, mail_host: str) -> tuple[bool, s
if not spf_records:
return (
False,
f"No SPF TXT at {platform_spf_host}. On getall.help add Host _spf with "
f"No SPF TXT at {platform_spf_host}. On the platform domain add Host _spf with "
f"v=spf1 ip4:<mail-server-ip> ~all.",
)
@ -138,10 +163,13 @@ def verify_dmarc(domain_name: str, expected_value: str) -> tuple[bool, str]:
return False, f"No DMARC TXT record found at {name}."
expected = _normalize_txt(expected_value)
for record in records:
if record == expected or record.startswith("v=dmarc1"):
if "v=dmarc1" in record:
return True, "DMARC record is present."
return False, "DMARC TXT record is missing or incomplete."
if record == expected:
return True, "DMARC record matches the expected value."
return (
False,
"DMARC TXT must match the value shown in setup exactly "
"(do not rely on an existing third-party DMARC record).",
)
def verify_company_dns(
@ -151,15 +179,14 @@ def verify_company_dns(
dkim_selector: str,
dkim_public_key: str,
platform_spf_include: str,
verification_token: str,
) -> list[dict]:
"""Customer-domain checks only (ownership + mail authentication)."""
dmarc_expected = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
checks = [
("ownership", *verify_ownership(domain_name, verification_token)),
("mx", *verify_mx(domain_name, shared_mail_host)),
("spf", *verify_spf(domain_name, platform_spf_include)),
(
"platform_spf",
*verify_platform_spf(platform_spf_include, shared_mail_host),
),
(
"dkim",
*verify_dkim(domain_name, dkim_selector, dkim_public_key),

View file

@ -0,0 +1,49 @@
"""Company domain registration policy."""
from __future__ import annotations
from app.services.domain_provisioning import normalize_domain, validate_domain
# Well-known domains that must never be used for company onboarding.
BLOCKED_COMPANY_DOMAINS = frozenset(
{
"google.com",
"gmail.com",
"yahoo.com",
"outlook.com",
"hotmail.com",
"live.com",
"icloud.com",
"apple.com",
"microsoft.com",
"amazon.com",
"facebook.com",
"meta.com",
"linkedin.com",
"proton.me",
"protonmail.com",
"aol.com",
"zoho.com",
"fastmail.com",
"yandex.com",
"mail.ru",
"cloudflare.com",
"github.com",
}
)
def assert_company_domain_allowed(domain: str, platform_domain: str) -> str:
"""Validate format and reject reserved or platform domains."""
normalized = validate_domain(domain)
if normalized == normalize_domain(platform_domain):
raise ValueError(
f"You cannot register {platform_domain} as a company domain. "
"Use a domain your organization controls."
)
if normalized in BLOCKED_COMPANY_DOMAINS:
raise ValueError(
"That domain cannot be used for company signup. "
"Enter a domain you control and can edit in DNS."
)
return normalized

View file

@ -94,11 +94,21 @@ def build_company_dns_records(
dkim_selector: str,
dkim_public_key: str,
platform_spf_include: str,
verification_token: str,
) -> list[dict]:
"""Zoho-style records: shared MX host, SPF include, per-domain DKIM/DMARC."""
"""Zoho-style records: ownership proof, shared MX host, SPF include, DKIM/DMARC."""
from app.services.dns_verification import ownership_record_value
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
return [
{
"record_type": "TXT",
"host": namecheap_host("_agmail-verify", domain_name),
"value": ownership_record_value(verification_token),
"purpose": "ownership",
"ttl": 3600,
},
{
"record_type": "MX",
"host": namecheap_host("@", domain_name),
@ -273,12 +283,14 @@ async def provision_mail_domain(
private_key, public_key = generate_dkim_keys()
if domain_type == "company":
mail_host = settings.smtp_public_host
verification_token = secrets.token_urlsafe(24)
dns_records = build_company_dns_records(
normalized,
settings.smtp_public_host,
dkim_selector,
public_key,
settings.platform_spf_include,
verification_token,
)
else:
dns_records = build_dns_records(
@ -331,6 +343,48 @@ async def activate_company_domain(session: AsyncSession, domain: MailDomain) ->
return domain
async def ensure_ownership_dns_record(session: AsyncSession, domain: MailDomain) -> str:
"""Return the ownership verification token, creating a DNS record if missing."""
from app.services.dns_verification import ownership_record_value, ownership_token_from_value
existing = next(
(record for record in domain.dns_records if record.purpose == "ownership"),
None,
)
if existing:
return ownership_token_from_value(existing.value)
token = secrets.token_urlsafe(24)
session.add(
DomainDnsRecord(
domain_id=domain.id,
record_type="TXT",
host=namecheap_host("_agmail-verify", domain.domain_name),
value=ownership_record_value(token),
purpose="ownership",
ttl=3600,
)
)
await session.flush()
return token
async def account_is_domain_owner(
session: AsyncSession,
account_id,
company_domain_name: str | None,
) -> bool:
if not company_domain_name:
return False
domain_id = await session.scalar(
select(MailDomain.id).where(
MailDomain.domain_name == company_domain_name,
MailDomain.account_id == account_id,
)
)
return domain_id is not None
async def get_account_company_domain(
session: AsyncSession,
account_id,