cfmail_provisioner.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. """Cloudflare-backed cfmail subdomain provisioner."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from datetime import datetime, timezone
  5. import json
  6. import os
  7. from pathlib import Path
  8. import secrets
  9. import subprocess
  10. import tempfile
  11. import time
  12. from typing import Any
  13. from curl_cffi import requests as cffi_requests
  14. from .cfmail import DEFAULT_CFMAIL_CONFIG_PATH, load_cfmail_accounts_from_file
  15. MX_RECORDS = (
  16. ("route1.mx.cloudflare.net", 20),
  17. ("route2.mx.cloudflare.net", 85),
  18. ("route3.mx.cloudflare.net", 36),
  19. )
  20. def _utc_stamp() -> str:
  21. return datetime.now(timezone.utc).strftime("%m%d%H%M%S")
  22. def _normalize_host(value: str) -> str:
  23. candidate = str(value or "").strip()
  24. if candidate.startswith("https://"):
  25. candidate = candidate[len("https://") :]
  26. elif candidate.startswith("http://"):
  27. candidate = candidate[len("http://") :]
  28. return candidate.strip().strip("/")
  29. @dataclass(frozen=True)
  30. class ProvisioningSettings:
  31. auth_email: str
  32. auth_key: str
  33. account_id: str
  34. zone_id: str
  35. worker_name: str
  36. zone_name: str
  37. @classmethod
  38. def from_env(cls) -> "ProvisioningSettings":
  39. return cls(
  40. auth_email=str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", "")).strip(),
  41. auth_key=str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_KEY", "")).strip(),
  42. account_id=str(os.getenv("ZHUCE6_CFMAIL_CF_ACCOUNT_ID", "")).strip(),
  43. zone_id=str(os.getenv("ZHUCE6_CFMAIL_CF_ZONE_ID", "")).strip(),
  44. worker_name=str(os.getenv("ZHUCE6_CFMAIL_WORKER_NAME", "")).strip(),
  45. zone_name=str(os.getenv("ZHUCE6_CFMAIL_ZONE_NAME", "")).strip(),
  46. )
  47. def validate(self) -> None:
  48. missing = [
  49. name
  50. for name, value in (
  51. ("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", self.auth_email),
  52. ("ZHUCE6_CFMAIL_CF_AUTH_KEY", self.auth_key),
  53. ("ZHUCE6_CFMAIL_CF_ACCOUNT_ID", self.account_id),
  54. ("ZHUCE6_CFMAIL_CF_ZONE_ID", self.zone_id),
  55. ("ZHUCE6_CFMAIL_WORKER_NAME", self.worker_name),
  56. ("ZHUCE6_CFMAIL_ZONE_NAME", self.zone_name),
  57. )
  58. if not value
  59. ]
  60. if missing:
  61. raise RuntimeError(f"missing cfmail provisioning env: {', '.join(missing)}")
  62. @dataclass(frozen=True)
  63. class ProvisionResult:
  64. success: bool
  65. step: str
  66. old_domain: str = ""
  67. new_domain: str = ""
  68. error: str = ""
  69. class CfmailProvisioner:
  70. def __init__(
  71. self,
  72. *,
  73. config_path: str | Path | None = None,
  74. proxy_url: str | None = None,
  75. settings: ProvisioningSettings | None = None,
  76. ) -> None:
  77. self.config_path = Path(config_path or DEFAULT_CFMAIL_CONFIG_PATH)
  78. self.settings = settings or ProvisioningSettings.from_env()
  79. self.proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  80. def _headers(self) -> dict[str, str]:
  81. self.settings.validate()
  82. return {
  83. "X-Auth-Email": self.settings.auth_email,
  84. "X-Auth-Key": self.settings.auth_key,
  85. }
  86. def _request(
  87. self,
  88. method: str,
  89. url: str,
  90. *,
  91. json_body: dict[str, Any] | None = None,
  92. ) -> dict[str, Any]:
  93. response = cffi_requests.request(
  94. method.upper(),
  95. url,
  96. headers={
  97. **self._headers(),
  98. "Content-Type": "application/json",
  99. },
  100. json=json_body,
  101. proxies=self.proxies,
  102. timeout=30,
  103. impersonate="chrome",
  104. )
  105. data = response.json() if response.content else {}
  106. if response.status_code >= 400 or not data.get("success", False):
  107. raise RuntimeError(f"{method.upper()} {url} failed: HTTP {response.status_code} {data}")
  108. return data
  109. def _request_paginated(self, url: str) -> list[dict[str, Any]]:
  110. page = 1
  111. results: list[dict[str, Any]] = []
  112. while True:
  113. separator = "&" if "?" in url else "?"
  114. payload = self._request("GET", f"{url}{separator}page={page}&per_page=100")
  115. items = payload.get("result") or []
  116. if isinstance(items, list):
  117. results.extend(item for item in items if isinstance(item, dict))
  118. info = payload.get("result_info") or {}
  119. total_pages = int(info.get("total_pages") or 1)
  120. if page >= total_pages:
  121. break
  122. page += 1
  123. return results
  124. def _get_worker_settings(self) -> dict[str, Any]:
  125. url = (
  126. f"https://api.cloudflare.com/client/v4/accounts/{self.settings.account_id}/workers/scripts/"
  127. f"{self.settings.worker_name}/settings"
  128. )
  129. return self._request("GET", url).get("result") or {}
  130. def _patch_worker_settings(self, bindings: list[dict[str, Any]]) -> None:
  131. url = (
  132. f"https://api.cloudflare.com/client/v4/accounts/{self.settings.account_id}/workers/scripts/"
  133. f"{self.settings.worker_name}/settings"
  134. )
  135. self.settings.validate()
  136. body = json.dumps({"bindings": bindings}, ensure_ascii=False, separators=(",", ":"))
  137. boundary = secrets.token_hex(16)
  138. multipart_body = (
  139. f"--{boundary}\r\n"
  140. f'Content-Disposition: form-data; name="settings"\r\n'
  141. f"Content-Type: application/json\r\n"
  142. f"\r\n"
  143. f"{body}\r\n"
  144. f"--{boundary}--\r\n"
  145. ).encode("utf-8")
  146. last_error = ""
  147. for attempt in range(3):
  148. try:
  149. response = cffi_requests.patch(
  150. url,
  151. headers={
  152. "X-Auth-Email": self.settings.auth_email,
  153. "X-Auth-Key": self.settings.auth_key,
  154. "Content-Type": f"multipart/form-data; boundary={boundary}",
  155. },
  156. data=multipart_body,
  157. proxies=self.proxies,
  158. timeout=30,
  159. impersonate="chrome",
  160. )
  161. data = response.json() if response.content else {}
  162. if response.status_code >= 400 or not data.get("success", False):
  163. last_error = f"HTTP {response.status_code} {data}"
  164. if attempt < 2:
  165. time.sleep(2)
  166. continue
  167. raise RuntimeError(f"PATCH worker settings failed: {last_error}")
  168. return
  169. except RuntimeError:
  170. raise
  171. except Exception as exc:
  172. last_error = str(exc)
  173. if attempt < 2:
  174. time.sleep(2)
  175. continue
  176. raise RuntimeError(f"PATCH worker settings failed after {attempt + 1} attempts: {last_error}")
  177. def _make_new_label(self) -> str:
  178. return f"auto{_utc_stamp()}{secrets.token_hex(2)}"
  179. def _new_domain(self, label: str) -> str:
  180. return f"{label}.{self.settings.zone_name}"
  181. def _create_email_routing_rule(self, domain: str, label: str) -> None:
  182. url = f"https://api.cloudflare.com/client/v4/zones/{self.settings.zone_id}/email/routing/rules"
  183. self._request(
  184. "POST",
  185. url,
  186. json_body={
  187. "name": f"{label} subdomain catch-all",
  188. "enabled": True,
  189. "matchers": [{"type": "literal", "field": "to", "value": f"*@" + domain}],
  190. "actions": [{"type": "worker", "value": [self.settings.worker_name]}],
  191. },
  192. )
  193. def _create_dns_records(self, domain: str) -> None:
  194. url = f"https://api.cloudflare.com/client/v4/zones/{self.settings.zone_id}/dns_records"
  195. for content, priority in MX_RECORDS:
  196. self._request(
  197. "POST",
  198. url,
  199. json_body={
  200. "type": "MX",
  201. "name": domain,
  202. "content": content,
  203. "priority": priority,
  204. "ttl": 1,
  205. },
  206. )
  207. self._request(
  208. "POST",
  209. url,
  210. json_body={
  211. "type": "TXT",
  212. "name": domain,
  213. "content": "v=spf1 include:_spf.mx.cloudflare.net ~all",
  214. "ttl": 1,
  215. },
  216. )
  217. def _list_dns_records(self) -> list[dict[str, Any]]:
  218. url = f"https://api.cloudflare.com/client/v4/zones/{self.settings.zone_id}/dns_records"
  219. return self._request_paginated(url)
  220. def _delete_dns_record(self, record_id: str) -> None:
  221. url = f"https://api.cloudflare.com/client/v4/zones/{self.settings.zone_id}/dns_records/{record_id}"
  222. self._request("DELETE", url)
  223. def _list_email_routing_rules(self) -> list[dict[str, Any]]:
  224. url = f"https://api.cloudflare.com/client/v4/zones/{self.settings.zone_id}/email/routing/rules"
  225. return self._request_paginated(url)
  226. def _delete_email_routing_rule(self, rule_id: str) -> None:
  227. url = f"https://api.cloudflare.com/client/v4/zones/{self.settings.zone_id}/email/routing/rules/{rule_id}"
  228. self._request("DELETE", url)
  229. def _routing_rule_domains(self, rule: dict[str, Any]) -> set[str]:
  230. domains: set[str] = set()
  231. for matcher in rule.get("matchers") or []:
  232. if not isinstance(matcher, dict):
  233. continue
  234. value = str(matcher.get("value") or "").strip().lower()
  235. if "*@" in value:
  236. domains.add(value.split("*@", 1)[-1])
  237. return domains
  238. def _normalize_domain_name(self, value: str) -> str:
  239. return str(value or "").strip().lower().rstrip(".")
  240. def _is_managed_auto_domain(self, domain: str) -> bool:
  241. domain_key = self._normalize_domain_name(domain)
  242. zone_suffix = f".{self.settings.zone_name.lower()}"
  243. return bool(domain_key) and domain_key.startswith("auto") and domain_key.endswith(zone_suffix)
  244. def _delete_domain_artifacts(self, domain: str) -> None:
  245. domain_key = str(domain or "").strip().lower()
  246. if not domain_key:
  247. return
  248. for record in self._list_dns_records():
  249. if str(record.get("name") or "").strip().lower() == domain_key:
  250. record_id = str(record.get("id") or "").strip()
  251. if record_id:
  252. try:
  253. self._delete_dns_record(record_id)
  254. except Exception:
  255. pass # skip read-only or protected records
  256. for rule in self._list_email_routing_rules():
  257. if domain_key in self._routing_rule_domains(rule):
  258. rule_id = str(rule.get("id") or "").strip()
  259. if rule_id:
  260. try:
  261. self._delete_email_routing_rule(rule_id)
  262. except Exception:
  263. pass
  264. def _managed_auto_domains(self, accounts: list[dict[str, Any]]) -> list[str]:
  265. return [
  266. self._normalize_domain_name(str(item.get("email_domain") or ""))
  267. for item in accounts
  268. if self._is_managed_auto_domain(str(item.get("email_domain") or ""))
  269. ]
  270. def current_active_accounts(self) -> list[dict[str, Any]]:
  271. accounts = self._load_all_accounts()
  272. return [
  273. dict(item)
  274. for item in accounts
  275. if bool(item.get("enabled", True))
  276. and str(item.get("email_domain") or "").strip()
  277. ]
  278. def current_active_domains(self) -> list[str]:
  279. return [
  280. self._normalize_domain_name(str(item.get("email_domain") or ""))
  281. for item in self.current_active_accounts()
  282. if self._normalize_domain_name(str(item.get("email_domain") or ""))
  283. ]
  284. def cleanup_stale_domains(self, keep_domains: set[str] | list[str] | None = None) -> dict[str, Any]:
  285. return self.cleanup_stale_cf_resources(keep_domains=keep_domains)
  286. def cleanup_stale_cf_resources(self, keep_domains: set[str] | list[str] | None = None) -> dict[str, Any]:
  287. accounts = self._load_all_accounts()
  288. active_domain = self._normalize_domain_name(str(self.current_active_account().get("email_domain") or ""))
  289. keep_set = {
  290. self._normalize_domain_name(domain)
  291. for domain in (keep_domains or [])
  292. if self._normalize_domain_name(domain)
  293. }
  294. keep_set.discard(active_domain)
  295. stale_domains: set[str] = set()
  296. removed_dns_records: list[str] = []
  297. removed_routing_rules: list[str] = []
  298. errors: list[str] = []
  299. for rule in self._list_email_routing_rules():
  300. rule_domains = {
  301. domain
  302. for domain in self._routing_rule_domains(rule)
  303. if self._is_managed_auto_domain(domain) and domain != active_domain and domain not in keep_set
  304. }
  305. stale_domains.update(rule_domains)
  306. if not rule_domains:
  307. continue
  308. rule_id = str(rule.get("id") or "").strip()
  309. if not rule_id:
  310. continue
  311. try:
  312. self._delete_email_routing_rule(rule_id)
  313. removed_routing_rules.append(rule_id)
  314. except Exception as exc:
  315. errors.append(f"routing_rule:{rule_id}: {exc}")
  316. for record in self._list_dns_records():
  317. record_type = str(record.get("type") or "").strip().upper()
  318. if record_type not in {"MX", "TXT"}:
  319. continue
  320. domain = self._normalize_domain_name(str(record.get("name") or ""))
  321. if not self._is_managed_auto_domain(domain) or domain == active_domain or domain in keep_set:
  322. continue
  323. stale_domains.add(domain)
  324. record_id = str(record.get("id") or "").strip()
  325. if not record_id:
  326. continue
  327. try:
  328. self._delete_dns_record(record_id)
  329. removed_dns_records.append(record_id)
  330. except Exception as exc:
  331. errors.append(f"dns_record:{record_id}: {exc}")
  332. stale_account_domains = set(self._managed_auto_domains(accounts)).intersection(stale_domains)
  333. if stale_account_domains:
  334. pruned_accounts = [
  335. item for item in accounts
  336. if self._normalize_domain_name(str(item.get("email_domain") or "")) not in stale_account_domains
  337. ]
  338. if len(pruned_accounts) != len(accounts):
  339. self._write_accounts(pruned_accounts)
  340. return {
  341. "removed_domains": sorted(stale_domains),
  342. "removed_dns_records": removed_dns_records,
  343. "removed_routing_rules": removed_routing_rules,
  344. "errors": errors,
  345. }
  346. def _is_record_quota_error(self, exc: Exception) -> bool:
  347. message = str(exc or "")
  348. return "81045" in message or "Record quota exceeded" in message
  349. def _set_worker_domains(self, domains: list[str]) -> None:
  350. settings = self._get_worker_settings()
  351. bindings = list(settings.get("bindings") or [])
  352. normalized_domains: list[str] = []
  353. seen_domains: set[str] = set()
  354. for domain in domains:
  355. domain_key = self._normalize_domain_name(domain)
  356. if not domain_key or domain_key in seen_domains:
  357. continue
  358. normalized_domains.append(domain_key)
  359. seen_domains.add(domain_key)
  360. updated = False
  361. for binding in bindings:
  362. if binding.get("name") not in {"DOMAINS", "DEFAULT_DOMAINS"} or binding.get("type") != "json":
  363. continue
  364. binding["json"] = list(normalized_domains)
  365. updated = True
  366. if not updated:
  367. raise RuntimeError("worker DOMAINS bindings missing")
  368. self._patch_worker_settings(bindings)
  369. def _update_worker_domains(self, domain: str | list[str], old_domain: str | None = None) -> None:
  370. if isinstance(domain, list):
  371. domains = list(domain)
  372. else:
  373. domains = [domain]
  374. old_domain_key = self._normalize_domain_name(old_domain or "")
  375. if old_domain_key and old_domain_key != self._normalize_domain_name(domain):
  376. domains.append(old_domain_key)
  377. self._set_worker_domains(domains)
  378. def smoke_test(self, worker_domain: str, admin_password: str, email_domain: str) -> None:
  379. test_name = f"smoke{secrets.token_hex(3)}"
  380. required_successes = 3
  381. success_streak = 0
  382. last_error = "smoke test did not run"
  383. for attempt in range(1, 10):
  384. response = cffi_requests.post(
  385. f"https://{_normalize_host(worker_domain)}/admin/new_address",
  386. headers={
  387. "x-admin-auth": admin_password,
  388. "Content-Type": "application/json",
  389. },
  390. json={"enablePrefix": True, "name": f"{test_name}{attempt}", "domain": email_domain},
  391. proxies=self.proxies,
  392. timeout=20,
  393. impersonate="chrome",
  394. )
  395. if response.status_code != 200:
  396. success_streak = 0
  397. last_error = f"HTTP {response.status_code} {response.text[:240]}"
  398. time.sleep(min(8, attempt * 2))
  399. continue
  400. try:
  401. data = response.json() if response.content else {}
  402. except Exception:
  403. success_streak = 0
  404. last_error = f"non-json response: {response.text[:240]}"
  405. time.sleep(min(8, attempt * 2))
  406. continue
  407. if str(data.get("address") or "").strip() and str(data.get("jwt") or "").strip():
  408. success_streak += 1
  409. if success_streak >= required_successes:
  410. return
  411. last_error = f"smoke success streak={success_streak}/{required_successes}"
  412. time.sleep(1)
  413. continue
  414. success_streak = 0
  415. last_error = f"incomplete payload: {json.dumps(data, ensure_ascii=False)[:240]}"
  416. time.sleep(min(8, attempt * 2))
  417. raise RuntimeError(f"smoke test failed: {last_error}")
  418. def _load_all_accounts(self) -> list[dict[str, Any]]:
  419. data = load_cfmail_accounts_from_file(self.config_path, silent=False)
  420. return [item for item in data if isinstance(item, dict)]
  421. def _write_accounts(self, accounts: list[dict[str, Any]]) -> None:
  422. payload = {"accounts": accounts}
  423. tmp_path = self.config_path.with_suffix(self.config_path.suffix + ".tmp")
  424. tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  425. tmp_path.replace(self.config_path)
  426. def _pick_active_domain(self, accounts: list[dict[str, Any]]) -> str:
  427. for item in reversed(accounts):
  428. domain = str(item.get("email_domain") or "").strip().lower()
  429. if domain and item.get("enabled", True):
  430. return domain
  431. return ""
  432. def normalize_accounts_to_single_active_domain(self) -> dict[str, Any]:
  433. accounts = self._load_all_accounts()
  434. active_domain = self._pick_active_domain(accounts)
  435. if not active_domain:
  436. return {"active_domain": "", "removed_domains": []}
  437. managed_domains = set(self._managed_auto_domains(accounts))
  438. previous_managed_domain = ""
  439. for item in reversed(accounts):
  440. domain = str(item.get("email_domain") or "").strip().lower()
  441. if not domain or domain == active_domain or domain not in managed_domains:
  442. continue
  443. previous_managed_domain = domain
  444. break
  445. normalized_accounts: list[dict[str, Any]] = []
  446. removed_domains: list[str] = []
  447. changed = False
  448. for item in accounts:
  449. domain = str(item.get("email_domain") or "").strip().lower()
  450. if domain == active_domain:
  451. if item.get("enabled") is not True:
  452. changed = True
  453. item["enabled"] = True
  454. normalized_accounts.append(item)
  455. continue
  456. if domain == previous_managed_domain:
  457. if item.get("enabled") is not False:
  458. changed = True
  459. item["enabled"] = False
  460. normalized_accounts.append(item)
  461. continue
  462. if domain in managed_domains:
  463. removed_domains.append(domain)
  464. changed = True
  465. continue
  466. if item.get("enabled") is not False:
  467. changed = True
  468. item["enabled"] = False
  469. normalized_accounts.append(item)
  470. if changed or len(normalized_accounts) != len(accounts):
  471. self._write_accounts(normalized_accounts)
  472. for domain in removed_domains:
  473. try:
  474. self._delete_domain_artifacts(domain)
  475. except Exception:
  476. pass
  477. return {"active_domain": active_domain, "removed_domains": removed_domains}
  478. def provision_additional_domain(self) -> ProvisionResult:
  479. current = self.current_active_account()
  480. worker_domain = str(current.get("worker_domain") or "").strip()
  481. admin_password = str(current.get("admin_password") or "").strip()
  482. if not worker_domain or not admin_password:
  483. return ProvisionResult(success=False, step="load_active_account", error="active cfmail account incomplete")
  484. label = self._make_new_label()
  485. new_domain = self._new_domain(label)
  486. try:
  487. self._create_email_routing_rule(new_domain, label)
  488. self._create_dns_records(new_domain)
  489. existing_domains = self.current_active_domains()
  490. self._set_worker_domains([*existing_domains, new_domain])
  491. self.smoke_test(worker_domain, admin_password, new_domain)
  492. accounts = self._load_all_accounts()
  493. accounts.append(
  494. {
  495. "name": f"cfmail-{new_domain.split('.', 1)[0]}",
  496. "worker_domain": _normalize_host(worker_domain),
  497. "email_domain": new_domain,
  498. "admin_password": admin_password,
  499. "enabled": True,
  500. }
  501. )
  502. self._write_accounts(accounts)
  503. return ProvisionResult(success=True, step="provision_additional_domain", new_domain=new_domain)
  504. except Exception as exc:
  505. try:
  506. self._delete_domain_artifacts(new_domain)
  507. except Exception:
  508. pass
  509. return ProvisionResult(
  510. success=False,
  511. step="provision_additional_domain",
  512. new_domain=new_domain,
  513. error=str(exc),
  514. )
  515. def retire_domain(self, domain: str) -> ProvisionResult:
  516. domain_key = self._normalize_domain_name(domain)
  517. if not domain_key:
  518. return ProvisionResult(success=False, step="retire_domain", error="missing domain")
  519. accounts = self._load_all_accounts()
  520. active_before = self.current_active_domains()
  521. matched = False
  522. updated_accounts: list[dict[str, Any]] = []
  523. for item in accounts:
  524. item_domain = self._normalize_domain_name(str(item.get("email_domain") or ""))
  525. if item_domain != domain_key:
  526. updated_accounts.append(item)
  527. continue
  528. matched = True
  529. if self._is_managed_auto_domain(domain_key):
  530. continue
  531. item["enabled"] = False
  532. updated_accounts.append(item)
  533. if not matched:
  534. return ProvisionResult(success=False, step="retire_domain", old_domain=domain_key, error="domain not found")
  535. self._write_accounts(updated_accounts)
  536. active_after = [
  537. self._normalize_domain_name(str(item.get("email_domain") or ""))
  538. for item in updated_accounts
  539. if bool(item.get("enabled", True))
  540. ]
  541. if active_after:
  542. self._set_worker_domains(active_after)
  543. elif active_before:
  544. self._set_worker_domains([d for d in active_before if d != domain_key])
  545. if self._is_managed_auto_domain(domain_key):
  546. try:
  547. self._delete_domain_artifacts(domain_key)
  548. except Exception:
  549. pass
  550. return ProvisionResult(success=True, step="retire_domain", old_domain=domain_key)
  551. def normalize_to_domain_pool(self, target_count: int) -> dict[str, Any]:
  552. desired = max(1, int(target_count))
  553. accounts = self._load_all_accounts()
  554. enabled_accounts = [
  555. dict(item)
  556. for item in accounts
  557. if bool(item.get("enabled", True))
  558. and str(item.get("email_domain") or "").strip()
  559. ]
  560. changed = False
  561. provisioned_domains: list[str] = []
  562. retired_domains: list[str] = []
  563. if not enabled_accounts:
  564. latest_index = -1
  565. for idx in range(len(accounts) - 1, -1, -1):
  566. domain = self._normalize_domain_name(str(accounts[idx].get("email_domain") or ""))
  567. if domain:
  568. latest_index = idx
  569. break
  570. if latest_index >= 0:
  571. accounts[latest_index]["enabled"] = True
  572. enabled_accounts = [dict(accounts[latest_index])]
  573. changed = True
  574. self._write_accounts(accounts)
  575. if len(enabled_accounts) > desired:
  576. keep = enabled_accounts[-desired:]
  577. keep_domains = {
  578. self._normalize_domain_name(str(item.get("email_domain") or ""))
  579. for item in keep
  580. }
  581. for item in enabled_accounts[:-desired]:
  582. domain = self._normalize_domain_name(str(item.get("email_domain") or ""))
  583. if not domain:
  584. continue
  585. result = self.retire_domain(domain)
  586. if result.success:
  587. retired_domains.append(domain)
  588. enabled_accounts = self.current_active_accounts()
  589. changed = True
  590. while len(enabled_accounts) < desired:
  591. result = self.provision_additional_domain()
  592. if not result.success:
  593. break
  594. provisioned_domains.append(result.new_domain)
  595. enabled_accounts = self.current_active_accounts()
  596. changed = True
  597. active_domains = [
  598. self._normalize_domain_name(str(item.get("email_domain") or ""))
  599. for item in enabled_accounts
  600. if self._normalize_domain_name(str(item.get("email_domain") or ""))
  601. ]
  602. if active_domains:
  603. self._set_worker_domains(active_domains)
  604. return {
  605. "active_domains": active_domains,
  606. "provisioned_domains": provisioned_domains,
  607. "retired_domains": retired_domains,
  608. "changed": changed,
  609. }
  610. def switch_active_domain(self, *, old_domain: str, new_domain: str, worker_domain: str, admin_password: str) -> list[str]:
  611. accounts = self._load_all_accounts()
  612. managed_domains = set(self._managed_auto_domains(accounts))
  613. old_domain_key = str(old_domain or "").strip().lower()
  614. replacement = {
  615. "name": f"cfmail-{new_domain.split('.', 1)[0]}",
  616. "worker_domain": _normalize_host(worker_domain),
  617. "email_domain": new_domain,
  618. "admin_password": admin_password,
  619. "enabled": True,
  620. }
  621. normalized_accounts: list[dict[str, Any]] = []
  622. removed_domains: list[str] = []
  623. matched = False
  624. for item in accounts:
  625. domain = str(item.get("email_domain") or "").strip().lower()
  626. if domain == new_domain.lower():
  627. item.update(replacement)
  628. item["enabled"] = True
  629. normalized_accounts.append(item)
  630. matched = True
  631. continue
  632. if domain == old_domain_key:
  633. item["enabled"] = False
  634. normalized_accounts.append(item)
  635. continue
  636. if domain in managed_domains:
  637. removed_domains.append(domain)
  638. continue
  639. item["enabled"] = False
  640. normalized_accounts.append(item)
  641. if not matched:
  642. normalized_accounts.append(replacement)
  643. self._write_accounts(normalized_accounts)
  644. return sorted(set(domain for domain in removed_domains if domain and domain != new_domain.lower()))
  645. def current_active_account(self) -> dict[str, Any]:
  646. accounts = self._load_all_accounts()
  647. active_domain = self._pick_active_domain(accounts)
  648. for item in reversed(accounts):
  649. if str(item.get("email_domain") or "").strip().lower() == active_domain:
  650. return item
  651. raise RuntimeError("no active cfmail account found")
  652. def rotate_active_domain(self) -> ProvisionResult:
  653. current = self.current_active_account()
  654. old_domain = str(current.get("email_domain") or "").strip().lower()
  655. worker_domain = str(current.get("worker_domain") or "").strip()
  656. admin_password = str(current.get("admin_password") or "").strip()
  657. if not old_domain or not worker_domain or not admin_password:
  658. return ProvisionResult(success=False, step="load_active_account", error="active cfmail account incomplete")
  659. last_error = ""
  660. last_new_domain = ""
  661. for attempt in range(2):
  662. label = self._make_new_label()
  663. new_domain = self._new_domain(label)
  664. last_new_domain = new_domain
  665. try:
  666. self._create_email_routing_rule(new_domain, label)
  667. self._create_dns_records(new_domain)
  668. self._update_worker_domains(new_domain, old_domain=old_domain)
  669. self.smoke_test(worker_domain, admin_password, new_domain)
  670. self.switch_active_domain(
  671. old_domain=old_domain,
  672. new_domain=new_domain,
  673. worker_domain=worker_domain,
  674. admin_password=admin_password,
  675. )
  676. try:
  677. self.cleanup_stale_cf_resources(keep_domains={old_domain})
  678. except Exception:
  679. pass # cleanup is best-effort, must not abort rotation
  680. return ProvisionResult(
  681. success=True,
  682. step="completed",
  683. old_domain=old_domain,
  684. new_domain=new_domain,
  685. )
  686. except Exception as exc:
  687. last_error = str(exc)
  688. try:
  689. self._delete_domain_artifacts(new_domain)
  690. except Exception:
  691. pass
  692. if attempt == 0 and self._is_record_quota_error(exc):
  693. cleanup_result = self.cleanup_stale_cf_resources()
  694. if (
  695. cleanup_result.get("removed_domains")
  696. or cleanup_result.get("removed_dns_records")
  697. or cleanup_result.get("removed_routing_rules")
  698. ):
  699. continue
  700. break
  701. return ProvisionResult(
  702. success=False,
  703. step="failed",
  704. old_domain=old_domain,
  705. new_domain=last_new_domain,
  706. error=last_error,
  707. )