prefs.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """Per-user preferences.
  2. Admin config sets the global default for each pref; a user may override it, and the
  3. override persists across logins. Overrides live in the existing jobs.db (a separate
  4. connection — WAL + busy_timeout are set by JobQueue), NOT in the shared config.toml.
  5. Key-value schema so adding a new pref needs no migration. The effective value is the
  6. admin default overridden by the user's stored value ONLY IF it still validates against
  7. the live config — so if an admin deletes a folder a user had pinned, that user quietly
  8. falls back to the default instead of hitting an unknown-folder error.
  9. """
  10. from __future__ import annotations
  11. import json
  12. import logging
  13. import sqlite3
  14. import threading
  15. from dataclasses import dataclass
  16. from datetime import datetime, timezone
  17. from typing import Any, Callable, Mapping
  18. log = logging.getLogger("pps.prefs")
  19. _SORT_FIELDS = {"subject", "date", "from", "rcpt"}
  20. _SORT_DIRS = {"asc", "desc"}
  21. @dataclass(frozen=True)
  22. class PrefSpec:
  23. default_path: tuple[str, ...] # where the admin default lives
  24. coerce: Callable[[Any], Any]
  25. validate: Callable[[Any, Mapping], bool] # (value, live quarantine cfg) -> ok
  26. def _in_folders(v, q) -> bool:
  27. return v in q.get("folders", [])
  28. PREF_SPECS: dict[str, PrefSpec] = {
  29. "default_folder": PrefSpec(("default_folder",), str, _in_folders),
  30. "default_limit": PrefSpec(("default_limit",), int, lambda v, q: 1 <= v <= 1000),
  31. "sort_field": PrefSpec(("default_sort_field",), str, lambda v, q: v in _SORT_FIELDS),
  32. "sort_dir": PrefSpec(("default_sort_dir",), str, lambda v, q: v in _SORT_DIRS),
  33. }
  34. def _now() -> str:
  35. return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
  36. class PrefStore:
  37. def __init__(self, db_path: str):
  38. self._lock = threading.Lock()
  39. self._conn = sqlite3.connect(db_path, check_same_thread=False)
  40. self._conn.row_factory = sqlite3.Row
  41. with self._lock:
  42. self._conn.execute("PRAGMA busy_timeout=5000")
  43. self._conn.execute(
  44. """CREATE TABLE IF NOT EXISTS user_prefs (
  45. email TEXT NOT NULL,
  46. key TEXT NOT NULL,
  47. value TEXT NOT NULL,
  48. updated_at TEXT NOT NULL,
  49. PRIMARY KEY (email, key)
  50. )"""
  51. )
  52. self._conn.commit()
  53. # ------------------------------------------------------------------ reads
  54. def raw(self, email: str) -> dict:
  55. """The user's stored overrides (decoded), unvalidated."""
  56. with self._lock:
  57. rows = self._conn.execute(
  58. "SELECT key, value FROM user_prefs WHERE email=?", (email,)
  59. ).fetchall()
  60. out = {}
  61. for r in rows:
  62. try:
  63. out[r["key"]] = json.loads(r["value"])
  64. except json.JSONDecodeError:
  65. continue
  66. return out
  67. def effective(self, email: str, snap) -> dict:
  68. """Admin defaults overridden by valid user values."""
  69. q = snap.quarantine
  70. overrides = self.raw(email)
  71. out = {}
  72. for key, spec in PREF_SPECS.items():
  73. default = _dig(q, spec.default_path)
  74. value = default
  75. if key in overrides:
  76. try:
  77. cand = spec.coerce(overrides[key])
  78. if spec.validate(cand, q):
  79. value = cand
  80. except (TypeError, ValueError):
  81. pass
  82. out[key] = value
  83. return out
  84. def describe(self, email: str, snap) -> dict:
  85. q = snap.quarantine
  86. defaults = {k: _dig(q, s.default_path) for k, s in PREF_SPECS.items()}
  87. overrides = self.raw(email)
  88. return {
  89. "prefs": self.effective(email, snap),
  90. "defaults": defaults,
  91. "overridden": sorted(k for k in overrides if k in PREF_SPECS),
  92. }
  93. # ------------------------------------------------------------------ writes
  94. def set_many(self, email: str, patch: dict, snap) -> dict:
  95. """Validate + persist a user's overrides. Returns the new effective prefs."""
  96. q = snap.quarantine
  97. to_write = []
  98. for key, value in patch.items():
  99. spec = PREF_SPECS.get(key)
  100. if spec is None:
  101. raise ValueError(f"unknown preference: {key}")
  102. try:
  103. coerced = spec.coerce(value)
  104. except (TypeError, ValueError):
  105. raise ValueError(f"invalid value for {key}: {value!r}")
  106. if not spec.validate(coerced, q):
  107. raise ValueError(f"invalid value for {key}: {value!r}")
  108. to_write.append((email, key, json.dumps(coerced), _now()))
  109. with self._lock:
  110. self._conn.executemany(
  111. "INSERT INTO user_prefs (email, key, value, updated_at) VALUES (?,?,?,?)"
  112. " ON CONFLICT(email, key) DO UPDATE SET value=excluded.value,"
  113. " updated_at=excluded.updated_at",
  114. to_write,
  115. )
  116. self._conn.commit()
  117. return self.effective(email, snap)
  118. def clear(self, email: str, keys: list[str] | None = None) -> None:
  119. with self._lock:
  120. if keys:
  121. self._conn.executemany(
  122. "DELETE FROM user_prefs WHERE email=? AND key=?",
  123. [(email, k) for k in keys],
  124. )
  125. else:
  126. self._conn.execute("DELETE FROM user_prefs WHERE email=?", (email,))
  127. self._conn.commit()
  128. def _dig(m: Mapping, path: tuple[str, ...]):
  129. cur: Any = m
  130. for p in path:
  131. if not isinstance(cur, Mapping) or p not in cur:
  132. return None
  133. cur = cur[p]
  134. return cur