hoarse 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. hoarse/__init__.py +12 -0
  2. hoarse/__main__.py +80 -0
  3. hoarse/_build_meta.py +6 -0
  4. hoarse/buildinfo.py +101 -0
  5. hoarse/core/__init__.py +1 -0
  6. hoarse/core/credentials.py +104 -0
  7. hoarse/core/errors.py +78 -0
  8. hoarse/core/logging.py +106 -0
  9. hoarse/core/mailtypes.py +187 -0
  10. hoarse/core/paths.py +89 -0
  11. hoarse/core/settings.py +284 -0
  12. hoarse/engine/__init__.py +1 -0
  13. hoarse/engine/__main__.py +5 -0
  14. hoarse/engine/account.py +964 -0
  15. hoarse/engine/accounts.py +419 -0
  16. hoarse/engine/main.py +1488 -0
  17. hoarse/engine/replay.py +928 -0
  18. hoarse/engine/send.py +217 -0
  19. hoarse/engine/spawn.py +79 -0
  20. hoarse/engine/sync/__init__.py +21 -0
  21. hoarse/engine/sync/bodies.py +206 -0
  22. hoarse/engine/sync/folder.py +417 -0
  23. hoarse/engine/sync/policy.py +221 -0
  24. hoarse/engine/sync/store.py +244 -0
  25. hoarse/features/__init__.py +1 -0
  26. hoarse/features/checkpoint.py +102 -0
  27. hoarse/features/cli.py +116 -0
  28. hoarse/features/exporters.py +272 -0
  29. hoarse/features/folderpath.py +126 -0
  30. hoarse/features/formats/__init__.py +1 -0
  31. hoarse/features/formats/eml.py +89 -0
  32. hoarse/features/formats/flags.py +85 -0
  33. hoarse/features/formats/maildir.py +163 -0
  34. hoarse/features/formats/mbox.py +542 -0
  35. hoarse/features/formats/safeio.py +60 -0
  36. hoarse/features/importers.py +415 -0
  37. hoarse/features/profile_lock.py +67 -0
  38. hoarse/htmlmail/__init__.py +1 -0
  39. hoarse/ipc/__init__.py +1 -0
  40. hoarse/ipc/client.py +184 -0
  41. hoarse/ipc/schema.py +520 -0
  42. hoarse/ipc/server.py +428 -0
  43. hoarse/mime/__init__.py +1 -0
  44. hoarse/mime/address.py +606 -0
  45. hoarse/mime/charset.py +142 -0
  46. hoarse/mime/compose.py +1143 -0
  47. hoarse/mime/dates.py +206 -0
  48. hoarse/mime/stream.py +265 -0
  49. hoarse/mime/words.py +570 -0
  50. hoarse/net/__init__.py +1 -0
  51. hoarse/net/autoconfig.py +558 -0
  52. hoarse/net/dns.py +165 -0
  53. hoarse/net/imap/__init__.py +15 -0
  54. hoarse/net/imap/client.py +2090 -0
  55. hoarse/net/imap/commands.py +275 -0
  56. hoarse/net/imap/lexer.py +1043 -0
  57. hoarse/net/imap/parser.py +1779 -0
  58. hoarse/net/imap/utf7.py +126 -0
  59. hoarse/net/probe.py +591 -0
  60. hoarse/net/sasl.py +43 -0
  61. hoarse/net/smtp.py +675 -0
  62. hoarse/net/tls.py +109 -0
  63. hoarse/store/__init__.py +1 -0
  64. hoarse/store/blobs.py +321 -0
  65. hoarse/store/cursor.py +148 -0
  66. hoarse/store/ids.py +85 -0
  67. hoarse/store/ingest.py +697 -0
  68. hoarse/store/liveops.py +292 -0
  69. hoarse/store/migrations/__init__.py +280 -0
  70. hoarse/store/migrations/m0001_initial.py +287 -0
  71. hoarse/store/migrations/m0002_sort_orders.py +134 -0
  72. hoarse/store/migrations/m0003_server_state.py +82 -0
  73. hoarse/store/migrations/m0004_sync_health.py +54 -0
  74. hoarse/store/migrations/m0005_blob_digest.py +45 -0
  75. hoarse/store/migrations/m0006_thread_view.py +125 -0
  76. hoarse/store/outbox.py +743 -0
  77. hoarse/store/reader.py +1253 -0
  78. hoarse/store/schema.py +595 -0
  79. hoarse/store/thread.py +431 -0
  80. hoarse/store/writer.py +3794 -0
  81. hoarse/theme/__init__.py +1 -0
  82. hoarse/theme/apply.py +67 -0
  83. hoarse/theme/assets.py +66 -0
  84. hoarse/theme/pack.py +459 -0
  85. hoarse/theme/packs/high-contrast/theme.toml +67 -0
  86. hoarse/theme/packs/modern-dark/theme.toml +65 -0
  87. hoarse/theme/packs/modern-light/theme.toml +65 -0
  88. hoarse/theme/packs/retro/theme.toml +68 -0
  89. hoarse/theme/painters.py +20 -0
  90. hoarse/theme/qss.py +312 -0
  91. hoarse/theme/resolve.py +316 -0
  92. hoarse/tools/__init__.py +1 -0
  93. hoarse/tools/bench.py +424 -0
  94. hoarse/tools/corpus.py +1733 -0
  95. hoarse/tools/fakeimap/__init__.py +55 -0
  96. hoarse/tools/fakeimap/__main__.py +71 -0
  97. hoarse/tools/fakeimap/certs.py +107 -0
  98. hoarse/tools/fakeimap/search.py +196 -0
  99. hoarse/tools/fakeimap/server.py +1950 -0
  100. hoarse/tools/fakeimap/state.py +605 -0
  101. hoarse/tools/fakeimap/structure.py +559 -0
  102. hoarse/tools/fakeimap/transcript.py +131 -0
  103. hoarse/tools/fakeimap/wire.py +415 -0
  104. hoarse/tools/fakesmtp/__init__.py +65 -0
  105. hoarse/tools/fakesmtp/server.py +848 -0
  106. hoarse/tools/reqcheck.py +455 -0
  107. hoarse/tools/seed.py +278 -0
  108. hoarse/tools/testcap.py +75 -0
  109. hoarse/tools/themecheck.py +91 -0
  110. hoarse/ui/__init__.py +1 -0
  111. hoarse/ui/about.py +90 -0
  112. hoarse/ui/account_settings.py +811 -0
  113. hoarse/ui/accounts.py +610 -0
  114. hoarse/ui/actions.py +224 -0
  115. hoarse/ui/app.py +131 -0
  116. hoarse/ui/compose_util.py +158 -0
  117. hoarse/ui/composer.py +668 -0
  118. hoarse/ui/controller.py +924 -0
  119. hoarse/ui/credsession.py +79 -0
  120. hoarse/ui/dialogs.py +307 -0
  121. hoarse/ui/engine.py +263 -0
  122. hoarse/ui/headers.py +252 -0
  123. hoarse/ui/linkify.py +270 -0
  124. hoarse/ui/mainwindow.py +1414 -0
  125. hoarse/ui/menus.py +320 -0
  126. hoarse/ui/models/__init__.py +1 -0
  127. hoarse/ui/models/demo.py +679 -0
  128. hoarse/ui/models/fetcher.py +416 -0
  129. hoarse/ui/models/message_list.py +1124 -0
  130. hoarse/ui/models/overlay.py +126 -0
  131. hoarse/ui/models/ranges.py +26 -0
  132. hoarse/ui/models/source.py +165 -0
  133. hoarse/ui/problems.py +46 -0
  134. hoarse/ui/screenshot.py +242 -0
  135. hoarse/ui/screenshot_accounts.py +309 -0
  136. hoarse/ui/screenshot_compose.py +177 -0
  137. hoarse/ui/serverform.py +252 -0
  138. hoarse/ui/settings.py +206 -0
  139. hoarse/ui/views/__init__.py +1 -0
  140. hoarse/ui/views/folder_tree.py +582 -0
  141. hoarse/ui/views/glyphs.py +337 -0
  142. hoarse/ui/views/headerblock.py +240 -0
  143. hoarse/ui/views/linkedit.py +155 -0
  144. hoarse/ui/views/message_delegate.py +377 -0
  145. hoarse/ui/views/message_view.py +301 -0
  146. hoarse/ui/views/reader.py +474 -0
  147. hoarse/ui/views/syncbar.py +101 -0
  148. hoarse/ui/welcome.py +63 -0
  149. hoarse/ui/wizard.py +848 -0
  150. hoarse-0.1.1.dist-info/METADATA +59 -0
  151. hoarse-0.1.1.dist-info/RECORD +154 -0
  152. hoarse-0.1.1.dist-info/WHEEL +4 -0
  153. hoarse-0.1.1.dist-info/entry_points.txt +2 -0
  154. hoarse-0.1.1.dist-info/licenses/LICENSE +21 -0
hoarse/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Hoarse — a desktop email client that stays fast at a million messages.
2
+
3
+ The specification in ``docs/spec/`` is normative. Hard invariants live in
4
+ ``.claude/skills/hoarse/SKILL.md``; the two that bite earliest are:
5
+
6
+ * ``hoarse.ui`` must not import ``hoarse.net`` or ``hoarse.engine`` (ARC-4).
7
+ * ``hoarse.engine`` must not import Qt, directly or transitively (ARC-3).
8
+
9
+ Both are enforced by ``tests/unit/test_layering.py``, not by convention.
10
+ """
11
+
12
+ __version__ = "0.1.1"
hoarse/__main__.py ADDED
@@ -0,0 +1,80 @@
1
+ """Launcher. Acquires the profile lock, starts the UI, spawns the engine (ARC-7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from hoarse.core import logging as hoarse_logging
11
+ from hoarse.core.paths import Paths
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ from hoarse import __version__
16
+
17
+ parser = argparse.ArgumentParser(prog="hoarse")
18
+ parser.add_argument("--version", action="version", version=f"hoarse {__version__}")
19
+ parser.add_argument("--build-info", action="store_true",
20
+ help="print the version, commit and runtime, for bug reports")
21
+ parser.add_argument("--profile", type=Path, help="override the profile directory")
22
+ parser.add_argument("--no-keyring", action="store_true", help="never persist credentials")
23
+ parser.add_argument("--verbose", action="store_true")
24
+ parser.add_argument("--demo", action="store_true",
25
+ help="open the interface on synthetic data (1,000,000 messages, no store)")
26
+ parser.add_argument("--theme", default="modern-light",
27
+ choices=("modern-light", "modern-dark", "retro", "high-contrast"),
28
+ help="theme pack for --demo (switchable live: View > Theme)")
29
+ from hoarse.features.cli import add_subcommands # argparse only; no Qt, no store
30
+
31
+ add_subcommands(parser)
32
+ args = parser.parse_args(argv)
33
+ if args.build_info:
34
+ from hoarse.buildinfo import build_info
35
+
36
+ sys.stdout.write(build_info().text() + "\n")
37
+ return 0
38
+
39
+ hoarse_logging.configure(logging.DEBUG if args.verbose else logging.INFO)
40
+ if args.command in ("import", "export"): # FEA-24: headless, no window, no engine
41
+ from hoarse.features.cli import run
42
+
43
+ return run(args)
44
+ if args.demo:
45
+ from hoarse.ui.app import run_demo # lazy: Qt loads after argument parsing (UI-36)
46
+
47
+ return run_demo(args.theme)
48
+ paths = Paths(args.profile)
49
+ paths.ensure()
50
+
51
+ from hoarse.features.profile_lock import ProfileBusyError, ProfileLock
52
+
53
+ log = logging.getLogger("hoarse")
54
+ log.info("profile at %s", paths.data)
55
+ lock = ProfileLock(paths.data / "hoarse.lock") # ARC-7: one UI (and one engine) per profile
56
+ try:
57
+ lock.acquire()
58
+ except ProfileBusyError as exc:
59
+ log.error("%s", exc)
60
+ return 1
61
+ try:
62
+ from hoarse.engine.spawn import spawn_engine # the launcher may; the UI package may not
63
+ from hoarse.ui.app import run_profile # lazy: Qt loads after argument parsing (UI-36)
64
+ from hoarse.ui.credsession import CredentialSession # no Qt in this one
65
+
66
+ session = CredentialSession(use_keyring=not args.no_keyring)
67
+
68
+ def launch(): # type: ignore[no-untyped-def]
69
+ # The session may have switched to --no-keyring after a keychain failure (SEC-24),
70
+ # so the flag is read at each (re)start, not once.
71
+ extra = ("--no-keyring",) if session.no_keyring else ()
72
+ return spawn_engine(args.profile, extra_args=extra)
73
+
74
+ return run_profile(args.profile, launch, args.theme, session=session)
75
+ finally:
76
+ lock.release()
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
hoarse/_build_meta.py ADDED
@@ -0,0 +1,6 @@
1
+ """Written by hatch_build.py at build time. Do not edit."""
2
+
3
+ COMMIT = '918ab9ece652'
4
+ COMMIT_DATE = '2026-10-22'
5
+ BUILT = '2026-10-22 13:30 UTC'
6
+ DIRTY = False
hoarse/buildinfo.py ADDED
@@ -0,0 +1,101 @@
1
+ """What build this is: version, source commit, and the runtime it runs on.
2
+
3
+ No Qt and no store: ``hoarse --version`` and the About dialog both use it.
4
+
5
+ The commit comes from, in order:
6
+
7
+ 1. ``hoarse._build_meta``, written into the wheel and sdist at build time by
8
+ ``hatch_build.py`` (a release installed from PyPI has no ``.git``);
9
+ 2. ``git`` in the checkout this package was imported from (``uv run hoarse``);
10
+ 3. otherwise it is unknown, and says so.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import platform
16
+ import sqlite3
17
+ import subprocess
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from hoarse import __version__
23
+
24
+ __all__ = ["BuildInfo", "build_info"]
25
+
26
+ _CHECKOUT = Path(__file__).resolve().parents[2] # src/hoarse/ -> repository root
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class BuildInfo:
31
+ version: str
32
+ commit: str | None # short hash; None when unknown
33
+ commit_date: str | None # ISO date of that commit
34
+ built: str | None # ISO time the package was built; None for a checkout
35
+ dirty: bool # a checkout with uncommitted changes
36
+ origin: str # "release build" | "source checkout" | "unknown origin"
37
+ python: str
38
+ platform: str
39
+ sqlite: str
40
+ qt: str | None = None # filled in by the UI (Qt must not be imported here)
41
+ pyside: str | None = None
42
+
43
+ @property
44
+ def commit_text(self) -> str:
45
+ if self.commit is None:
46
+ return "unknown"
47
+ text = self.commit + (" (uncommitted changes)" if self.dirty else "")
48
+ return f"{text}, {self.commit_date}" if self.commit_date else text
49
+
50
+ def lines(self) -> list[tuple[str, str]]:
51
+ """``(label, value)`` rows, in display order."""
52
+ rows = [("Version", self.version), ("Build", self.origin),
53
+ ("Commit", self.commit_text)]
54
+ if self.built:
55
+ rows.append(("Built", self.built))
56
+ rows += [("Python", self.python), ("Platform", self.platform),
57
+ ("SQLite", self.sqlite)]
58
+ if self.qt:
59
+ rows.append(("Qt", f"{self.qt} (PySide6 {self.pyside})" if self.pyside else self.qt))
60
+ return rows
61
+
62
+ def text(self) -> str:
63
+ """Plain text for "Copy build info" and bug reports."""
64
+ return "\n".join(f"{k}: {v}" for k, v in [("Hoarse", self.version), *self.lines()[1:]])
65
+
66
+
67
+ def _git(*args: str) -> str | None:
68
+ try:
69
+ out = subprocess.run(["git", "-C", str(_CHECKOUT), *args], capture_output=True,
70
+ text=True, timeout=2, check=True)
71
+ except (OSError, subprocess.SubprocessError):
72
+ return None
73
+ return out.stdout.strip()
74
+
75
+
76
+ def build_info() -> BuildInfo:
77
+ commit = commit_date = built = None
78
+ dirty = False
79
+ try:
80
+ from hoarse import _build_meta # type: ignore[attr-defined]
81
+ except ImportError:
82
+ _build_meta = None
83
+ if _build_meta is not None:
84
+ commit, commit_date = _build_meta.COMMIT, _build_meta.COMMIT_DATE
85
+ built, dirty = _build_meta.BUILT, bool(getattr(_build_meta, "DIRTY", False))
86
+ origin = "release build"
87
+ elif (_CHECKOUT / ".git").exists():
88
+ commit = _git("rev-parse", "--short=12", "HEAD")
89
+ commit_date = _git("log", "-1", "--format=%cs") if commit else None
90
+ dirty = bool(_git("status", "--porcelain", "--untracked-files=no"))
91
+ origin = "source checkout"
92
+ else:
93
+ origin = "unknown origin"
94
+ return BuildInfo(
95
+ version=__version__, commit=commit or None, commit_date=commit_date or None,
96
+ built=built, dirty=dirty, origin=origin,
97
+ python=f"{platform.python_implementation()} {platform.python_version()}",
98
+ platform=f"{platform.system()} {platform.release()} ({platform.machine()})"
99
+ if sys.platform != "darwin" else f"macOS {platform.mac_ver()[0]} ({platform.machine()})",
100
+ sqlite=sqlite3.sqlite_version,
101
+ )
@@ -0,0 +1 @@
1
+ """See docs/spec/ — this package's contract is normative."""
@@ -0,0 +1,104 @@
1
+ """Secrets live in the OS keychain, never in settings or IPC (ch. 12 §12.4).
2
+
3
+ Contract shared by the UI and the engine. The window collects a password,
4
+ stores it here under a reference, and sends the engine only the reference
5
+ (ARC-14). The engine resolves the reference when it connects.
6
+
7
+ Backends: ``keyring`` (Secret Service / Keychain / Credential Manager), or an
8
+ in-memory store for ``--no-keyring`` sessions and tests (SEC-25). The
9
+ encrypted-file vault fallback is a later work package.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import secrets
15
+ import threading
16
+ from typing import Final, Protocol
17
+
18
+ from hoarse.core.errors import HoarseError
19
+
20
+ SERVICE: Final = "hoarse"
21
+
22
+
23
+ class CredentialError(HoarseError):
24
+ """The keychain is unavailable, locked, or refused the request (SEC-24)."""
25
+
26
+
27
+ class CredentialStore(Protocol):
28
+ def get(self, ref: str) -> str | None: ...
29
+ def put(self, ref: str, secret: str) -> None: ...
30
+ def delete(self, ref: str) -> None: ...
31
+ @property
32
+ def backend_name(self) -> str: ... # shown in settings (SEC-23)
33
+
34
+
35
+ def new_ref(kind: str = "server") -> str:
36
+ """A fresh opaque reference. Not derived from the address or host, so the
37
+ keychain listing does not reveal which accounts a user has."""
38
+ return f"{kind}:{secrets.token_hex(12)}"
39
+
40
+
41
+ class MemoryCredentials:
42
+ """Per-process store: ``--no-keyring`` and tests. Nothing persists."""
43
+
44
+ backend_name = "memory (not saved)"
45
+
46
+ def __init__(self) -> None:
47
+ self._d: dict[str, str] = {}
48
+ self._lock = threading.Lock()
49
+
50
+ def get(self, ref: str) -> str | None:
51
+ with self._lock:
52
+ return self._d.get(ref)
53
+
54
+ def put(self, ref: str, secret: str) -> None:
55
+ with self._lock:
56
+ self._d[ref] = secret
57
+
58
+ def delete(self, ref: str) -> None:
59
+ with self._lock:
60
+ self._d.pop(ref, None)
61
+
62
+
63
+ class KeyringCredentials:
64
+ """The OS keychain via ``keyring``. Every failure is a CredentialError, never
65
+ a hang or a silent plaintext fallback (SEC-24)."""
66
+
67
+ def __init__(self) -> None:
68
+ try:
69
+ import keyring
70
+ from keyring.backends.fail import Keyring as FailKeyring
71
+ except ImportError as e: # pragma: no cover - keyring is a dependency
72
+ raise CredentialError(f"keyring is not installed: {e}") from e
73
+ self._kr = keyring
74
+ backend = keyring.get_keyring()
75
+ if isinstance(backend, FailKeyring):
76
+ raise CredentialError("no system keychain is available")
77
+ self.backend_name = f"system keychain ({type(backend).__name__})"
78
+
79
+ def get(self, ref: str) -> str | None:
80
+ try:
81
+ value: str | None = self._kr.get_password(SERVICE, ref)
82
+ return value
83
+ except Exception as e: # keyring raises backend-specific types
84
+ raise CredentialError(f"keychain read failed: {type(e).__name__}") from e
85
+
86
+ def put(self, ref: str, secret: str) -> None:
87
+ try:
88
+ self._kr.set_password(SERVICE, ref, secret)
89
+ except Exception as e:
90
+ raise CredentialError(f"keychain write failed: {type(e).__name__}") from e
91
+
92
+ def delete(self, ref: str) -> None:
93
+ try:
94
+ self._kr.delete_password(SERVICE, ref)
95
+ except self._kr.errors.PasswordDeleteError:
96
+ pass
97
+ except Exception as e:
98
+ raise CredentialError(f"keychain delete failed: {type(e).__name__}") from e
99
+
100
+
101
+ def open_store(*, use_keyring: bool = True) -> CredentialStore:
102
+ """The store for this session. Raises CredentialError if the keychain was
103
+ requested and is unusable; the caller offers ``--no-keyring`` (SEC-25)."""
104
+ return KeyringCredentials() if use_keyring else MemoryCredentials()
hoarse/core/errors.py ADDED
@@ -0,0 +1,78 @@
1
+ """Error types shared across packages.
2
+
3
+ Every parser in Hoarse is *total*: malformed input produces a typed error carrying
4
+ the offending bytes, never an ``IndexError`` and never a hang (MIM-3, PRO-4).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class HoarseError(Exception):
11
+ """Base for every error Hoarse raises deliberately."""
12
+
13
+
14
+ class ProtocolError(HoarseError):
15
+ """A server sent something that does not parse.
16
+
17
+ Carries the offending bytes so a transcript fixture can be minted from a
18
+ failure in the field (PRO-4).
19
+ """
20
+
21
+ def __init__(self, message: str, *, raw: bytes = b"", offset: int | None = None) -> None:
22
+ super().__init__(message)
23
+ self.raw = raw
24
+ self.offset = offset
25
+
26
+
27
+ class StoreError(HoarseError):
28
+ """The store could not satisfy a request."""
29
+
30
+
31
+ class SchemaVersionError(StoreError):
32
+ """The database was written by a newer Hoarse than this one (STO-31).
33
+
34
+ Refusing to open is deliberate: guessing at an unknown schema is how stores
35
+ get corrupted.
36
+ """
37
+
38
+
39
+ class NotFound(StoreError): # noqa: N818 -- names fixed by the IPC codes
40
+ """A folder, message or account named by a request does not exist.
41
+
42
+ The engine maps it to the IPC error code ``NOT_FOUND``.
43
+ """
44
+
45
+
46
+ class NameExists(StoreError): # noqa: N818
47
+ """A folder of that name already exists under the same parent (``NAME_EXISTS``)."""
48
+
49
+
50
+ class InvalidName(StoreError): # noqa: N818
51
+ """A folder name is empty, too long, or contains the hierarchy delimiter or a
52
+ control character (``INVALID_NAME``)."""
53
+
54
+
55
+ class NotAllowed(StoreError): # noqa: N818
56
+ """The request is well-formed but refused (``NOT_ALLOWED``).
57
+
58
+ Deleting or renaming a special-use folder, moving messages to the folder they
59
+ are already in, and anything that would span two accounts (D-14).
60
+ """
61
+
62
+
63
+ class StaleSync(StoreError): # noqa: N818 -- reads as the condition, like NotFound
64
+ """A sync batch was fetched under a ``UIDVALIDITY`` the folder no longer has.
65
+
66
+ Raised by the store's server-state writes when the caller's ``uidvalidity``
67
+ does not match the folder's: the batch predates a ``reset_folder`` and its
68
+ uids name other messages now (`SYN-16`). Nothing is written; the engine
69
+ drops the batch and resyncs the folder.
70
+ """
71
+
72
+
73
+ class ContentError(HoarseError):
74
+ """Message content could not be handled; the message is kept and flagged."""
75
+
76
+
77
+ class ConfigError(HoarseError):
78
+ """Settings on disk are unusable."""
hoarse/core/logging.py ADDED
@@ -0,0 +1,106 @@
1
+ """Logging with redaction on by default (SEC-32).
2
+
3
+ Logs carry no address, subject, body or credential unless a verbose protocol
4
+ trace is explicitly enabled for the session. ``print`` is banned by ruff (T20)
5
+ so that nothing can bypass this.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import logging
13
+ import re
14
+ from typing import Final
15
+
16
+ _REDACTED: Final = "[redacted]"
17
+
18
+ # Ordered most-specific first: a credential must never survive because an
19
+ # address pattern matched part of it first.
20
+ _PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
21
+ (re.compile(r"(?i)\b(AUTHENTICATE|LOGIN|AUTH)\s+\S+.*"), r"\1 " + _REDACTED),
22
+ (re.compile(r"(?i)\b(bearer|basic)\s+[\w.\-+/=]+"), r"\1 " + _REDACTED),
23
+ # A quoted value is redacted up to its closing quote, so a passphrase with
24
+ # spaces or commas goes whole; an unquoted one up to whitespace , or }.
25
+ (re.compile(r"(?i)\b(password|passwd|passphrase|secret|token|client_secret|pw|pwd)"
26
+ r"""(['"]?)\s*[=:]\s*(?:(['"])(?:(?!\3)[^\r\n])*\3?|[^\s,}'"]+)"""),
27
+ r"\1\2=\3" + _REDACTED + r"\3"),
28
+ # Addresses: quoted local parts, domain literals and dotless hosts too.
29
+ (re.compile(r'"[^"\r\n]{1,256}"@[\w.\[\]:-]+'), _REDACTED),
30
+ (re.compile(r"[\w.+-]+@(?:\[[0-9A-Fa-f.:]+\]|[\w-]+(?:\.[\w-]+)*)"), _REDACTED),
31
+ )
32
+
33
+ # A bare SASL PLAIN response is base64 of "authzid\0user\0pass": decode any
34
+ # base64-looking token and redact it if it carries NUL separators.
35
+ _B64: Final = re.compile(r"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{8,}={0,2}(?![A-Za-z0-9+/=])")
36
+
37
+
38
+ def _scrub_sasl(match: re.Match[str]) -> str:
39
+ token = match.group(0)
40
+ try:
41
+ decoded = base64.b64decode(token + "=" * (-len(token) % 4), validate=True)
42
+ except (ValueError, binascii.Error):
43
+ return token
44
+ return _REDACTED if b"\x00" in decoded else token
45
+
46
+
47
+ class RedactingFilter(logging.Filter):
48
+ """Strips credentials and addresses from every record.
49
+
50
+ Applied to the message *and* its arguments, because ``log("%s", addr)``
51
+ would otherwise leak through the lazy-formatting path.
52
+ """
53
+
54
+ def __init__(self, *, enabled: bool = True) -> None:
55
+ super().__init__()
56
+ self.enabled = enabled
57
+
58
+ @staticmethod
59
+ def _scrub(text: str) -> str:
60
+ for pattern, replacement in _PATTERNS:
61
+ text = pattern.sub(replacement, text)
62
+ text = _B64.sub(_scrub_sasl, text)
63
+ return text
64
+
65
+ def filter(self, record: logging.LogRecord) -> bool:
66
+ if not self.enabled:
67
+ return True
68
+ if isinstance(record.msg, str):
69
+ record.msg = self._scrub(record.msg)
70
+ else: # log.error(exc) / log.info(obj): scrub its text, not just str messages
71
+ record.msg = self._scrub(str(record.msg))
72
+ if record.args:
73
+ # Scrub strings only: stringifying everything breaks %d/%f formats.
74
+ if isinstance(record.args, dict):
75
+ record.args = {k: self._scrub_arg(v) for k, v in record.args.items()}
76
+ else:
77
+ record.args = tuple(self._scrub_arg(a) for a in record.args)
78
+ if record.exc_info and not record.exc_text:
79
+ # Tracebacks carry exception messages, which can carry addresses.
80
+ record.exc_text = self._scrub(logging.Formatter().formatException(record.exc_info))
81
+ elif record.exc_text:
82
+ record.exc_text = self._scrub(record.exc_text)
83
+ return True
84
+
85
+ @classmethod
86
+ def _scrub_arg(cls, value: object) -> object:
87
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
88
+ return value
89
+ return cls._scrub(str(value))
90
+
91
+
92
+ def configure(level: int = logging.INFO, *, redact: bool = True) -> None:
93
+ """Install a redacting handler on the root logger.
94
+
95
+ ``redact=False`` is the opt-in protocol trace of SEC-32, and the caller is
96
+ responsible for showing the user that it is running.
97
+ """
98
+ handler = logging.StreamHandler()
99
+ handler.setFormatter(
100
+ logging.Formatter("%(asctime)s %(levelname)-7s %(name)-24s %(message)s")
101
+ )
102
+ handler.addFilter(RedactingFilter(enabled=redact))
103
+ root = logging.getLogger()
104
+ root.handlers.clear()
105
+ root.addHandler(handler)
106
+ root.setLevel(level)
@@ -0,0 +1,187 @@
1
+ """Neutral server-state types shared by ``net`` (which produces them) and
2
+ ``store`` (which persists them), so neither imports the other (ARC-19).
3
+
4
+ The engine is the only place both meet: it calls the IMAP client, gets these
5
+ values, and hands them to ``StoreWriter``. Qt-free, asyncio-free, I/O-free.
6
+
7
+ Strings here are already decoded (RFC 2047 words in ENVELOPE are decoded by
8
+ the parser via ``hoarse.mime``); raw protocol bytes never cross this seam.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable
14
+ from dataclasses import dataclass, field
15
+ from typing import Final
16
+
17
+ # STO-6 bit values. Mirrors ``store.schema.Flags``; a test pins the two
18
+ # together so they cannot drift.
19
+ SEEN: Final = 1
20
+ ANSWERED: Final = 2
21
+ FLAGGED: Final = 4
22
+ DELETED: Final = 8
23
+ DRAFT: Final = 16
24
+ RECENT: Final = 32
25
+ FORWARDED: Final = 64
26
+ JUNK: Final = 128
27
+ NOT_JUNK: Final = 256
28
+
29
+ _SYSTEM: Final[dict[str, int]] = {
30
+ "\\seen": SEEN,
31
+ "\\answered": ANSWERED,
32
+ "\\flagged": FLAGGED,
33
+ "\\deleted": DELETED,
34
+ "\\draft": DRAFT,
35
+ "\\recent": RECENT,
36
+ "$forwarded": FORWARDED,
37
+ "$junk": JUNK,
38
+ "$notjunk": NOT_JUNK,
39
+ "junk": JUNK, # common non-standard spellings seen in the wild
40
+ "nonjunk": NOT_JUNK,
41
+ "notjunk": NOT_JUNK,
42
+ }
43
+
44
+ _CANONICAL: Final[dict[int, str]] = {
45
+ SEEN: "\\Seen",
46
+ ANSWERED: "\\Answered",
47
+ FLAGGED: "\\Flagged",
48
+ DELETED: "\\Deleted",
49
+ DRAFT: "\\Draft",
50
+ FORWARDED: "$Forwarded",
51
+ JUNK: "$Junk",
52
+ NOT_JUNK: "$NotJunk",
53
+ }
54
+
55
+
56
+ def split_flags(names: Iterable[str]) -> tuple[int, tuple[str, ...]]:
57
+ """IMAP flag names -> (STO-6 bitfield, remaining keywords, case preserved,
58
+ de-duplicated case-insensitively, in first-seen order)."""
59
+ bits = 0
60
+ seen: set[str] = set()
61
+ keywords: list[str] = []
62
+ for name in names:
63
+ low = name.lower()
64
+ if low in _SYSTEM:
65
+ bits |= _SYSTEM[low]
66
+ elif low not in seen and not low.startswith("\\"):
67
+ seen.add(low)
68
+ keywords.append(name)
69
+ return bits, tuple(keywords)
70
+
71
+
72
+ def flag_names(bits: int) -> list[str]:
73
+ """STO-6 bits -> the IMAP names to STORE. ``RECENT`` is server-only and
74
+ never stored by a client, so it is omitted."""
75
+ return [name for bit, name in _CANONICAL.items() if bits & bit]
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Address:
80
+ """One address. RFC 5322 groups are flattened: the group's start is
81
+ ``Address(name=<phrase>, addr="", group=<phrase>)``, and each member
82
+ carries ``group=<phrase>`` with its own name and addr."""
83
+ name: str # decoded display name, "" if none
84
+ addr: str # "local@domain"; "" for a group marker
85
+ group: str | None = None # set on members of an RFC 5322 group, and on its marker
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class Envelope:
90
+ """IMAP ENVELOPE (RFC 3501 §7.4.2), decoded. ``date`` is the raw Date:
91
+ string; the store parses it with ``mime.dates`` and falls back to
92
+ INTERNALDATE (MIM-7)."""
93
+ date: str | None
94
+ subject: str
95
+ from_: tuple[Address, ...]
96
+ sender: tuple[Address, ...]
97
+ reply_to: tuple[Address, ...]
98
+ to: tuple[Address, ...]
99
+ cc: tuple[Address, ...]
100
+ bcc: tuple[Address, ...]
101
+ in_reply_to: str | None # raw, angle brackets kept
102
+ message_id: str | None # raw, angle brackets kept
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class BodyPart:
107
+ """One leaf of BODYSTRUCTURE, flattened. Multipart containers are not
108
+ listed; their structure is implied by ``part_id`` ("1", "1.2", ...)."""
109
+ part_id: str
110
+ type: str # lowercased, e.g. "text"
111
+ subtype: str # lowercased, e.g. "plain"
112
+ params: dict[str, str] = field(default_factory=dict) # lowercased keys
113
+ content_id: str | None = None
114
+ encoding: str | None = None # lowercased
115
+ size: int = 0 # octets as encoded
116
+ lines: int | None = None
117
+ disposition: str | None = None # "inline"/"attachment"
118
+ disposition_params: dict[str, str] = field(default_factory=dict)
119
+ filename: str | None = None # decoded (RFC 2231/2047), from disposition or name
120
+
121
+ @property
122
+ def is_attachment(self) -> bool:
123
+ return self.disposition == "attachment" or (
124
+ self.filename is not None and self.type not in ("text", "multipart")
125
+ )
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class ServerMessage:
130
+ """A message as described by one header FETCH (SYN-9, MIM-1):
131
+ ``UID FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODYSTRUCTURE [MODSEQ]
132
+ [X-GM-MSGID X-GM-THRID] [EMAILID THREADID]``."""
133
+ uid: int
134
+ flags: int # STO-6 bits, via split_flags
135
+ keywords: tuple[str, ...]
136
+ internal_date: int # unix seconds
137
+ size: int
138
+ envelope: Envelope
139
+ parts: tuple[BodyPart, ...]
140
+ references: tuple[str, ...] = () # from BODY.PEEK[HEADER.FIELDS (REFERENCES)], raw ids
141
+ modseq: int | None = None
142
+ gm_msgid: int | None = None
143
+ gm_thrid: int | None = None
144
+ email_id: str | None = None # OBJECTID (RFC 8474)
145
+ thread_id: str | None = None
146
+ gm_labels: tuple[str, ...] = () # X-GM-LABELS, decoded (WP 5.8)
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class FlagChange:
151
+ uid: int
152
+ flags: int
153
+ keywords: tuple[str, ...]
154
+ modseq: int | None = None
155
+ gm_labels: tuple[str, ...] | None = None # None = labels not reported in this update
156
+
157
+
158
+ @dataclass(frozen=True, slots=True)
159
+ class ServerFolder:
160
+ """One LIST response (with LIST-EXTENDED/SPECIAL-USE/STATUS where given).
161
+ ``path`` is the decoded mailbox name (modified UTF-7 or UTF8=ACCEPT)."""
162
+ path: str
163
+ delimiter: str | None
164
+ special_use: str | None # "inbox","drafts","sent","junk","trash","archive","all","flagged"
165
+ selectable: bool = True
166
+ subscribed: bool = True
167
+ has_children: bool | None = None
168
+ # STATUS, when LIST-STATUS or a separate STATUS supplied it
169
+ messages: int | None = None
170
+ unseen: int | None = None
171
+ uidnext: int | None = None
172
+ uidvalidity: int | None = None
173
+ highestmodseq: int | None = None
174
+ # Raw mailbox name, when ``path`` cannot round-trip (invalid modified UTF-7).
175
+ wire_name: bytes | None = None
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class FolderSyncState:
180
+ """What the store remembers per folder to resync cheaply (§6.4)."""
181
+ uidvalidity: int | None
182
+ uidnext: int | None
183
+ highestmodseq: int | None
184
+ tier: str # "qresync" | "condstore" | "plain" | "new"
185
+ complete: bool # headers fully backfilled
186
+ lowest_uid: int | None # backfill frontier (newest-first, SYN-10)
187
+ error: str | None = None # last sync failure for this folder, user-presentable; None = healthy