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.
- hoarse/__init__.py +12 -0
- hoarse/__main__.py +80 -0
- hoarse/_build_meta.py +6 -0
- hoarse/buildinfo.py +101 -0
- hoarse/core/__init__.py +1 -0
- hoarse/core/credentials.py +104 -0
- hoarse/core/errors.py +78 -0
- hoarse/core/logging.py +106 -0
- hoarse/core/mailtypes.py +187 -0
- hoarse/core/paths.py +89 -0
- hoarse/core/settings.py +284 -0
- hoarse/engine/__init__.py +1 -0
- hoarse/engine/__main__.py +5 -0
- hoarse/engine/account.py +964 -0
- hoarse/engine/accounts.py +419 -0
- hoarse/engine/main.py +1488 -0
- hoarse/engine/replay.py +928 -0
- hoarse/engine/send.py +217 -0
- hoarse/engine/spawn.py +79 -0
- hoarse/engine/sync/__init__.py +21 -0
- hoarse/engine/sync/bodies.py +206 -0
- hoarse/engine/sync/folder.py +417 -0
- hoarse/engine/sync/policy.py +221 -0
- hoarse/engine/sync/store.py +244 -0
- hoarse/features/__init__.py +1 -0
- hoarse/features/checkpoint.py +102 -0
- hoarse/features/cli.py +116 -0
- hoarse/features/exporters.py +272 -0
- hoarse/features/folderpath.py +126 -0
- hoarse/features/formats/__init__.py +1 -0
- hoarse/features/formats/eml.py +89 -0
- hoarse/features/formats/flags.py +85 -0
- hoarse/features/formats/maildir.py +163 -0
- hoarse/features/formats/mbox.py +542 -0
- hoarse/features/formats/safeio.py +60 -0
- hoarse/features/importers.py +415 -0
- hoarse/features/profile_lock.py +67 -0
- hoarse/htmlmail/__init__.py +1 -0
- hoarse/ipc/__init__.py +1 -0
- hoarse/ipc/client.py +184 -0
- hoarse/ipc/schema.py +520 -0
- hoarse/ipc/server.py +428 -0
- hoarse/mime/__init__.py +1 -0
- hoarse/mime/address.py +606 -0
- hoarse/mime/charset.py +142 -0
- hoarse/mime/compose.py +1143 -0
- hoarse/mime/dates.py +206 -0
- hoarse/mime/stream.py +265 -0
- hoarse/mime/words.py +570 -0
- hoarse/net/__init__.py +1 -0
- hoarse/net/autoconfig.py +558 -0
- hoarse/net/dns.py +165 -0
- hoarse/net/imap/__init__.py +15 -0
- hoarse/net/imap/client.py +2090 -0
- hoarse/net/imap/commands.py +275 -0
- hoarse/net/imap/lexer.py +1043 -0
- hoarse/net/imap/parser.py +1779 -0
- hoarse/net/imap/utf7.py +126 -0
- hoarse/net/probe.py +591 -0
- hoarse/net/sasl.py +43 -0
- hoarse/net/smtp.py +675 -0
- hoarse/net/tls.py +109 -0
- hoarse/store/__init__.py +1 -0
- hoarse/store/blobs.py +321 -0
- hoarse/store/cursor.py +148 -0
- hoarse/store/ids.py +85 -0
- hoarse/store/ingest.py +697 -0
- hoarse/store/liveops.py +292 -0
- hoarse/store/migrations/__init__.py +280 -0
- hoarse/store/migrations/m0001_initial.py +287 -0
- hoarse/store/migrations/m0002_sort_orders.py +134 -0
- hoarse/store/migrations/m0003_server_state.py +82 -0
- hoarse/store/migrations/m0004_sync_health.py +54 -0
- hoarse/store/migrations/m0005_blob_digest.py +45 -0
- hoarse/store/migrations/m0006_thread_view.py +125 -0
- hoarse/store/outbox.py +743 -0
- hoarse/store/reader.py +1253 -0
- hoarse/store/schema.py +595 -0
- hoarse/store/thread.py +431 -0
- hoarse/store/writer.py +3794 -0
- hoarse/theme/__init__.py +1 -0
- hoarse/theme/apply.py +67 -0
- hoarse/theme/assets.py +66 -0
- hoarse/theme/pack.py +459 -0
- hoarse/theme/packs/high-contrast/theme.toml +67 -0
- hoarse/theme/packs/modern-dark/theme.toml +65 -0
- hoarse/theme/packs/modern-light/theme.toml +65 -0
- hoarse/theme/packs/retro/theme.toml +68 -0
- hoarse/theme/painters.py +20 -0
- hoarse/theme/qss.py +312 -0
- hoarse/theme/resolve.py +316 -0
- hoarse/tools/__init__.py +1 -0
- hoarse/tools/bench.py +424 -0
- hoarse/tools/corpus.py +1733 -0
- hoarse/tools/fakeimap/__init__.py +55 -0
- hoarse/tools/fakeimap/__main__.py +71 -0
- hoarse/tools/fakeimap/certs.py +107 -0
- hoarse/tools/fakeimap/search.py +196 -0
- hoarse/tools/fakeimap/server.py +1950 -0
- hoarse/tools/fakeimap/state.py +605 -0
- hoarse/tools/fakeimap/structure.py +559 -0
- hoarse/tools/fakeimap/transcript.py +131 -0
- hoarse/tools/fakeimap/wire.py +415 -0
- hoarse/tools/fakesmtp/__init__.py +65 -0
- hoarse/tools/fakesmtp/server.py +848 -0
- hoarse/tools/reqcheck.py +455 -0
- hoarse/tools/seed.py +278 -0
- hoarse/tools/testcap.py +75 -0
- hoarse/tools/themecheck.py +91 -0
- hoarse/ui/__init__.py +1 -0
- hoarse/ui/about.py +90 -0
- hoarse/ui/account_settings.py +811 -0
- hoarse/ui/accounts.py +610 -0
- hoarse/ui/actions.py +224 -0
- hoarse/ui/app.py +131 -0
- hoarse/ui/compose_util.py +158 -0
- hoarse/ui/composer.py +668 -0
- hoarse/ui/controller.py +924 -0
- hoarse/ui/credsession.py +79 -0
- hoarse/ui/dialogs.py +307 -0
- hoarse/ui/engine.py +263 -0
- hoarse/ui/headers.py +252 -0
- hoarse/ui/linkify.py +270 -0
- hoarse/ui/mainwindow.py +1414 -0
- hoarse/ui/menus.py +320 -0
- hoarse/ui/models/__init__.py +1 -0
- hoarse/ui/models/demo.py +679 -0
- hoarse/ui/models/fetcher.py +416 -0
- hoarse/ui/models/message_list.py +1124 -0
- hoarse/ui/models/overlay.py +126 -0
- hoarse/ui/models/ranges.py +26 -0
- hoarse/ui/models/source.py +165 -0
- hoarse/ui/problems.py +46 -0
- hoarse/ui/screenshot.py +242 -0
- hoarse/ui/screenshot_accounts.py +309 -0
- hoarse/ui/screenshot_compose.py +177 -0
- hoarse/ui/serverform.py +252 -0
- hoarse/ui/settings.py +206 -0
- hoarse/ui/views/__init__.py +1 -0
- hoarse/ui/views/folder_tree.py +582 -0
- hoarse/ui/views/glyphs.py +337 -0
- hoarse/ui/views/headerblock.py +240 -0
- hoarse/ui/views/linkedit.py +155 -0
- hoarse/ui/views/message_delegate.py +377 -0
- hoarse/ui/views/message_view.py +301 -0
- hoarse/ui/views/reader.py +474 -0
- hoarse/ui/views/syncbar.py +101 -0
- hoarse/ui/welcome.py +63 -0
- hoarse/ui/wizard.py +848 -0
- hoarse-0.1.1.dist-info/METADATA +59 -0
- hoarse-0.1.1.dist-info/RECORD +154 -0
- hoarse-0.1.1.dist-info/WHEEL +4 -0
- hoarse-0.1.1.dist-info/entry_points.txt +2 -0
- 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
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
|
+
)
|
hoarse/core/__init__.py
ADDED
|
@@ -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)
|
hoarse/core/mailtypes.py
ADDED
|
@@ -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
|