380 lines
11 KiB
Python
380 lines
11 KiB
Python
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",
|
|
)
|