import ipaddress import re import secrets import string from pathlib import Path from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from pwdlib import PasswordHash from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import get_settings from app.models import DomainDnsRecord, MailDomain, SmtpCredential password_hasher = PasswordHash.recommended() domain_pattern = re.compile( r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+" r"[a-z]{2,63}$" ) def normalize_domain(value: str) -> str: return value.strip().lower().removeprefix("@") def validate_domain(value: str) -> str: domain = normalize_domain(value) if not domain_pattern.fullmatch(domain): raise ValueError("Enter a valid domain name, such as example.com.") return domain def generate_dkim_keys() -> tuple[str, str]: private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ).decode() public_pem = ( private_key.public_key() .public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) .decode() ) public_key = "".join( line for line in public_pem.splitlines() if not line.startswith("-----") ) return private_pem, public_key def namecheap_host(host: str, domain_name: str) -> str: """Strip the zone suffix so records match Namecheap's Host field.""" if host in {"@", "*"}: return host suffix = f".{domain_name}" if host.endswith(suffix): return host[: -len(suffix)] return host def spf_ip_mechanism(mail_server_ip: str | None) -> str | None: if not mail_server_ip or mail_server_ip == "YOUR_MAIL_SERVER_IP": return None try: address = ipaddress.ip_address(mail_server_ip.strip()) except ValueError: return None return f"ip6:{address}" if address.version == 6 else f"ip4:{address}" def build_spf_value(mail_server_ip: str | None = None) -> str: parts = ["v=spf1", "mx", "a"] ip_mechanism = spf_ip_mechanism(mail_server_ip) if ip_mechanism: parts.append(ip_mechanism) parts.append("~all") return " ".join(parts) def build_shared_mx_spf_value(platform_spf_include: str) -> str: return f"v=spf1 include:{platform_spf_include} ~all" def build_company_dns_records( domain_name: str, shared_mail_host: str, dkim_selector: str, dkim_public_key: str, platform_spf_include: str, ) -> list[dict]: """Zoho-style records: shared MX host, SPF include, per-domain DKIM/DMARC.""" 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": "MX", "host": namecheap_host("@", domain_name), "value": shared_mail_host, "priority": 10, "purpose": "mx", "ttl": 3600, }, { "record_type": "TXT", "host": namecheap_host("@", domain_name), "value": build_shared_mx_spf_value(platform_spf_include), "purpose": "spf", "ttl": 3600, }, { "record_type": "TXT", "host": namecheap_host(f"{dkim_selector}._domainkey", domain_name), "value": dkim_value, "purpose": "dkim", "ttl": 3600, }, { "record_type": "TXT", "host": namecheap_host("_dmarc", domain_name), "value": dmarc_value, "purpose": "dmarc", "ttl": 3600, }, ] def build_dns_records( domain_name: str, mail_host: str, dkim_selector: str, dkim_public_key: str, mail_server_ip: str | None = None, ) -> list[dict]: dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}" dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}" mail_subdomain = mail_host.removesuffix(f".{domain_name}") return [ { "record_type": "MX", "host": namecheap_host("@", domain_name), "value": mail_host, "priority": 10, "purpose": "mx", "ttl": 3600, }, { "record_type": "A", "host": namecheap_host(mail_subdomain, domain_name), "value": mail_server_ip or "YOUR_MAIL_SERVER_IP", "purpose": "mail_host", "ttl": 3600, }, { "record_type": "TXT", "host": namecheap_host("@", domain_name), "value": build_spf_value(mail_server_ip), "purpose": "spf", "ttl": 3600, }, { "record_type": "TXT", "host": namecheap_host(f"{dkim_selector}._domainkey", domain_name), "value": dkim_value, "purpose": "dkim", "ttl": 3600, }, { "record_type": "TXT", "host": namecheap_host("_dmarc", domain_name), "value": dmarc_value, "purpose": "dmarc", "ttl": 3600, }, ] def build_deployment_dns_records( *, domain_name: str, mail_server_ip: str, dkim_selector: str, dkim_public_key: str | None, app_public_host: str | None = None, ) -> dict[str, list[dict]]: """Split records into inbound delivery vs outbound authentication.""" mail_host = f"mail.{domain_name}" dkim_key = dkim_public_key or ( "REGISTER_DOMAIN_TO_GENERATE_DKIM_PUBLIC_KEY" ) records = build_dns_records( domain_name, mail_host, dkim_selector, dkim_key, mail_server_ip=mail_server_ip, ) by_purpose = {record["purpose"]: record for record in records} inbound = [by_purpose["mx"], by_purpose["mail_host"]] if app_public_host: inbound.append( { "record_type": "A", "host": namecheap_host(app_public_host, domain_name), "value": mail_server_ip, "purpose": "app_host", "ttl": 3600, } ) outbound = [by_purpose["spf"], by_purpose["dkim"], by_purpose["dmarc"]] ip_mechanism = spf_ip_mechanism(mail_server_ip) if ip_mechanism: outbound.insert( 0, { "record_type": "TXT", "host": namecheap_host("_spf", domain_name), "value": f"v=spf1 {ip_mechanism} ~all", "purpose": "platform_spf", "ttl": 3600, }, ) return {"inbound": inbound, "outbound": outbound} def generate_smtp_username(domain_name: str) -> str: slug = domain_name.replace(".", "-") token = secrets.token_hex(4) return f"smtp-{slug}-{token}" def generate_smtp_password(length: int = 24) -> str: alphabet = string.ascii_letters + string.digits return "".join(secrets.choice(alphabet) for _ in range(length)) def record_for_db(record: dict) -> dict: allowed = {"record_type", "host", "value", "purpose", "ttl"} return {key: record[key] for key in allowed if key in record} async def get_domain_by_name( session: AsyncSession, domain_name: str, ) -> MailDomain | None: return await session.scalar( select(MailDomain).where(MailDomain.domain_name == domain_name) ) async def provision_mail_domain( session: AsyncSession, *, domain_name: str, domain_type: str, account_id=None, ) -> MailDomain: settings = get_settings() normalized = validate_domain(domain_name) existing = await get_domain_by_name(session, normalized) if existing: return existing mail_host = f"mail.{normalized}" dkim_selector = settings.dkim_selector private_key, public_key = generate_dkim_keys() if domain_type == "company": mail_host = settings.smtp_public_host dns_records = build_company_dns_records( normalized, settings.smtp_public_host, dkim_selector, public_key, settings.platform_spf_include, ) else: dns_records = build_dns_records( normalized, mail_host, dkim_selector, public_key, ) domain = MailDomain( domain_name=normalized, domain_type=domain_type, account_id=account_id, mail_host=mail_host, dkim_selector=dkim_selector, dkim_private_key=private_key, dkim_public_key=public_key, status="pending_dns", ) session.add(domain) await session.flush() for record in dns_records: session.add(DomainDnsRecord(domain_id=domain.id, **record_for_db(record))) await session.flush() return domain def register_relay_domain(domain_name: str) -> None: """Append a verified company domain for Postfix inbound relay.""" settings = get_settings() normalized = validate_domain(domain_name) path = Path(settings.relay_domains_file) path.parent.mkdir(parents=True, exist_ok=True) existing = { line.strip().lower() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() } if path.exists() else set() if normalized in existing: return with path.open("a", encoding="utf-8") as handle: handle.write(f"{normalized}\n") async def activate_company_domain(session: AsyncSession, domain: MailDomain) -> MailDomain: domain.status = "active" register_relay_domain(domain.domain_name) await session.flush() return domain async def get_account_company_domain( session: AsyncSession, account_id, company_domain_name: str | None, ) -> MailDomain | None: if not company_domain_name: return None return await session.scalar( select(MailDomain) .options(selectinload(MailDomain.dns_records)) .where( MailDomain.domain_name == company_domain_name, MailDomain.account_id == account_id, ) ) async def create_smtp_credential( session: AsyncSession, domain: MailDomain, *, label: str, ) -> tuple[SmtpCredential, str]: username = generate_smtp_username(domain.domain_name) plain_password = generate_smtp_password() credential = SmtpCredential( domain_id=domain.id, username=username, password_hash=password_hasher.hash(plain_password), label=label, is_active=True, ) session.add(credential) await session.flush() return credential, plain_password async def ensure_platform_domain(session: AsyncSession) -> MailDomain: settings = get_settings() domain = await get_domain_by_name(session, settings.platform_domain) if domain: return domain return await provision_mail_domain( session, domain_name=settings.platform_domain, domain_type="platform", )