mailbox_dedupe.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. """Local mailbox dedupe store for cfmail-style disposable addresses."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from datetime import datetime
  5. import json
  6. from pathlib import Path
  7. import re
  8. import threading
  9. def _safe_component(value: str) -> str:
  10. cleaned = re.sub(r"[^A-Za-z0-9@._+-]+", "_", str(value or "").strip())
  11. return cleaned.strip("._") or "mailbox"
  12. def _normalize_email(email: str) -> str:
  13. return str(email or "").strip().lower()
  14. @dataclass(frozen=True)
  15. class MailboxDedupeEvent:
  16. timestamp: str
  17. action: str
  18. email: str
  19. reason: str = ""
  20. class MailboxDedupeStore:
  21. def __init__(self, *, state_file: Path, pool_dir: Path) -> None:
  22. self.state_file = Path(state_file)
  23. self.pool_dir = Path(pool_dir)
  24. self._lock = threading.RLock()
  25. self._loaded = False
  26. self._seen: set[str] = set()
  27. self._inflight: set[str] = set()
  28. def _ensure_loaded(self) -> None:
  29. if self._loaded:
  30. return
  31. self.state_file.parent.mkdir(parents=True, exist_ok=True)
  32. if self.state_file.exists():
  33. for raw_line in self.state_file.read_text(encoding="utf-8").splitlines():
  34. line = raw_line.strip()
  35. if not line:
  36. continue
  37. try:
  38. payload = json.loads(line)
  39. except json.JSONDecodeError:
  40. continue
  41. email = _normalize_email(str(payload.get("email") or ""))
  42. if email:
  43. self._seen.add(email)
  44. self._loaded = True
  45. def _pool_file_exists(self, email: str) -> bool:
  46. target = self.pool_dir / f"{_safe_component(email)}.json"
  47. return target.exists()
  48. def _append_event(self, action: str, email: str, *, reason: str = "") -> None:
  49. event = MailboxDedupeEvent(
  50. timestamp=datetime.now().astimezone().isoformat(timespec="seconds"),
  51. action=action,
  52. email=email,
  53. reason=reason,
  54. )
  55. with self.state_file.open("a", encoding="utf-8") as handle:
  56. handle.write(json.dumps(event.__dict__, ensure_ascii=False) + "\n")
  57. def reserve(self, email: str) -> bool:
  58. normalized = _normalize_email(email)
  59. if not normalized:
  60. return False
  61. with self._lock:
  62. self._ensure_loaded()
  63. if normalized in self._inflight or normalized in self._seen or self._pool_file_exists(normalized):
  64. self._seen.add(normalized)
  65. return False
  66. self._seen.add(normalized)
  67. self._inflight.add(normalized)
  68. self._append_event("reserve", normalized)
  69. return True
  70. def release(self, email: str) -> None:
  71. normalized = _normalize_email(email)
  72. if not normalized:
  73. return
  74. with self._lock:
  75. self._inflight.discard(normalized)
  76. def mark(self, email: str, *, reason: str) -> None:
  77. normalized = _normalize_email(email)
  78. if not normalized:
  79. return
  80. with self._lock:
  81. self._ensure_loaded()
  82. self._seen.add(normalized)
  83. self._append_event("mark", normalized, reason=reason)
  84. _STORE_CACHE: dict[tuple[str, str], MailboxDedupeStore] = {}
  85. _STORE_CACHE_LOCK = threading.Lock()
  86. def get_mailbox_dedupe_store(*, state_file: Path, pool_dir: Path) -> MailboxDedupeStore:
  87. resolved_state_file = Path(state_file).expanduser().resolve()
  88. resolved_pool_dir = Path(pool_dir).expanduser().resolve()
  89. key = (str(resolved_state_file), str(resolved_pool_dir))
  90. with _STORE_CACHE_LOCK:
  91. store = _STORE_CACHE.get(key)
  92. if store is None:
  93. store = MailboxDedupeStore(state_file=resolved_state_file, pool_dir=resolved_pool_dir)
  94. _STORE_CACHE[key] = store
  95. return store