Cache VM112/VM122 para lista e detalhe domínio, modal purge com loading animado, cards Escopo OPS clicáveis (camada + Spec + navegação), blocklist visível na UI, e documentação nas specs e anais de referência 20260625. Co-authored-by: Cursor <cursoragent@cursor.com>
598 lines
22 KiB
Python
598 lines
22 KiB
Python
"""Listagem e purge de domínios orquestrados na VM112 (Spec 017 + 026)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from app.config import settings
|
|
from app.services import activity_log, carbonio, carbonio_cache, domain_registry, purge_jobs
|
|
from app.services.domain_site_layout import PLATFORM_DOMAINS, SITES_ROOT
|
|
from app.services.infrastructure import _mail_host
|
|
from app.services.traefik_purge import (
|
|
_collect_purge_hosts,
|
|
purge_traefik_routers,
|
|
purge_traefik_sni,
|
|
rollback_traefik_backup,
|
|
validate_traefik_after_purge,
|
|
)
|
|
from app.services.purge_snapshot import build_audit_snapshot
|
|
from app.services.purge_openpanel import purge_openpanel
|
|
from app.services.purge_pmg import purge_pmg
|
|
|
|
_LOG_DIR = Path("/var/log/ibytera-mail-portal/sessions")
|
|
_WIZARD_PORTAL_USERS_DIR = Path("/var/lib/ligbox-wizard/portal_users")
|
|
_TENANT_BRANDING = Path("/opt/ligbox-wizard/backend/app/services/tenant_branding.py")
|
|
_DEPLOY_SCRIPTS = Path("/opt/ligbox-deploy/scripts/admin-login-check")
|
|
_TRAEFIK_CERT_EXPORT = Path("/opt/zextras/ssl/letsencrypt/traefik-export")
|
|
_NGINX_INCLUDES = Path("/opt/zextras/conf/nginx/includes")
|
|
_PURGE_BLOCKLIST = PLATFORM_DOMAINS | frozenset({"itecnologys.com"})
|
|
|
|
|
|
def _domain_slug(domain: str) -> str:
|
|
return domain.lower().strip().replace(".", "-")
|
|
|
|
|
|
def _list_carbonio_domains() -> list[str]:
|
|
return carbonio.list_all_domains()
|
|
|
|
|
|
def _list_site_domains() -> list[str]:
|
|
if not SITES_ROOT.is_dir():
|
|
return []
|
|
return [
|
|
p.name.lower()
|
|
for p in SITES_ROOT.iterdir()
|
|
if p.is_dir() and (p / "domain.json").is_file()
|
|
]
|
|
|
|
|
|
def _portal_users_for_domain(domain: str, users_dir: Path | None = None) -> list[dict[str, Any]]:
|
|
domain = domain.lower().strip()
|
|
base = users_dir or Path(settings.portal_users_dir)
|
|
if not base.is_dir():
|
|
return []
|
|
found: list[dict[str, Any]] = []
|
|
for f in base.glob("*.json"):
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
email = (data.get("planned_corporate_email") or "").lower().strip()
|
|
if email.endswith(f"@{domain}"):
|
|
found.append({
|
|
"login_id": data.get("login_id") or f.stem,
|
|
"planned_corporate_email": email,
|
|
"path": str(f),
|
|
})
|
|
return found
|
|
|
|
|
|
def _build_orchestrated_domains(query: str = "") -> list[dict[str, Any]]:
|
|
carbonio_set = set(_list_carbonio_domains())
|
|
names = sorted(carbonio_set | set(_list_site_domains()))
|
|
users_by_domain: dict[str, list[dict[str, Any]]] = {}
|
|
users_dir = Path(settings.portal_users_dir)
|
|
if users_dir.is_dir():
|
|
for f in users_dir.glob("*.json"):
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
email = (data.get("planned_corporate_email") or "").lower().strip()
|
|
if "@" not in email:
|
|
continue
|
|
dom = email.split("@", 1)[1]
|
|
users_by_domain.setdefault(dom, []).append({
|
|
"login_id": data.get("login_id") or f.stem,
|
|
"planned_corporate_email": email,
|
|
"path": str(f),
|
|
})
|
|
if _WIZARD_PORTAL_USERS_DIR.is_dir():
|
|
for f in _WIZARD_PORTAL_USERS_DIR.glob("*.json"):
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
email = (data.get("planned_corporate_email") or "").lower().strip()
|
|
if "@" not in email:
|
|
continue
|
|
dom = email.split("@", 1)[1]
|
|
entry = {
|
|
"login_id": data.get("login_id") or f.stem,
|
|
"planned_corporate_email": email,
|
|
"path": str(f),
|
|
}
|
|
if entry not in users_by_domain.get(dom, []):
|
|
users_by_domain.setdefault(dom, []).append(entry)
|
|
q = (query or "").strip().lower()
|
|
items: list[dict[str, Any]] = []
|
|
for domain in names:
|
|
if domain in PLATFORM_DOMAINS:
|
|
continue
|
|
rec = domain_registry.get_domain_record(domain) or {}
|
|
admin_email = rec.get("portal_admin_email") or ""
|
|
exists = domain in carbonio_set
|
|
item = {
|
|
"domain": domain,
|
|
"mail_host": _mail_host(domain),
|
|
"portal_admin_email": admin_email,
|
|
"carbonio_exists": exists,
|
|
"site_folder_exists": (SITES_ROOT / domain / "domain.json").is_file(),
|
|
"account_count": None,
|
|
"accounts_preview": [],
|
|
"portal_users": users_by_domain.get(domain, []),
|
|
"updated_at": rec.get("updated_at"),
|
|
}
|
|
if q:
|
|
blob = " ".join([
|
|
domain,
|
|
admin_email,
|
|
" ".join(u.get("login_id", "") for u in item["portal_users"]),
|
|
]).lower()
|
|
if q not in blob:
|
|
continue
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def list_orchestrated_domains(query: str = "") -> list[dict[str, Any]]:
|
|
q = (query or "").strip().lower()
|
|
if not q:
|
|
cached = carbonio_cache.get(carbonio_cache.CACHE_KEY_ORCH_LIST)
|
|
if cached is not None:
|
|
return cached
|
|
items = _build_orchestrated_domains(q)
|
|
if not q:
|
|
carbonio_cache.set(
|
|
carbonio_cache.CACHE_KEY_ORCH_LIST,
|
|
items,
|
|
carbonio_cache.TTL_ORCH_LIST,
|
|
)
|
|
return items
|
|
|
|
|
|
def list_orchestrated_domains_meta(query: str = "") -> dict[str, Any]:
|
|
items = list_orchestrated_domains(query)
|
|
age = carbonio_cache.entry_age_sec(carbonio_cache.CACHE_KEY_ALL_DOMAINS)
|
|
return {
|
|
"domains": items,
|
|
"cached": age is not None,
|
|
"cache_age_sec": age,
|
|
}
|
|
|
|
|
|
def get_domain_detail(domain: str) -> dict[str, Any]:
|
|
domain = domain.lower().strip()
|
|
detail_key = f"domain_detail:{domain}"
|
|
cached = carbonio_cache.get(detail_key)
|
|
if cached is not None:
|
|
return dict(cached)
|
|
|
|
matches = [i for i in list_orchestrated_domains() if i["domain"] == domain]
|
|
if not matches:
|
|
raise ValueError(f"Domínio {domain} não encontrado na orquestração")
|
|
base = dict(matches[0])
|
|
from app.services.infrastructure import get_status
|
|
|
|
rec = domain_registry.get_domain_record(domain) or {}
|
|
aliases = list(rec.get("mail_aliases") or [])
|
|
base["domain_record"] = rec
|
|
carbonio_exists = bool(base.get("carbonio_exists"))
|
|
|
|
def fetch_accounts() -> list[str]:
|
|
if not carbonio_exists:
|
|
return []
|
|
try:
|
|
return carbonio.list_accounts(domain)
|
|
except carbonio.CarbonioError:
|
|
return []
|
|
|
|
def fetch_infra() -> dict[str, Any]:
|
|
return get_status(domain, aliases)
|
|
|
|
def fetch_cf() -> dict[str, Any]:
|
|
try:
|
|
from app.services.cloudflare import CloudflareDNS
|
|
|
|
return CloudflareDNS().get_zone_by_name(domain)
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
with ThreadPoolExecutor(max_workers=3) as pool:
|
|
fut_acc = pool.submit(fetch_accounts)
|
|
fut_inf = pool.submit(fetch_infra)
|
|
fut_cf = pool.submit(fetch_cf)
|
|
base["accounts"] = fut_acc.result()
|
|
base["infra_status"] = fut_inf.result()
|
|
base["cloudflare_zone"] = fut_cf.result()
|
|
|
|
carbonio_cache.set(detail_key, base, carbonio_cache.TTL_DOMAIN_DETAIL)
|
|
return base
|
|
|
|
|
|
def _delete_carbonio_accounts(domain: str) -> list[str]:
|
|
logs: list[str] = []
|
|
accounts: list[str] = []
|
|
try:
|
|
accounts = carbonio.list_accounts(domain)
|
|
except carbonio.CarbonioError:
|
|
pass
|
|
for email in accounts:
|
|
code, _out, _err = carbonio._zmprov_run("da", email, log_cmd=True)
|
|
logs.append(f"da {email}: rc={code}")
|
|
return logs
|
|
|
|
|
|
def _delete_carbonio_domain_only(domain: str) -> str | None:
|
|
if carbonio.domain_exists(domain, use_cache=False):
|
|
code, _out, _err = carbonio._zmprov_run("dd", domain, log_cmd=True)
|
|
carbonio.invalidate_domain_list_cache()
|
|
return f"dd {domain}: rc={code}"
|
|
return None
|
|
|
|
|
|
def _purge_portal_users(domain: str) -> list[str]:
|
|
removed: list[str] = []
|
|
for users_dir in (Path(settings.portal_users_dir), _WIZARD_PORTAL_USERS_DIR):
|
|
for u in _portal_users_for_domain(domain, users_dir):
|
|
try:
|
|
Path(u["path"]).unlink(missing_ok=True)
|
|
label = f"{u['login_id']}@{users_dir.name}"
|
|
if label not in removed:
|
|
removed.append(label)
|
|
except OSError as e:
|
|
removed.append(f"ERR:{u['login_id']}:{e}")
|
|
return removed
|
|
|
|
|
|
def _purge_site_folder(domain: str) -> bool:
|
|
path = SITES_ROOT / domain
|
|
if path.is_dir():
|
|
shutil.rmtree(path)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _purge_cloudflare_zone(domain: str) -> str:
|
|
try:
|
|
from app.services.cloudflare import CloudflareDNS, CloudflareError
|
|
|
|
cf = CloudflareDNS()
|
|
zone = cf.get_zone_by_name(domain)
|
|
if not zone:
|
|
return "no_zone"
|
|
zid = zone.get("id")
|
|
with cf._client() as c:
|
|
r = c.delete(f"https://api.cloudflare.com/client/v4/zones/{zid}")
|
|
data = r.json()
|
|
if data.get("success"):
|
|
return f"deleted:{zid}"
|
|
raise CloudflareError(str(data.get("errors")))
|
|
except Exception as e:
|
|
return f"error:{e}"
|
|
|
|
|
|
def _purge_tenant_branding(domain: str) -> str:
|
|
if not _TENANT_BRANDING.is_file():
|
|
return "skip"
|
|
text = _TENANT_BRANDING.read_text(encoding="utf-8")
|
|
needle = f'"{domain}"'
|
|
if needle not in text:
|
|
return "absent"
|
|
new_text = re.sub(rf'^\s*"{re.escape(domain)}".*\n', "", text, flags=re.MULTILINE)
|
|
_TENANT_BRANDING.write_text(new_text, encoding="utf-8")
|
|
return "removed"
|
|
|
|
|
|
def _purge_deploy_script_refs(domain: str) -> str:
|
|
mail_host = _mail_host(domain)
|
|
changed = []
|
|
for name in ("apply-admin-nginx-overrides.py", "sync-traefik-admin-certs.sh"):
|
|
path = _DEPLOY_SCRIPTS / name
|
|
if not path.is_file():
|
|
continue
|
|
text = path.read_text(encoding="utf-8")
|
|
if mail_host not in text:
|
|
continue
|
|
new_text = re.sub(rf'^\s*"{re.escape(mail_host)}".*\n', "", text, flags=re.MULTILINE)
|
|
new_text = new_text.replace(f" {mail_host}", "")
|
|
new_text = new_text.replace(f"'{mail_host}'", "")
|
|
path.write_text(new_text, encoding="utf-8")
|
|
changed.append(name)
|
|
return ",".join(changed) if changed else "none"
|
|
|
|
|
|
def _purge_traefik_export_certs(domain: str) -> str:
|
|
slug = _domain_slug(domain)
|
|
pattern = f"mail-{slug}*"
|
|
removed = 0
|
|
if _TRAEFIK_CERT_EXPORT.is_dir():
|
|
for f in _TRAEFIK_CERT_EXPORT.glob(pattern):
|
|
f.unlink(missing_ok=True)
|
|
removed += 1
|
|
return str(removed)
|
|
|
|
|
|
def _remove_nginx_server_block(text: str, server_name: str) -> tuple[str, bool]:
|
|
pattern = re.compile(
|
|
rf"server\s*\{{\s*\n\s*server_name\s+{re.escape(server_name)}\b",
|
|
re.MULTILINE,
|
|
)
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return text, False
|
|
start = match.start()
|
|
brace = 0
|
|
i = text.find("{", start)
|
|
while i < len(text):
|
|
if text[i] == "{":
|
|
brace += 1
|
|
elif text[i] == "}":
|
|
brace -= 1
|
|
if brace == 0:
|
|
end = i + 1
|
|
while end < len(text) and text[end] in "\r\n":
|
|
end += 1
|
|
return text[:start] + text[end:], True
|
|
i += 1
|
|
return text, False
|
|
|
|
|
|
def _purge_nginx_vhosts(domain: str, hosts: list[str]) -> str:
|
|
if not _NGINX_INCLUDES.is_dir():
|
|
return "skip"
|
|
targets = [
|
|
_NGINX_INCLUDES / "nginx.conf.web.https",
|
|
_NGINX_INCLUDES / "nginx.conf.web.admin",
|
|
_NGINX_INCLUDES / "nginx.conf.web.http",
|
|
_NGINX_INCLUDES / "nginx.conf.mail.imap",
|
|
_NGINX_INCLUDES / "nginx.conf.mail.imaps",
|
|
_NGINX_INCLUDES / "nginx.conf.mail.pop3",
|
|
_NGINX_INCLUDES / "nginx.conf.mail.pop3s",
|
|
_NGINX_INCLUDES / "nginx.conf.map.key",
|
|
_NGINX_INCLUDES / "nginx.conf.map.crt",
|
|
]
|
|
edited = 0
|
|
for fp in targets:
|
|
if not fp.is_file():
|
|
continue
|
|
text = fp.read_text(encoding="utf-8")
|
|
orig = text
|
|
for host in hosts:
|
|
while True:
|
|
text, removed = _remove_nginx_server_block(text, host)
|
|
if not removed:
|
|
break
|
|
for host in hosts:
|
|
text = re.sub(rf"^{re.escape(host)} .*\n", "", text, flags=re.MULTILINE)
|
|
if text != orig:
|
|
fp.with_suffix(fp.suffix + f".bak-purge-{_domain_slug(domain)}").write_text(orig, encoding="utf-8")
|
|
fp.write_text(text, encoding="utf-8")
|
|
edited += 1
|
|
if edited == 0:
|
|
return "none"
|
|
proc = subprocess.run(
|
|
["/opt/zextras/common/sbin/nginx", "-t", "-c", "/opt/zextras/conf/nginx.conf"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
if proc.returncode != 0:
|
|
return f"nginx_test_fail:{(proc.stderr or proc.stdout)[:120]}"
|
|
subprocess.run(
|
|
["su", "-", "zextras", "-c", "/opt/zextras/common/sbin/nginx -s reload"],
|
|
capture_output=True,
|
|
timeout=60,
|
|
)
|
|
return f"updated:{edited}"
|
|
|
|
|
|
def _purge_session_logs(domain: str) -> int:
|
|
count = 0
|
|
if not _LOG_DIR.is_dir():
|
|
return 0
|
|
for f in _LOG_DIR.glob("*.jsonl"):
|
|
try:
|
|
if domain in f.read_text(encoding="utf-8", errors="ignore").lower():
|
|
f.unlink()
|
|
count += 1
|
|
except OSError:
|
|
pass
|
|
return count
|
|
|
|
|
|
def _execute_purge(
|
|
domain: str,
|
|
report: Callable[[str, str, str | None], None] | None = None,
|
|
*,
|
|
job_id: str | None = None,
|
|
requested_by: str | None = None,
|
|
) -> dict[str, Any]:
|
|
def _step(step_id: str, status: str, detail: str | None = None) -> None:
|
|
if report:
|
|
report(step_id, status, detail)
|
|
if status == "running":
|
|
activity_log.info(f"PURGE [{step_id}] {detail or '…'}", source="admin")
|
|
elif status == "done":
|
|
activity_log.ok(f"PURGE [{step_id}] {detail or 'OK'}", source="admin")
|
|
elif status == "error":
|
|
activity_log.error(f"PURGE [{step_id}] {detail or 'erro'}", source="admin")
|
|
|
|
rec = domain_registry.get_domain_record(domain) or {}
|
|
aliases = list(rec.get("mail_aliases") or [])
|
|
hosts = _collect_purge_hosts(domain, aliases)
|
|
result: dict[str, Any] = {"domain": domain}
|
|
traefik_backup: str | None = None
|
|
|
|
_step("audit_snapshot", "running")
|
|
snapshot = build_audit_snapshot(domain, job_id=job_id, requested_by=requested_by)
|
|
result["audit_snapshot"] = snapshot
|
|
ac = snapshot.get("carbonio", {}).get("account_count", 0)
|
|
_step("audit_snapshot", "done", f"{ac} conta(s), inventário gravado")
|
|
|
|
_step("carbonio_accounts", "running")
|
|
result["carbonio_accounts"] = _delete_carbonio_accounts(domain)
|
|
_step("carbonio_accounts", "done", "; ".join(result["carbonio_accounts"]) or "nenhuma conta")
|
|
|
|
_step("carbonio_domain", "running")
|
|
dd = _delete_carbonio_domain_only(domain)
|
|
result["carbonio_domain"] = dd or "domínio já ausente"
|
|
_step("carbonio_domain", "done", result["carbonio_domain"])
|
|
|
|
_step("portal_users", "running")
|
|
result["portal_users_removed"] = _purge_portal_users(domain)
|
|
_step("portal_users", "done", ", ".join(result["portal_users_removed"]) or "nenhum")
|
|
|
|
_step("site_folder", "running")
|
|
result["site_folder_removed"] = _purge_site_folder(domain)
|
|
_step("site_folder", "done", "removido" if result["site_folder_removed"] else "já ausente")
|
|
|
|
_step("cloudflare", "running")
|
|
result["cloudflare"] = _purge_cloudflare_zone(domain)
|
|
_step("cloudflare", "done", result["cloudflare"])
|
|
|
|
_step("session_logs", "running")
|
|
result["session_logs_removed"] = _purge_session_logs(domain)
|
|
_step("session_logs", "done", str(result["session_logs_removed"]))
|
|
|
|
_step("tenant_branding", "running")
|
|
result["tenant_branding"] = _purge_tenant_branding(domain)
|
|
_step("tenant_branding", "done", result["tenant_branding"])
|
|
|
|
_step("deploy_scripts", "running")
|
|
result["deploy_scripts"] = _purge_deploy_script_refs(domain)
|
|
_step("deploy_scripts", "done", result["deploy_scripts"])
|
|
|
|
_step("traefik_export_certs", "running")
|
|
result["traefik_export_certs"] = _purge_traefik_export_certs(domain)
|
|
_step("traefik_export_certs", "done", result["traefik_export_certs"])
|
|
|
|
_step("nginx_vhosts", "running")
|
|
result["nginx_vhosts"] = _purge_nginx_vhosts(domain, hosts)
|
|
nginx_ok = not str(result["nginx_vhosts"]).startswith("nginx_test_fail")
|
|
_step("nginx_vhosts", "done" if nginx_ok else "error", result["nginx_vhosts"])
|
|
|
|
try:
|
|
_step("traefik_sni", "running")
|
|
result["traefik_sni"] = purge_traefik_sni(hosts)
|
|
sni_ok = not str(result["traefik_sni"]).startswith("sni_fail")
|
|
_step("traefik_sni", "done" if sni_ok else "error", result["traefik_sni"])
|
|
|
|
_step("traefik_routers", "running")
|
|
tr = purge_traefik_routers(domain, hosts)
|
|
result["traefik_routers"] = tr.get("detail") or ("traefik_ok" if tr.get("ok") else "traefik_fail")
|
|
traefik_backup = tr.get("backup")
|
|
result["traefik_backup"] = traefik_backup
|
|
tr_ok = bool(tr.get("ok"))
|
|
_step("traefik_routers", "done" if tr_ok else "error", result["traefik_routers"])
|
|
|
|
_step("traefik_validate", "running")
|
|
validation = validate_traefik_after_purge(domain)
|
|
result["traefik_validate"] = validation
|
|
val_detail = json.dumps(validation.get("checks") or {}, ensure_ascii=False)[:400]
|
|
val_ok = bool(validation.get("ok"))
|
|
if not val_ok and traefik_backup:
|
|
rb = rollback_traefik_backup(traefik_backup)
|
|
result["traefik_rollback"] = rb
|
|
validation = validate_traefik_after_purge(domain)
|
|
result["traefik_validate_after_rollback"] = validation
|
|
_step("traefik_validate", "done" if val_ok else "error", val_detail)
|
|
|
|
if not sni_ok or not tr_ok or not val_ok:
|
|
raise RuntimeError(
|
|
f"Traefik purge incompleto (sni={result['traefik_sni']}, "
|
|
f"routers={result['traefik_routers']}, validate={val_detail})"
|
|
)
|
|
except Exception as e:
|
|
result["traefik_error"] = str(e)
|
|
if report:
|
|
_step("traefik_validate", "error", str(e))
|
|
raise
|
|
|
|
_step("openpanel_dns", "running")
|
|
op = purge_openpanel(domain)
|
|
result["openpanel"] = op
|
|
_step("openpanel_dns", "done", op.get("dns", "ok"))
|
|
_step("openpanel_user", "running")
|
|
_step("openpanel_user", "done", op.get("user", "ok"))
|
|
|
|
_step("pmg_domain", "running")
|
|
pmg = purge_pmg(domain)
|
|
result["pmg"] = pmg
|
|
_step("pmg_domain", "done", pmg.get("domains", "ok"))
|
|
_step("pmg_transport", "running")
|
|
_step("pmg_transport", "done", pmg.get("transport", "ok"))
|
|
|
|
result["carbonio"] = result.get("carbonio_accounts", []) + (
|
|
[result["carbonio_domain"]] if result.get("carbonio_domain") else []
|
|
)
|
|
return result
|
|
|
|
|
|
def run_purge_job(job_id: str) -> None:
|
|
job = purge_jobs.get_job(job_id)
|
|
if not job:
|
|
return
|
|
domain = job["domain"]
|
|
report = purge_jobs.make_reporter(job_id)
|
|
try:
|
|
if domain in _PURGE_BLOCKLIST:
|
|
raise ValueError(f"Domínio {domain} está na blocklist de purge")
|
|
activity_log.info(f"PURGE job {job_id} iniciado: {domain}", source="admin")
|
|
result = _execute_purge(domain, report=report, job_id=job_id)
|
|
purge_jobs.complete_job(job_id, result)
|
|
carbonio.invalidate_domain_list_cache()
|
|
carbonio_cache.invalidate_domain(domain)
|
|
activity_log.ok(f"PURGE job {job_id} concluído: {domain}", source="admin")
|
|
except Exception as e:
|
|
purge_jobs.fail_job(job_id, str(e))
|
|
activity_log.error(f"PURGE job {job_id} falhou: {e}", source="admin")
|
|
|
|
|
|
def assert_purge_allowed(domain: str) -> None:
|
|
domain = domain.lower().strip()
|
|
if domain in _PURGE_BLOCKLIST:
|
|
raise ValueError(f"Domínio {domain} está na blocklist de purge")
|
|
|
|
|
|
def purge_domain(domain: str) -> dict[str, Any]:
|
|
domain = domain.lower().strip()
|
|
assert_purge_allowed(domain)
|
|
activity_log.info(f"PURGE iniciado: {domain}", source="admin")
|
|
job = purge_jobs.create_job(domain)
|
|
job_id = job["job_id"]
|
|
report = purge_jobs.make_reporter(job_id)
|
|
try:
|
|
result = _execute_purge(domain, report=report)
|
|
purge_jobs.complete_job(job_id, result)
|
|
carbonio.invalidate_domain_list_cache()
|
|
carbonio_cache.invalidate_domain(domain)
|
|
activity_log.ok(f"PURGE concluído: {domain}", source="admin")
|
|
job_data = purge_jobs.get_job(job_id) or {}
|
|
steps = purge_jobs.steps_for_desk(job_data.get("steps") or [])
|
|
return {"domain": domain, "steps": steps, **result}
|
|
except Exception as e:
|
|
purge_jobs.fail_job(job_id, str(e))
|
|
raise
|
|
|
|
|
|
def delete_carbonio_account(email: str) -> dict:
|
|
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
|
|
email = email.lower().strip()
|
|
if "@" not in email:
|
|
raise ValueError("e-mail inválido")
|
|
domain = email.split("@", 1)[1]
|
|
assert_purge_allowed(domain)
|
|
if not carbonio.account_exists(email):
|
|
return {"ok": True, "email": email, "message": "Conta já não existia", "skipped": True}
|
|
code, out, err = carbonio._zmprov_run("da", email, log_cmd=True)
|
|
if code != 0 and not carbonio._is_missing_account(err, out):
|
|
raise carbonio.CarbonioError(err or out or f"zmprov da falhou para {email}")
|
|
carbonio.invalidate_domain_cache(domain)
|
|
return {"ok": True, "email": email, "message": f"Conta {email} removida do Carbonio", "rc": code}
|