| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- from __future__ import annotations
- import json
- import os
- from pathlib import Path
- import sys
- from typing import Any, TextIO
- from curl_cffi import requests as cffi_requests
- from core.cfmail import load_cfmail_accounts_from_file
- from core.env_loader import load_env_file
- from core.paths import DEFAULT_ENV_FILE, resolve_cfmail_config_path
- def _print(stdout: TextIO, message: str) -> None:
- print(message, file=stdout)
- def _normalize_domain(value: str) -> str:
- return str(value or "").strip().lower().rstrip(".")
- def _load_active_domain(config_path: Path) -> str:
- accounts = [
- item
- for item in load_cfmail_accounts_from_file(config_path, silent=False)
- if isinstance(item, dict) and item.get("enabled", True)
- ]
- for item in reversed(accounts):
- domain = _normalize_domain(str(item.get("email_domain") or ""))
- if domain:
- return domain
- raise RuntimeError(f"no active cfmail domain found in {config_path}")
- def _load_env(env_file: Path) -> None:
- env_values = _read_env_file(env_file)
- load_env_file(env_file)
- _override_env_from_file(env_file, env_values)
- cfmail_env_file = Path(
- str(env_values.get("ZHUCE6_CFMAIL_ENV_FILE") or env_file.parent / "config" / "cfmail_provision.env").strip()
- or str(env_file.parent / "config" / "cfmail_provision.env")
- ).expanduser().resolve()
- load_env_file(cfmail_env_file)
- _override_env_from_file(cfmail_env_file)
- def _read_env_file(path: Path) -> dict[str, str]:
- payload: dict[str, str] = {}
- if not path.is_file():
- return payload
- for raw_line in path.read_text(encoding="utf-8").splitlines():
- line = raw_line.strip()
- if not line or line.startswith("#"):
- continue
- if line.startswith("export "):
- line = line[7:]
- key, sep, value = line.partition("=")
- if not sep:
- continue
- key = key.strip()
- value = value.strip().strip('"').strip("'")
- if key:
- payload[key] = value
- return payload
- def _override_env_from_file(path: Path, payload: dict[str, str] | None = None) -> None:
- values = payload if payload is not None else _read_env_file(path)
- for key, value in values.items():
- os.environ[key] = value
- def _headers(auth_email: str, auth_key: str) -> dict[str, str]:
- return {
- "X-Auth-Email": auth_email,
- "X-Auth-Key": auth_key,
- "Content-Type": "application/json",
- }
- def _request(method: str, url: str, *, headers: dict[str, str]) -> dict[str, Any]:
- response = cffi_requests.request(
- method.upper(),
- url,
- headers=headers,
- timeout=30,
- impersonate="chrome",
- )
- payload = response.json() if response.content else {}
- if response.status_code >= 400 or not payload.get("success", False):
- raise RuntimeError(f"{method.upper()} {url} failed: HTTP {response.status_code} {json.dumps(payload, ensure_ascii=False)}")
- return payload
- def _request_paginated(url: str, *, headers: dict[str, str]) -> list[dict[str, Any]]:
- page = 1
- results: list[dict[str, Any]] = []
- while True:
- separator = "&" if "?" in url else "?"
- payload = _request("GET", f"{url}{separator}page={page}&per_page=100", headers=headers)
- items = payload.get("result") or []
- if isinstance(items, list):
- results.extend(item for item in items if isinstance(item, dict))
- info = payload.get("result_info") or {}
- total_pages = int(info.get("total_pages") or 1)
- if page >= total_pages:
- break
- page += 1
- return results
- def _routing_rule_domains(rule: dict[str, Any]) -> set[str]:
- domains: set[str] = set()
- for matcher in rule.get("matchers") or []:
- if not isinstance(matcher, dict):
- continue
- value = _normalize_domain(str(matcher.get("value") or ""))
- if "*@" in value:
- domains.add(value.split("*@", 1)[-1])
- return domains
- def run_cleanup(
- *,
- env_file: Path | None = None,
- config_path: Path | None = None,
- stdout: TextIO | None = None,
- ) -> dict[str, Any]:
- out = stdout or sys.stdout
- resolved_env_file = (env_file or DEFAULT_ENV_FILE).expanduser().resolve()
- resolved_config_path = (config_path or resolve_cfmail_config_path()).expanduser().resolve()
- _load_env(resolved_env_file)
- auth_email = str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", "")).strip()
- auth_key = str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_KEY", "")).strip()
- zone_id = str(os.getenv("ZHUCE6_CFMAIL_CF_ZONE_ID", "")).strip()
- zone_name = _normalize_domain(str(os.getenv("ZHUCE6_CFMAIL_ZONE_NAME", "")))
- missing = [
- name
- for name, value in (
- ("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", auth_email),
- ("ZHUCE6_CFMAIL_CF_AUTH_KEY", auth_key),
- ("ZHUCE6_CFMAIL_CF_ZONE_ID", zone_id),
- )
- if not value
- ]
- if missing:
- raise RuntimeError(f"missing cleanup env: {', '.join(missing)}")
- active_domain = _load_active_domain(resolved_config_path)
- headers = _headers(auth_email, auth_key)
- base_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}"
- zone_suffix = f".{zone_name}" if zone_name else ""
- _print(out, f"[cleanup] env: {resolved_env_file}")
- _print(out, f"[cleanup] config: {resolved_config_path}")
- _print(out, f"[cleanup] active domain: {active_domain}")
- rules = _request_paginated(f"{base_url}/email/routing/rules", headers=headers)
- _print(out, f"[cleanup] routing rules fetched: {len(rules)}")
- removed_routing_rules: list[str] = []
- for rule in rules:
- rule_id = str(rule.get("id") or "").strip()
- rule_name = str(rule.get("name") or "").strip()
- domains = _routing_rule_domains(rule)
- should_keep = active_domain in domains or "nova" in rule_name.lower()
- if should_keep or not domains:
- continue
- _print(out, f"[cleanup] delete routing rule: {rule_id} name={rule_name}")
- try:
- _request("DELETE", f"{base_url}/email/routing/rules/{rule_id}", headers=headers)
- removed_routing_rules.append(rule_id)
- except Exception as exc:
- _print(out, f"[cleanup] skip routing rule {rule_id}: {exc}")
- dns_records = _request_paginated(f"{base_url}/dns_records", headers=headers)
- _print(out, f"[cleanup] dns records fetched: {len(dns_records)}")
- removed_dns_records: list[str] = []
- for record in dns_records:
- record_id = str(record.get("id") or "").strip()
- record_type = str(record.get("type") or "").strip().upper()
- record_name = _normalize_domain(str(record.get("name") or ""))
- if record_type not in {"MX", "TXT"}:
- continue
- if not record_name.startswith("auto"):
- continue
- if zone_suffix and not record_name.endswith(zone_suffix):
- continue
- if record_name == active_domain:
- continue
- _print(out, f"[cleanup] delete dns record: {record_id} type={record_type} name={record_name}")
- try:
- _request("DELETE", f"{base_url}/dns_records/{record_id}", headers=headers)
- removed_dns_records.append(record_id)
- except Exception as exc:
- _print(out, f"[cleanup] skip dns record {record_id}: {exc}")
- summary = {
- "active_domain": active_domain,
- "removed_routing_rules": removed_routing_rules,
- "removed_dns_records": removed_dns_records,
- }
- _print(out, f"[cleanup] summary: {json.dumps(summary, ensure_ascii=False)}")
- return summary
- def main() -> int:
- run_cleanup()
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|