cfmail_domain_rotation.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """cfmail domain blacklist tracking and rotation gating."""
  2. from __future__ import annotations
  3. from collections import deque
  4. from dataclasses import dataclass, field
  5. from datetime import datetime, timezone
  6. import os
  7. import threading
  8. import time
  9. from typing import Any
  10. BLACKLIST_ERROR_CODES = frozenset({"registration_disallowed", "unsupported_email"})
  11. MAILBOX_REUSED_ERROR_CODES = frozenset({"user_already_exists"})
  12. DEFAULT_ROTATION_WINDOW = 10
  13. DEFAULT_ROTATION_THRESHOLD = 6
  14. DEFAULT_ROTATION_COOLDOWN_SECONDS = 300
  15. DEFAULT_ROTATION_MAX_SUCCESSES = 2
  16. DEFAULT_MAILBOX_REUSED_THRESHOLD = 2
  17. DEFAULT_REGISTRATION_DISALLOWED_THRESHOLD = 2
  18. def _env_int(name: str, default: int, minimum: int = 1) -> int:
  19. try:
  20. return max(minimum, int(str(os.getenv(name, default)).strip() or str(default)))
  21. except Exception:
  22. return max(minimum, default)
  23. def _utc_now() -> str:
  24. return datetime.now(timezone.utc).isoformat(timespec="seconds")
  25. def extract_email_domain(payload: dict[str, Any] | None) -> str:
  26. raw = payload if isinstance(payload, dict) else {}
  27. metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
  28. domain = str(metadata.get("email_domain") or "").strip().lower()
  29. if domain:
  30. return domain
  31. email = str(raw.get("email") or "").strip().lower()
  32. if "@" not in email:
  33. return ""
  34. return email.rsplit("@", 1)[-1].strip().lower()
  35. @dataclass(frozen=True)
  36. class DomainAttempt:
  37. domain: str
  38. stage: str
  39. success: bool
  40. proxy_key: str
  41. error_message: str
  42. blacklist_code: str = ""
  43. backend_failure: bool = False
  44. recorded_at: float = field(default_factory=time.time)
  45. @property
  46. def is_blacklist_failure(self) -> bool:
  47. return bool(self.blacklist_code)
  48. def classify_domain_attempt(payload: dict[str, Any] | None, *, proxy_key: str = "") -> DomainAttempt | None:
  49. raw = payload if isinstance(payload, dict) else {}
  50. domain = extract_email_domain(raw)
  51. if not domain:
  52. return None
  53. metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
  54. stage = str(raw.get("stage") or "").strip()
  55. success = bool(raw.get("success"))
  56. error_message = str(raw.get("error_message") or "").strip()
  57. blacklist_code = ""
  58. if stage == "create_account":
  59. candidate = str(metadata.get("create_account_error_code") or "").strip().lower()
  60. if candidate in BLACKLIST_ERROR_CODES or candidate in MAILBOX_REUSED_ERROR_CODES:
  61. blacklist_code = candidate
  62. backend_failure = stage == "mailbox"
  63. return DomainAttempt(
  64. domain=domain,
  65. stage=stage,
  66. success=success,
  67. proxy_key=str(proxy_key or "").strip(),
  68. error_message=error_message,
  69. blacklist_code=blacklist_code,
  70. backend_failure=backend_failure,
  71. )
  72. @dataclass
  73. class RotationDecision:
  74. should_rotate: bool
  75. domain: str = ""
  76. reason: str = ""
  77. blacklist_failures: int = 0
  78. successes: int = 0
  79. window_size: int = 0
  80. class DomainHealthTracker:
  81. def __init__(
  82. self,
  83. *,
  84. window_size: int | None = None,
  85. blacklist_threshold: int | None = None,
  86. rotation_cooldown_seconds: int | None = None,
  87. max_successes_in_window: int | None = None,
  88. mailbox_reused_threshold: int | None = None,
  89. registration_disallowed_threshold: int | None = None,
  90. ) -> None:
  91. self.window_size = window_size or _env_int(
  92. "ZHUCE6_CFMAIL_ROTATION_WINDOW",
  93. DEFAULT_ROTATION_WINDOW,
  94. )
  95. self.blacklist_threshold = blacklist_threshold or _env_int(
  96. "ZHUCE6_CFMAIL_ROTATION_BLACKLIST_THRESHOLD",
  97. DEFAULT_ROTATION_THRESHOLD,
  98. )
  99. self.rotation_cooldown_seconds = rotation_cooldown_seconds or _env_int(
  100. "ZHUCE6_CFMAIL_ROTATION_COOLDOWN_SECONDS",
  101. DEFAULT_ROTATION_COOLDOWN_SECONDS,
  102. )
  103. self.max_successes_in_window = max_successes_in_window or _env_int(
  104. "ZHUCE6_CFMAIL_ROTATION_MAX_SUCCESSES",
  105. DEFAULT_ROTATION_MAX_SUCCESSES,
  106. )
  107. self.mailbox_reused_threshold = mailbox_reused_threshold or _env_int(
  108. "ZHUCE6_CFMAIL_MAILBOX_REUSED_THRESHOLD",
  109. DEFAULT_MAILBOX_REUSED_THRESHOLD,
  110. )
  111. self.registration_disallowed_threshold = registration_disallowed_threshold or _env_int(
  112. "ZHUCE6_CFMAIL_REGISTRATION_DISALLOWED_THRESHOLD",
  113. DEFAULT_REGISTRATION_DISALLOWED_THRESHOLD,
  114. )
  115. self._lock = threading.RLock()
  116. self._events: dict[str, deque[DomainAttempt]] = {}
  117. self._rotation_state: dict[str, Any] = {
  118. "in_progress": False,
  119. "active_domain": "",
  120. "last_blacklisted_domain": "",
  121. "last_new_domain": "",
  122. "last_reason": "",
  123. "last_error": "",
  124. "last_rotated_at": "",
  125. "last_checked_at": "",
  126. "cooldown_until": 0.0,
  127. }
  128. def record(self, attempt: DomainAttempt) -> RotationDecision:
  129. with self._lock:
  130. events = self._events.setdefault(attempt.domain, deque(maxlen=self.window_size))
  131. events.append(attempt)
  132. self._rotation_state["active_domain"] = attempt.domain
  133. self._rotation_state["last_checked_at"] = _utc_now()
  134. return self._evaluate_locked(attempt.domain)
  135. def _evaluate_locked(self, domain: str) -> RotationDecision:
  136. events = list(self._events.get(domain) or [])
  137. if not events:
  138. return RotationDecision(should_rotate=False, domain=domain)
  139. blacklist_failures = sum(1 for item in events if item.is_blacklist_failure)
  140. mailbox_reused_failures = sum(1 for item in events if item.blacklist_code in MAILBOX_REUSED_ERROR_CODES)
  141. registration_disallowed_failures = sum(1 for item in events if item.blacklist_code == "registration_disallowed")
  142. successes = sum(1 for item in events if item.success)
  143. backend_failures = sum(1 for item in events if item.backend_failure)
  144. if time.time() < float(self._rotation_state.get("cooldown_until") or 0):
  145. return RotationDecision(
  146. should_rotate=False,
  147. domain=domain,
  148. reason="rotation cooldown active",
  149. blacklist_failures=blacklist_failures,
  150. successes=successes,
  151. window_size=len(events),
  152. )
  153. if backend_failures > 0 and blacklist_failures == 0:
  154. return RotationDecision(
  155. should_rotate=False,
  156. domain=domain,
  157. reason="backend failure detected",
  158. blacklist_failures=blacklist_failures,
  159. successes=successes,
  160. window_size=len(events),
  161. )
  162. if mailbox_reused_failures >= self.mailbox_reused_threshold:
  163. return RotationDecision(
  164. should_rotate=True,
  165. domain=domain,
  166. reason="mailbox_reused threshold reached",
  167. blacklist_failures=blacklist_failures,
  168. successes=successes,
  169. window_size=len(events),
  170. )
  171. if (
  172. registration_disallowed_failures >= self.registration_disallowed_threshold
  173. and successes <= self.max_successes_in_window
  174. ):
  175. return RotationDecision(
  176. should_rotate=True,
  177. domain=domain,
  178. reason="registration_disallowed threshold reached",
  179. blacklist_failures=blacklist_failures,
  180. successes=successes,
  181. window_size=len(events),
  182. )
  183. if len(events) < self.window_size:
  184. return RotationDecision(
  185. should_rotate=False,
  186. domain=domain,
  187. reason="insufficient signal window",
  188. blacklist_failures=blacklist_failures,
  189. successes=successes,
  190. window_size=len(events),
  191. )
  192. if blacklist_failures >= self.blacklist_threshold and successes <= self.max_successes_in_window:
  193. return RotationDecision(
  194. should_rotate=True,
  195. domain=domain,
  196. reason="blacklist threshold reached",
  197. blacklist_failures=blacklist_failures,
  198. successes=successes,
  199. window_size=len(events),
  200. )
  201. return RotationDecision(
  202. should_rotate=False,
  203. domain=domain,
  204. reason="threshold not met",
  205. blacklist_failures=blacklist_failures,
  206. successes=successes,
  207. window_size=len(events),
  208. )
  209. def mark_rotation_started(self, domain: str, reason: str) -> None:
  210. with self._lock:
  211. self._rotation_state["in_progress"] = True
  212. self._rotation_state["last_blacklisted_domain"] = domain
  213. self._rotation_state["last_reason"] = reason
  214. self._rotation_state["last_error"] = ""
  215. def mark_rotation_completed(self, old_domain: str, new_domain: str) -> None:
  216. with self._lock:
  217. self._rotation_state["in_progress"] = False
  218. self._rotation_state["active_domain"] = new_domain
  219. self._rotation_state["last_blacklisted_domain"] = old_domain
  220. self._rotation_state["last_new_domain"] = new_domain
  221. self._rotation_state["last_error"] = ""
  222. self._rotation_state["last_rotated_at"] = _utc_now()
  223. self._rotation_state["cooldown_until"] = time.time() + self.rotation_cooldown_seconds
  224. self._events.pop(old_domain, None)
  225. def mark_rotation_failed(self, domain: str, error: str) -> None:
  226. with self._lock:
  227. self._rotation_state["in_progress"] = False
  228. self._rotation_state["last_blacklisted_domain"] = domain
  229. self._rotation_state["last_error"] = str(error or "").strip()[:300]
  230. self._rotation_state["cooldown_until"] = time.time() + self.rotation_cooldown_seconds
  231. def snapshot(self) -> dict[str, Any]:
  232. with self._lock:
  233. return {
  234. "in_progress": bool(self._rotation_state.get("in_progress")),
  235. "active_domain": str(self._rotation_state.get("active_domain") or ""),
  236. "last_blacklisted_domain": str(self._rotation_state.get("last_blacklisted_domain") or ""),
  237. "last_new_domain": str(self._rotation_state.get("last_new_domain") or ""),
  238. "last_reason": str(self._rotation_state.get("last_reason") or ""),
  239. "last_error": str(self._rotation_state.get("last_error") or ""),
  240. "last_rotated_at": str(self._rotation_state.get("last_rotated_at") or ""),
  241. "last_checked_at": str(self._rotation_state.get("last_checked_at") or ""),
  242. "window_size": self.window_size,
  243. "blacklist_threshold": self.blacklist_threshold,
  244. "mailbox_reused_threshold": self.mailbox_reused_threshold,
  245. "registration_disallowed_threshold": self.registration_disallowed_threshold,
  246. "max_successes_in_window": self.max_successes_in_window,
  247. }