"""Provisionamento local via zmprov (Carbonio).""" from __future__ import annotations import re import subprocess from pathlib import Path from app.config import settings NGINX_WEB_HTTPS = Path("/opt/zextras/conf/nginx/includes/nginx.conf.web.https") ZMPROXYCONFGEN = "/opt/zextras/libexec/zmproxyconfgen" from app.services import activity_log, carbonio_cache EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") _NO_SUCH_DOMAIN = "NO_SUCH_DOMAIN" _NO_SUCH_ACCOUNT = "NO_SUCH_ACCOUNT" class CarbonioError(Exception): pass def _zmprov_run(*args: str, log_cmd: bool = True) -> tuple[int, str, str]: cmd = ["sudo", "-u", settings.zextras_user, settings.zmprov, *args] if log_cmd: activity_log.cmd(" ".join(cmd), source="vm112") proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip() def _run_zmprov(*args: str) -> str: """Comando zmprov que deve ter sucesso (criar domínio/conta, etc.).""" code, out, err = _zmprov_run(*args, log_cmd=True) if code != 0: msg = err or out or "zmprov failed" activity_log.error(f"zmprov falhou: {msg}", source="vm112") raise CarbonioError(msg) if out: activity_log.ok(out[:500], source="vm112") return out def _is_missing_domain(stderr: str, stdout: str) -> bool: blob = f"{stderr}\n{stdout}" return _NO_SUCH_DOMAIN in blob def _is_missing_account(stderr: str, stdout: str) -> bool: blob = f"{stderr}\n{stdout}" return _NO_SUCH_ACCOUNT in blob or _NO_SUCH_DOMAIN in blob def _is_account_exists(stderr: str, stdout: str) -> bool: blob = f"{stderr}\n{stdout}" return "ACCOUNT_EXISTS" in blob def _carbonio_unreachable(err: str, out: str) -> bool: blob = f"{err}\n{out}".lower() return "connection refused" in blob or "io_error" in blob def domain_exists(domain: str, *, use_cache: bool = True) -> bool: """Verificação silenciosa — domínio inexistente NÃO é erro de onboarding.""" domain = domain.lower().strip() cache_key = f"domain_exists:{domain}" if use_cache: cached = carbonio_cache.get(cache_key) if cached is not None: return bool(cached) code, out, err = _zmprov_run("gd", domain, log_cmd=False) if code == 0: exists = f"# name {domain}" in out or f"name {domain}" in out elif _is_missing_domain(err, out): exists = False elif _carbonio_unreachable(err, out): exists = False else: activity_log.warn(f"zmprov gd {domain}: {err or out}", source="vm112") exists = False if use_cache: carbonio_cache.set(cache_key, exists, carbonio_cache.TTL_DOMAIN_EXISTS) return exists def list_all_domains(*, use_cache: bool = True) -> list[str]: """zmprov gad (~5s) — cache TTL curto para Serviços IaaS / Desk.""" cache_key = carbonio_cache.CACHE_KEY_ALL_DOMAINS if use_cache: cached = carbonio_cache.get(cache_key) if cached is not None: return list(cached) code, out, _err = _zmprov_run("gad", log_cmd=False) if code != 0: return [] domains = [ln.strip().lower() for ln in out.splitlines() if ln.strip()] if use_cache: carbonio_cache.set(cache_key, domains, carbonio_cache.TTL_ALL_DOMAINS) return domains def invalidate_domain_list_cache() -> None: carbonio_cache.invalidate_all_domains() def set_domain_public_hostname(domain: str) -> str: """Webmail mail.{domínio} — evita redirect para mail.ligbox.com.br.""" domain = domain.lower().strip() mail_host = f"mail.{domain}" logo = f"https://{mail_host}/public/logos/ligbox-logo.png" admin = f"admin@{domain}" _run_zmprov( "md", domain, "zimbraPublicServiceHostname", mail_host, "zimbraVirtualHostname", mail_host, "zimbraPublicServiceProtocol", "https", "zimbraPublicServicePort", "443", "zimbraSkinLogoURL", logo, "carbonioLogoUrl", logo, "carbonioNotificationFrom", admin, "carbonioNotificationRecipients", admin, ) return mail_host def add_mail_alias_hostnames(domain: str, hostnames: list[str]) -> None: """Adiciona hostnames virtuais (alias webmail) ao domínio Carbonio.""" domain = domain.lower().strip() primary_mail = f"mail.{domain}" for host in hostnames: h = host.lower().strip().rstrip(".") if not h or h == primary_mail: continue try: _run_zmprov("md", domain, "zimbraVirtualHostname", h) activity_log.ok(f"Alias hostname Carbonio: {h}", source="vm112") except CarbonioError as e: activity_log.warn(f"Alias {h}: {e}", source="vm112") def create_domain(domain: str) -> str: domain = domain.lower().strip() if domain_exists(domain): activity_log.info(f"Domínio {domain} já existe no Carbonio", source="vm112") set_domain_public_hostname(domain) return f"domain {domain} already exists" activity_log.info(f"Criar domínio no Carbonio: {domain}", source="vm112") out = _run_zmprov("cd", domain) set_domain_public_hostname(domain) invalidate_domain_list_cache() activity_log.ok(f"Webmail: https://mail.{domain}/", source="vm112") return out def create_account(email: str, password: str, display_name: str | None = None) -> str: return create_account_full( email, password, display_name=display_name, quota_mb=0, must_change_password=False, ) def ensure_onboarding_account( email: str, password: str, display_name: str | None = None, ) -> tuple[str, bool]: """ Cria conta admin no onboarding ou reutiliza se já existir (retry do wizard). Returns (message, reused). """ email = email.lower().strip() domain = email.split("@", 1)[1] if account_exists(email): activity_log.info( f"Conta {email} já existe no Carbonio — actualizar senha e continuar", source="vm112", ) if display_name: try: _run_zmprov("ma", email, "displayName", display_name) except CarbonioError: pass set_password(email, password) set_password_must_change(email, False) invalidate_domain_cache(domain) return f"account {email} already exists (password updated)", True msg = create_account(email, password, display_name) return msg, False def create_account_full( email: str, password: str, *, display_name: str | None = None, quota_mb: int = 0, must_change_password: bool = True, ) -> str: """Uma única chamada zmprov ca (evita 3–4 round-trips de ~5s cada).""" if not EMAIL_RE.match(email): raise CarbonioError("invalid email") domain = email.split("@", 1)[1] if not domain_exists(domain): create_domain(domain) activity_log.info(f"Criar conta: {email}", source="vm112") args = ["ca", email, password] if display_name: args.extend(["displayName", display_name]) if quota_mb > 0: args.extend(["zimbraMailQuota", str(int(quota_mb) * 1024 * 1024)]) if must_change_password: args.extend(["zimbraPasswordMustChange", "TRUE"]) out = _run_zmprov(*args) invalidate_domain_cache(domain) return out def account_summary_fast( email: str, *, display_name: str | None = None, quota_mb: int = 0, must_change_password: bool = True, ) -> dict: """Resposta imediata sem zmprov ga (~5s).""" quota_bytes = int(quota_mb) * 1024 * 1024 if quota_mb > 0 else 0 return { "email": email.lower(), "display_name": display_name or "", "status": "active", "active": True, "suspended": False, "quota_bytes": quota_bytes, "quota_label": _quota_label(str(quota_bytes)), "two_factor_enabled": False, "password_must_change": must_change_password, "last_logon": None, } def list_accounts(domain: str, *, use_cache: bool = True) -> list[str]: """Lista contas do domínio (zmprov -l gaa ~3–5s — cache TTL curto).""" domain = domain.lower().strip() cache_key = f"accounts_list:{domain}" if use_cache: cached = carbonio_cache.get(cache_key) if cached is not None: return list(cached) code, out, err = _zmprov_run("-l", "gaa", domain, log_cmd=False) if code != 0: msg = err or out or "zmprov gaa failed" raise CarbonioError(msg) result = sorted(line.strip() for line in out.splitlines() if line.strip()) if use_cache: carbonio_cache.set(cache_key, result, carbonio_cache.TTL_ACCOUNTS_LIST) return result def set_password(email: str, password: str) -> str: return _run_zmprov("sp", email, password) def account_exists(email: str) -> bool: code, out, err = _zmprov_run("ga", email, log_cmd=False) if code == 0: return email.lower() in out.lower() if _is_missing_account(err, out): return False activity_log.warn(f"zmprov ga {email}: {err or out}", source="vm112") return False def _parse_ga_attributes(out: str) -> dict[str, str]: attrs: dict[str, str] = {} for line in out.splitlines(): if ":" not in line: continue key, _, val = line.partition(":") attrs[key.strip()] = val.strip() return attrs def get_account_attributes(email: str) -> dict[str, str]: code, out, err = _zmprov_run("ga", email, log_cmd=False) if code != 0: raise CarbonioError(err or out or f"Conta {email} não encontrada") return _parse_ga_attributes(out) def get_domain_attributes(domain: str) -> dict[str, str]: code, out, err = _zmprov_run("gd", domain, log_cmd=False) if code != 0: raise CarbonioError(err or out or f"Domínio {domain} não encontrado") return _parse_ga_attributes(out) def _quota_label(bytes_raw: str) -> str: try: n = int(bytes_raw) except (TypeError, ValueError): return bytes_raw or "—" if n <= 0: return "Ilimitada (COS)" if n >= 1024**3: return f"{n / 1024**3:.1f} GB" if n >= 1024**2: return f"{n / 1024**2:.0f} MB" return f"{n} B" def account_summary(email: str, *, use_cache: bool = True) -> dict: email = email.lower().strip() cache_key = f"account_summary:{email}" if use_cache: cached = carbonio_cache.get(cache_key) if cached is not None: return cached attrs = get_account_attributes(email) status = attrs.get("zimbraAccountStatus", "unknown") quota_b = attrs.get("zimbraMailQuota", "0") tfa = attrs.get("zimbraTwoFactorAuthEnabled", "").upper() == "TRUE" must_change = attrs.get("zimbraPasswordMustChange", "").upper() == "TRUE" last_logon = attrs.get("zimbraLastLogonTimestamp", "") if last_logon and len(last_logon) >= 8: last_logon = f"{last_logon[0:4]}-{last_logon[4:6]}-{last_logon[6:8]}" summary = { "email": email.lower(), "display_name": attrs.get("displayName") or attrs.get("cn") or "", "status": status, "active": status == "active", "suspended": status in ("locked", "closed", "maintenance"), "quota_bytes": int(quota_b) if quota_b.isdigit() else 0, "quota_label": _quota_label(quota_b), "two_factor_enabled": tfa, "password_must_change": must_change, "last_logon": last_logon or None, "_summary": True, } if use_cache: carbonio_cache.set(cache_key, summary, carbonio_cache.TTL_ACCOUNT_SUMMARY) return summary def list_domain_accounts_fast(domain: str, *, use_cache: bool = True) -> list[dict]: """Uma chamada zmprov -l gaa (~5s) em vez de ga por conta (~5s × N).""" domain = domain.lower().strip() cache_key = f"accounts_fast:{domain}" if use_cache: cached = carbonio_cache.get(cache_key) if cached is not None: return cached emails = list_accounts(domain) result = [ { "email": e, "display_name": "", "status": "—", "active": True, "suspended": False, "quota_label": "—", "two_factor_enabled": False, "password_must_change": False, "last_logon": None, "_summary": False, } for e in emails ] if use_cache: carbonio_cache.set(cache_key, result, carbonio_cache.TTL_ACCOUNTS_LIST) return result def list_domain_accounts(domain: str, *, full: bool = False, use_cache: bool = True) -> list[dict]: if not full: return list_domain_accounts_fast(domain, use_cache=use_cache) domain = domain.lower().strip() emails = list_accounts(domain) result = [] for email in emails: try: result.append(account_summary(email, use_cache=use_cache)) except CarbonioError: result.append( { "email": email, "status": "unknown", "active": False, "suspended": False, "_summary": False, } ) return result def invalidate_domain_cache(domain: str) -> None: carbonio_cache.invalidate_domain(domain.lower().strip()) def set_account_status(email: str, active: bool) -> str: status = "active" if active else "locked" activity_log.info(f"Conta {email} → {status}", source="vm112") out = _run_zmprov("ma", email, "zimbraAccountStatus", status) carbonio_cache.invalidate_account(email) return out def set_mail_quota(email: str, quota_mb: int) -> str: if quota_mb <= 0: bytes_q = "0" else: bytes_q = str(int(quota_mb) * 1024 * 1024) activity_log.info(f"Quota {email} → {quota_mb} MB", source="vm112") out = _run_zmprov("ma", email, "zimbraMailQuota", bytes_q) carbonio_cache.invalidate_account(email) return out def set_password_must_change(email: str, must_change: bool = True) -> str: val = "TRUE" if must_change else "FALSE" out = _run_zmprov("ma", email, "zimbraPasswordMustChange", val) carbonio_cache.invalidate_account(email) return out def set_password_with_policy( email: str, password: str, *, must_change: bool = True, ) -> str: out = set_password(email, password) if must_change: set_password_must_change(email, True) else: carbonio_cache.invalidate_account(email) return out def domain_two_factor_capabilities(domain: str) -> dict: attrs = get_domain_attributes(domain) avail = attrs.get("zimbraFeatureTwoFactorAuthAvailable", "FALSE").upper() == "TRUE" required = attrs.get("zimbraFeatureTwoFactorAuthRequired", "FALSE").upper() == "TRUE" return { "available": avail, "required": required, "automated": avail, "message": ( "2FA disponível neste domínio (política via painel activa)." if avail else ( "Política 2FA via painel inactiva no COS/domínio. " "Utilizadores podem activar OTP em webmail: Settings → Auth → OTP Authentication → NEW OTP." ) ), } def set_user_two_factor(email: str, enabled: bool) -> str: caps = domain_two_factor_capabilities(email.split("@", 1)[1]) if not caps["available"]: raise CarbonioError(caps["message"]) val = "TRUE" if enabled else "FALSE" activity_log.info(f"2FA {email} → {val}", source="vm112") return _run_zmprov("ma", email, "zimbraTwoFactorAuthEnabled", val) def set_domain_two_factor_required(domain: str, required: bool) -> str: caps = domain_two_factor_capabilities(domain) if not caps["available"]: raise CarbonioError(caps["message"]) val = "TRUE" if required else "FALSE" return _run_zmprov("md", domain, "zimbraFeatureTwoFactorAuthRequired", val)