hoarse 0.1.1__tar.gz
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-0.1.1/.gitignore +13 -0
- hoarse-0.1.1/CHANGELOG.md +52 -0
- hoarse-0.1.1/LICENSE +21 -0
- hoarse-0.1.1/PKG-INFO +59 -0
- hoarse-0.1.1/README.md +30 -0
- hoarse-0.1.1/docs/spec/01-goals.md +59 -0
- hoarse-0.1.1/docs/spec/02-decisions.md +225 -0
- hoarse-0.1.1/docs/spec/03-architecture.md +174 -0
- hoarse-0.1.1/docs/spec/04-storage.md +404 -0
- hoarse-0.1.1/docs/spec/05-search.md +148 -0
- hoarse-0.1.1/docs/spec/06-sync.md +180 -0
- hoarse-0.1.1/docs/spec/07-protocols.md +155 -0
- hoarse-0.1.1/docs/spec/08-oplog.md +115 -0
- hoarse-0.1.1/docs/spec/09-mime-render.md +151 -0
- hoarse-0.1.1/docs/spec/10-ui.md +129 -0
- hoarse-0.1.1/docs/spec/11-theming.md +142 -0
- hoarse-0.1.1/docs/spec/12-accounts-security.md +112 -0
- hoarse-0.1.1/docs/spec/13-features.md +117 -0
- hoarse-0.1.1/docs/spec/14-quality.md +131 -0
- hoarse-0.1.1/docs/spec/15-roadmap.md +88 -0
- hoarse-0.1.1/docs/spec/16-packaging.md +88 -0
- hoarse-0.1.1/docs/spec/README.md +57 -0
- hoarse-0.1.1/hatch_build.py +57 -0
- hoarse-0.1.1/pyproject.toml +102 -0
- hoarse-0.1.1/src/hoarse/__init__.py +12 -0
- hoarse-0.1.1/src/hoarse/__main__.py +80 -0
- hoarse-0.1.1/src/hoarse/_build_meta.py +6 -0
- hoarse-0.1.1/src/hoarse/buildinfo.py +101 -0
- hoarse-0.1.1/src/hoarse/core/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/core/credentials.py +104 -0
- hoarse-0.1.1/src/hoarse/core/errors.py +78 -0
- hoarse-0.1.1/src/hoarse/core/logging.py +106 -0
- hoarse-0.1.1/src/hoarse/core/mailtypes.py +187 -0
- hoarse-0.1.1/src/hoarse/core/paths.py +89 -0
- hoarse-0.1.1/src/hoarse/core/settings.py +284 -0
- hoarse-0.1.1/src/hoarse/engine/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/engine/__main__.py +5 -0
- hoarse-0.1.1/src/hoarse/engine/account.py +964 -0
- hoarse-0.1.1/src/hoarse/engine/accounts.py +419 -0
- hoarse-0.1.1/src/hoarse/engine/main.py +1488 -0
- hoarse-0.1.1/src/hoarse/engine/replay.py +928 -0
- hoarse-0.1.1/src/hoarse/engine/send.py +217 -0
- hoarse-0.1.1/src/hoarse/engine/spawn.py +79 -0
- hoarse-0.1.1/src/hoarse/engine/sync/__init__.py +21 -0
- hoarse-0.1.1/src/hoarse/engine/sync/bodies.py +206 -0
- hoarse-0.1.1/src/hoarse/engine/sync/folder.py +417 -0
- hoarse-0.1.1/src/hoarse/engine/sync/policy.py +221 -0
- hoarse-0.1.1/src/hoarse/engine/sync/store.py +244 -0
- hoarse-0.1.1/src/hoarse/features/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/features/checkpoint.py +102 -0
- hoarse-0.1.1/src/hoarse/features/cli.py +116 -0
- hoarse-0.1.1/src/hoarse/features/exporters.py +272 -0
- hoarse-0.1.1/src/hoarse/features/folderpath.py +126 -0
- hoarse-0.1.1/src/hoarse/features/formats/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/features/formats/eml.py +89 -0
- hoarse-0.1.1/src/hoarse/features/formats/flags.py +85 -0
- hoarse-0.1.1/src/hoarse/features/formats/maildir.py +163 -0
- hoarse-0.1.1/src/hoarse/features/formats/mbox.py +542 -0
- hoarse-0.1.1/src/hoarse/features/formats/safeio.py +60 -0
- hoarse-0.1.1/src/hoarse/features/importers.py +415 -0
- hoarse-0.1.1/src/hoarse/features/profile_lock.py +67 -0
- hoarse-0.1.1/src/hoarse/htmlmail/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/ipc/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/ipc/client.py +184 -0
- hoarse-0.1.1/src/hoarse/ipc/schema.py +520 -0
- hoarse-0.1.1/src/hoarse/ipc/server.py +428 -0
- hoarse-0.1.1/src/hoarse/mime/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/mime/address.py +606 -0
- hoarse-0.1.1/src/hoarse/mime/charset.py +142 -0
- hoarse-0.1.1/src/hoarse/mime/compose.py +1143 -0
- hoarse-0.1.1/src/hoarse/mime/dates.py +206 -0
- hoarse-0.1.1/src/hoarse/mime/stream.py +265 -0
- hoarse-0.1.1/src/hoarse/mime/words.py +570 -0
- hoarse-0.1.1/src/hoarse/net/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/net/autoconfig.py +558 -0
- hoarse-0.1.1/src/hoarse/net/dns.py +165 -0
- hoarse-0.1.1/src/hoarse/net/imap/__init__.py +15 -0
- hoarse-0.1.1/src/hoarse/net/imap/client.py +2090 -0
- hoarse-0.1.1/src/hoarse/net/imap/commands.py +275 -0
- hoarse-0.1.1/src/hoarse/net/imap/lexer.py +1043 -0
- hoarse-0.1.1/src/hoarse/net/imap/parser.py +1779 -0
- hoarse-0.1.1/src/hoarse/net/imap/utf7.py +126 -0
- hoarse-0.1.1/src/hoarse/net/probe.py +591 -0
- hoarse-0.1.1/src/hoarse/net/sasl.py +43 -0
- hoarse-0.1.1/src/hoarse/net/smtp.py +675 -0
- hoarse-0.1.1/src/hoarse/net/tls.py +109 -0
- hoarse-0.1.1/src/hoarse/store/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/store/blobs.py +321 -0
- hoarse-0.1.1/src/hoarse/store/cursor.py +148 -0
- hoarse-0.1.1/src/hoarse/store/ids.py +85 -0
- hoarse-0.1.1/src/hoarse/store/ingest.py +697 -0
- hoarse-0.1.1/src/hoarse/store/liveops.py +292 -0
- hoarse-0.1.1/src/hoarse/store/migrations/__init__.py +280 -0
- hoarse-0.1.1/src/hoarse/store/migrations/m0001_initial.py +287 -0
- hoarse-0.1.1/src/hoarse/store/migrations/m0002_sort_orders.py +134 -0
- hoarse-0.1.1/src/hoarse/store/migrations/m0003_server_state.py +82 -0
- hoarse-0.1.1/src/hoarse/store/migrations/m0004_sync_health.py +54 -0
- hoarse-0.1.1/src/hoarse/store/migrations/m0005_blob_digest.py +45 -0
- hoarse-0.1.1/src/hoarse/store/migrations/m0006_thread_view.py +125 -0
- hoarse-0.1.1/src/hoarse/store/outbox.py +743 -0
- hoarse-0.1.1/src/hoarse/store/reader.py +1253 -0
- hoarse-0.1.1/src/hoarse/store/schema.py +595 -0
- hoarse-0.1.1/src/hoarse/store/thread.py +431 -0
- hoarse-0.1.1/src/hoarse/store/writer.py +3794 -0
- hoarse-0.1.1/src/hoarse/theme/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/theme/apply.py +67 -0
- hoarse-0.1.1/src/hoarse/theme/assets.py +66 -0
- hoarse-0.1.1/src/hoarse/theme/pack.py +459 -0
- hoarse-0.1.1/src/hoarse/theme/packs/high-contrast/theme.toml +67 -0
- hoarse-0.1.1/src/hoarse/theme/packs/modern-dark/theme.toml +65 -0
- hoarse-0.1.1/src/hoarse/theme/packs/modern-light/theme.toml +65 -0
- hoarse-0.1.1/src/hoarse/theme/packs/retro/theme.toml +68 -0
- hoarse-0.1.1/src/hoarse/theme/painters.py +20 -0
- hoarse-0.1.1/src/hoarse/theme/qss.py +312 -0
- hoarse-0.1.1/src/hoarse/theme/resolve.py +316 -0
- hoarse-0.1.1/src/hoarse/tools/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/tools/bench.py +424 -0
- hoarse-0.1.1/src/hoarse/tools/corpus.py +1733 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/__init__.py +55 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/__main__.py +71 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/certs.py +107 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/search.py +196 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/server.py +1950 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/state.py +605 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/structure.py +559 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/transcript.py +131 -0
- hoarse-0.1.1/src/hoarse/tools/fakeimap/wire.py +415 -0
- hoarse-0.1.1/src/hoarse/tools/fakesmtp/__init__.py +65 -0
- hoarse-0.1.1/src/hoarse/tools/fakesmtp/server.py +848 -0
- hoarse-0.1.1/src/hoarse/tools/reqcheck.py +455 -0
- hoarse-0.1.1/src/hoarse/tools/seed.py +278 -0
- hoarse-0.1.1/src/hoarse/tools/testcap.py +75 -0
- hoarse-0.1.1/src/hoarse/tools/themecheck.py +91 -0
- hoarse-0.1.1/src/hoarse/ui/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/ui/about.py +90 -0
- hoarse-0.1.1/src/hoarse/ui/account_settings.py +811 -0
- hoarse-0.1.1/src/hoarse/ui/accounts.py +610 -0
- hoarse-0.1.1/src/hoarse/ui/actions.py +224 -0
- hoarse-0.1.1/src/hoarse/ui/app.py +131 -0
- hoarse-0.1.1/src/hoarse/ui/compose_util.py +158 -0
- hoarse-0.1.1/src/hoarse/ui/composer.py +668 -0
- hoarse-0.1.1/src/hoarse/ui/controller.py +924 -0
- hoarse-0.1.1/src/hoarse/ui/credsession.py +79 -0
- hoarse-0.1.1/src/hoarse/ui/dialogs.py +307 -0
- hoarse-0.1.1/src/hoarse/ui/engine.py +263 -0
- hoarse-0.1.1/src/hoarse/ui/headers.py +252 -0
- hoarse-0.1.1/src/hoarse/ui/linkify.py +270 -0
- hoarse-0.1.1/src/hoarse/ui/mainwindow.py +1414 -0
- hoarse-0.1.1/src/hoarse/ui/menus.py +320 -0
- hoarse-0.1.1/src/hoarse/ui/models/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/ui/models/demo.py +679 -0
- hoarse-0.1.1/src/hoarse/ui/models/fetcher.py +416 -0
- hoarse-0.1.1/src/hoarse/ui/models/message_list.py +1124 -0
- hoarse-0.1.1/src/hoarse/ui/models/overlay.py +126 -0
- hoarse-0.1.1/src/hoarse/ui/models/ranges.py +26 -0
- hoarse-0.1.1/src/hoarse/ui/models/source.py +165 -0
- hoarse-0.1.1/src/hoarse/ui/problems.py +46 -0
- hoarse-0.1.1/src/hoarse/ui/screenshot.py +242 -0
- hoarse-0.1.1/src/hoarse/ui/screenshot_accounts.py +309 -0
- hoarse-0.1.1/src/hoarse/ui/screenshot_compose.py +177 -0
- hoarse-0.1.1/src/hoarse/ui/serverform.py +252 -0
- hoarse-0.1.1/src/hoarse/ui/settings.py +206 -0
- hoarse-0.1.1/src/hoarse/ui/views/__init__.py +1 -0
- hoarse-0.1.1/src/hoarse/ui/views/folder_tree.py +582 -0
- hoarse-0.1.1/src/hoarse/ui/views/glyphs.py +337 -0
- hoarse-0.1.1/src/hoarse/ui/views/headerblock.py +240 -0
- hoarse-0.1.1/src/hoarse/ui/views/linkedit.py +155 -0
- hoarse-0.1.1/src/hoarse/ui/views/message_delegate.py +377 -0
- hoarse-0.1.1/src/hoarse/ui/views/message_view.py +301 -0
- hoarse-0.1.1/src/hoarse/ui/views/reader.py +474 -0
- hoarse-0.1.1/src/hoarse/ui/views/syncbar.py +101 -0
- hoarse-0.1.1/src/hoarse/ui/welcome.py +63 -0
- hoarse-0.1.1/src/hoarse/ui/wizard.py +848 -0
- hoarse-0.1.1/tests/conftest.py +26 -0
- hoarse-0.1.1/tests/unit/core/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/core/test_logging_redaction.py +71 -0
- hoarse-0.1.1/tests/unit/core/test_mailtypes.py +21 -0
- hoarse-0.1.1/tests/unit/engine/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/engine/aioclient.py +101 -0
- hoarse-0.1.1/tests/unit/engine/conftest.py +23 -0
- hoarse-0.1.1/tests/unit/engine/fakewriter.py +174 -0
- hoarse-0.1.1/tests/unit/engine/opfake.py +202 -0
- hoarse-0.1.1/tests/unit/engine/run_fake_engine.py +19 -0
- hoarse-0.1.1/tests/unit/engine/run_hung_engine.py +47 -0
- hoarse-0.1.1/tests/unit/engine/syncrig.py +260 -0
- hoarse-0.1.1/tests/unit/engine/test_engine_accounts.py +657 -0
- hoarse-0.1.1/tests/unit/engine/test_engine_dispatch.py +522 -0
- hoarse-0.1.1/tests/unit/engine/test_engine_e2e.py +252 -0
- hoarse-0.1.1/tests/unit/engine/test_replay.py +663 -0
- hoarse-0.1.1/tests/unit/engine/test_replay_store.py +221 -0
- hoarse-0.1.1/tests/unit/engine/test_secret_changed.py +113 -0
- hoarse-0.1.1/tests/unit/engine/test_send_command.py +447 -0
- hoarse-0.1.1/tests/unit/engine/test_sync.py +690 -0
- hoarse-0.1.1/tests/unit/engine/test_sync_engine.py +154 -0
- hoarse-0.1.1/tests/unit/engine/test_sync_policy.py +193 -0
- hoarse-0.1.1/tests/unit/engine/test_sync_unstorable.py +127 -0
- hoarse-0.1.1/tests/unit/engine/test_wave_integration.py +126 -0
- hoarse-0.1.1/tests/unit/features/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/features/features_fakes.py +114 -0
- hoarse-0.1.1/tests/unit/features/test_cli.py +138 -0
- hoarse-0.1.1/tests/unit/features/test_corpus_roundtrip.py +47 -0
- hoarse-0.1.1/tests/unit/features/test_exporters.py +214 -0
- hoarse-0.1.1/tests/unit/features/test_formats.py +119 -0
- hoarse-0.1.1/tests/unit/features/test_hostile_fs.py +222 -0
- hoarse-0.1.1/tests/unit/features/test_importers.py +364 -0
- hoarse-0.1.1/tests/unit/features/test_mbox.py +325 -0
- hoarse-0.1.1/tests/unit/features/test_real_store.py +193 -0
- hoarse-0.1.1/tests/unit/features/test_scale.py +134 -0
- hoarse-0.1.1/tests/unit/htmlmail/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/ipc/conftest.py +23 -0
- hoarse-0.1.1/tests/unit/ipc/test_ipc_client.py +121 -0
- hoarse-0.1.1/tests/unit/ipc/test_ipc_server.py +391 -0
- hoarse-0.1.1/tests/unit/ipc/test_schema_send.py +47 -0
- hoarse-0.1.1/tests/unit/mime/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/mime/conftest.py +122 -0
- hoarse-0.1.1/tests/unit/mime/test_address.py +412 -0
- hoarse-0.1.1/tests/unit/mime/test_charset.py +147 -0
- hoarse-0.1.1/tests/unit/mime/test_compose.py +1040 -0
- hoarse-0.1.1/tests/unit/mime/test_dates.py +245 -0
- hoarse-0.1.1/tests/unit/mime/test_stream.py +188 -0
- hoarse-0.1.1/tests/unit/mime/test_totality.py +65 -0
- hoarse-0.1.1/tests/unit/mime/test_words.py +339 -0
- hoarse-0.1.1/tests/unit/net/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/net/conftest.py +19 -0
- hoarse-0.1.1/tests/unit/net/fakemail.py +415 -0
- hoarse-0.1.1/tests/unit/net/fixtures/ispdb/gmail.com.xml +77 -0
- hoarse-0.1.1/tests/unit/net/fixtures/ispdb/gmx.net.xml +70 -0
- hoarse-0.1.1/tests/unit/net/fixtures/ispdb/posteo.de.xml +99 -0
- hoarse-0.1.1/tests/unit/net/test_autoconfig.py +354 -0
- hoarse-0.1.1/tests/unit/net/test_imap_client.py +780 -0
- hoarse-0.1.1/tests/unit/net/test_imap_client_hostile.py +291 -0
- hoarse-0.1.1/tests/unit/net/test_imap_lexer.py +1266 -0
- hoarse-0.1.1/tests/unit/net/test_imap_parser.py +614 -0
- hoarse-0.1.1/tests/unit/net/test_probe.py +340 -0
- hoarse-0.1.1/tests/unit/net/test_probe_deadlines.py +191 -0
- hoarse-0.1.1/tests/unit/net/test_smtp.py +435 -0
- hoarse-0.1.1/tests/unit/store/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/store/storefixtures.py +273 -0
- hoarse-0.1.1/tests/unit/store/test_accounts.py +354 -0
- hoarse-0.1.1/tests/unit/store/test_blob_integrity.py +590 -0
- hoarse-0.1.1/tests/unit/store/test_blobs.py +96 -0
- hoarse-0.1.1/tests/unit/store/test_crash.py +137 -0
- hoarse-0.1.1/tests/unit/store/test_detach.py +163 -0
- hoarse-0.1.1/tests/unit/store/test_ingest_review.py +81 -0
- hoarse-0.1.1/tests/unit/store/test_migrations.py +479 -0
- hoarse-0.1.1/tests/unit/store/test_reader.py +828 -0
- hoarse-0.1.1/tests/unit/store/test_replay_api.py +849 -0
- hoarse-0.1.1/tests/unit/store/test_schema.py +1077 -0
- hoarse-0.1.1/tests/unit/store/test_seed.py +166 -0
- hoarse-0.1.1/tests/unit/store/test_serverstate.py +947 -0
- hoarse-0.1.1/tests/unit/store/test_sync_health.py +315 -0
- hoarse-0.1.1/tests/unit/store/test_threadview.py +511 -0
- hoarse-0.1.1/tests/unit/store/test_writer.py +883 -0
- hoarse-0.1.1/tests/unit/test_buildinfo.py +50 -0
- hoarse-0.1.1/tests/unit/test_layering.py +210 -0
- hoarse-0.1.1/tests/unit/test_version.py +38 -0
- hoarse-0.1.1/tests/unit/theme/.gitkeep +0 -0
- hoarse-0.1.1/tests/unit/theme/_fixtures.py +80 -0
- hoarse-0.1.1/tests/unit/theme/conftest.py +20 -0
- hoarse-0.1.1/tests/unit/theme/test_architecture.py +95 -0
- hoarse-0.1.1/tests/unit/theme/test_contrast.py +110 -0
- hoarse-0.1.1/tests/unit/theme/test_pack_loader.py +244 -0
- hoarse-0.1.1/tests/unit/theme/test_qss.py +82 -0
- hoarse-0.1.1/tests/unit/theme/test_resolve.py +153 -0
- hoarse-0.1.1/tests/unit/theme/test_themecheck_cli.py +46 -0
- hoarse-0.1.1/tests/unit/tools/fakeimap_helpers.py +127 -0
- hoarse-0.1.1/tests/unit/tools/test_bench.py +262 -0
- hoarse-0.1.1/tests/unit/tools/test_corpus.py +603 -0
- hoarse-0.1.1/tests/unit/tools/test_fakeimap.py +638 -0
- hoarse-0.1.1/tests/unit/tools/test_fakeimap_faults.py +351 -0
- hoarse-0.1.1/tests/unit/tools/test_fakeimap_gmail.py +232 -0
- hoarse-0.1.1/tests/unit/tools/test_fakeimap_replay.py +256 -0
- hoarse-0.1.1/tests/unit/tools/test_fakeimap_structure.py +188 -0
- hoarse-0.1.1/tests/unit/tools/test_fakesmtp.py +532 -0
- hoarse-0.1.1/tests/unit/tools/test_reqcheck.py +310 -0
- hoarse-0.1.1/tests/unit/ui/acc_rig.py +125 -0
- hoarse-0.1.1/tests/unit/ui/conftest.py +61 -0
- hoarse-0.1.1/tests/unit/ui/mail_rig.py +156 -0
- hoarse-0.1.1/tests/unit/ui/test_about.py +38 -0
- hoarse-0.1.1/tests/unit/ui/test_account_settings.py +387 -0
- hoarse-0.1.1/tests/unit/ui/test_account_wizard.py +645 -0
- hoarse-0.1.1/tests/unit/ui/test_compose_util.py +102 -0
- hoarse-0.1.1/tests/unit/ui/test_composer.py +673 -0
- hoarse-0.1.1/tests/unit/ui/test_controller_menus.py +977 -0
- hoarse-0.1.1/tests/unit/ui/test_damaged_body.py +26 -0
- hoarse-0.1.1/tests/unit/ui/test_engine_bus.py +196 -0
- hoarse-0.1.1/tests/unit/ui/test_first_run_accounts_window.py +366 -0
- hoarse-0.1.1/tests/unit/ui/test_headers.py +311 -0
- hoarse-0.1.1/tests/unit/ui/test_linkify.py +306 -0
- hoarse-0.1.1/tests/unit/ui/test_mainwindow.py +192 -0
- hoarse-0.1.1/tests/unit/ui/test_mark_read.py +166 -0
- hoarse-0.1.1/tests/unit/ui/test_message_list_model.py +262 -0
- hoarse-0.1.1/tests/unit/ui/test_model_threads_overlay.py +329 -0
- hoarse-0.1.1/tests/unit/ui/test_reader_headers_links.py +318 -0
- hoarse-0.1.1/tests/unit/ui/test_secret_lifecycle.py +78 -0
- hoarse-0.1.1/tests/unit/ui/test_source_and_demo.py +250 -0
- hoarse-0.1.1/tests/unit/ui/test_sync_wiring.py +304 -0
- hoarse-0.1.1/tests/unit/ui/test_threaded_real_store.py +118 -0
- hoarse-0.1.1/tests/unit/ui/test_threaded_view.py +229 -0
- hoarse-0.1.1/tests/unit/ui/test_views.py +268 -0
- hoarse-0.1.1/tests/unit/ui/test_views_sorting_threads.py +342 -0
- hoarse-0.1.1/tests/unit/ui/ui_fakes.py +429 -0
- hoarse-0.1.1/uv.lock +624 -0
hoarse-0.1.1/.gitignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Hoarse. Versions are semantic (`PKG-10`); the policy for
|
|
4
|
+
when each part moves is in [`docs/plan/versioning.md`](docs/plan/versioning.md).
|
|
5
|
+
Each entry names the store schema version, and says so explicitly when a release
|
|
6
|
+
needs a resync or a profile migration (`PKG-12`).
|
|
7
|
+
|
|
8
|
+
## 0.1.1 — 2026-10-22
|
|
9
|
+
|
|
10
|
+
- **Help > About Hoarse** shows the version and build information: where the
|
|
11
|
+
build came from, its commit, when it was built, and the Python, platform,
|
|
12
|
+
SQLite, Qt and store schema versions, with a button that copies them for a bug
|
|
13
|
+
report. `hoarse --build-info` prints the same on the command line.
|
|
14
|
+
- Released packages carry the commit they were built from.
|
|
15
|
+
- A corrected password now takes effect at once: saving it to the keychain, or
|
|
16
|
+
pressing Apply in Account Settings, retries a stopped account. An older
|
|
17
|
+
password you chose not to remember no longer overrides the saved one, and is
|
|
18
|
+
forgotten when its account is deleted.
|
|
19
|
+
- Store schema 6 (unchanged); no resync needed.
|
|
20
|
+
|
|
21
|
+
## 0.1.0 — 2026-10-22
|
|
22
|
+
|
|
23
|
+
The first versioned release. Alpha: usable against real IMAP accounts, not yet
|
|
24
|
+
feature-complete (see the roadmap, `docs/spec/15-roadmap.md`).
|
|
25
|
+
|
|
26
|
+
- **Python** 3.14 (minimum and tested). **PySide6** 6.12 or later (tested 6.12.0).
|
|
27
|
+
- **Store schema** 6, blob schema 2. Profiles from earlier development builds
|
|
28
|
+
migrate automatically; no resync is needed.
|
|
29
|
+
|
|
30
|
+
### What works
|
|
31
|
+
|
|
32
|
+
- Three-pane, Thunderbird-style window with modern, retro, dark and high-contrast
|
|
33
|
+
theme packs; a message list that stays fast at a million messages; threaded
|
|
34
|
+
view (one row per thread, shown by its first message); folder tree with context
|
|
35
|
+
menus (create subfolders, rename, delete, move, properties with subfolder totals).
|
|
36
|
+
- Multiple accounts with an account wizard (autoconfig), separate incoming and
|
|
37
|
+
outgoing passwords, keychain or session-only storage.
|
|
38
|
+
- IMAP sync: folder list, headers and bodies within the offline window, with
|
|
39
|
+
QRESYNC/CONDSTORE where offered.
|
|
40
|
+
- Reading: header block (From, Subject, List-Id, Date, To, Cc, Reply-To by
|
|
41
|
+
default, customisable), clickable plain-text links, mark read on view.
|
|
42
|
+
- Writing: composer for new, reply, reply all, reply to list and forward;
|
|
43
|
+
attachments; signatures; 10-second undo after Send; SMTP submission with an
|
|
44
|
+
exactly-once guarantee across crashes; a copy filed in Sent.
|
|
45
|
+
- Changes made in Hoarse (read state, flags, moves, deletes, folder operations)
|
|
46
|
+
replay to the IMAP server through a durable op-log, with undo and retries.
|
|
47
|
+
- Import and export of mbox, Maildir and .eml.
|
|
48
|
+
|
|
49
|
+
### Not yet
|
|
50
|
+
|
|
51
|
+
Drafts, recipient autocomplete, HTML rendering (HTML mail shows as text),
|
|
52
|
+
search UI, filters, address book, OAuth, POP3 and JMAP sending/sync.
|
hoarse-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Gruno
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
hoarse-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hoarse
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A desktop email client that stays fast at a million messages
|
|
5
|
+
Author-email: Daniel Gruno <humbedooh@apache.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: client,email,imap,mail,pyside6,qt,smtp
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Communications :: Email :: Email Clients (MUA)
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Requires-Dist: jeepney>=0.9; sys_platform == 'linux'
|
|
21
|
+
Requires-Dist: keyring>=25.7
|
|
22
|
+
Requires-Dist: nh3>=0.3.7
|
|
23
|
+
Requires-Dist: pyside6-essentials>=6.12
|
|
24
|
+
Requires-Dist: pywin32-ctypes>=0.2; sys_platform == 'win32'
|
|
25
|
+
Requires-Dist: secretstorage>=3.3; sys_platform == 'linux'
|
|
26
|
+
Provides-Extra: webengine
|
|
27
|
+
Requires-Dist: pyside6-addons>=6.12; extra == 'webengine'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Hoarse
|
|
31
|
+
|
|
32
|
+
A desktop email client for Linux, macOS and Windows, written in Python, that stays responsive on
|
|
33
|
+
mail accounts far larger than the ones most clients are tested against.
|
|
34
|
+
|
|
35
|
+
- Laid out like Thunderbird: three panes, a folder tree per account, a dense message list.
|
|
36
|
+
- Themed by declarative packs — a modern pack and a retro pack that genuinely look different.
|
|
37
|
+
- Designed against a single account holding **more than one million messages**.
|
|
38
|
+
|
|
39
|
+
**Start with the specification: [`docs/spec/README.md`](docs/spec/README.md).** It is normative.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
Hoarse needs Python 3.14 and installs from PyPI with no compiler and no system
|
|
44
|
+
packages:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uvx hoarse # or: pipx install hoarse / uv tool install hoarse
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
From a checkout, for development:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv run hoarse
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Versions are semantic; see [`CHANGELOG.md`](CHANGELOG.md) and
|
|
57
|
+
[`docs/plan/versioning.md`](docs/plan/versioning.md).
|
|
58
|
+
|
|
59
|
+
Development is split across five tracks — see [`docs/plan/parallel-tracks.md`](docs/plan/parallel-tracks.md).
|
hoarse-0.1.1/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Hoarse
|
|
2
|
+
|
|
3
|
+
A desktop email client for Linux, macOS and Windows, written in Python, that stays responsive on
|
|
4
|
+
mail accounts far larger than the ones most clients are tested against.
|
|
5
|
+
|
|
6
|
+
- Laid out like Thunderbird: three panes, a folder tree per account, a dense message list.
|
|
7
|
+
- Themed by declarative packs — a modern pack and a retro pack that genuinely look different.
|
|
8
|
+
- Designed against a single account holding **more than one million messages**.
|
|
9
|
+
|
|
10
|
+
**Start with the specification: [`docs/spec/README.md`](docs/spec/README.md).** It is normative.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Hoarse needs Python 3.14 and installs from PyPI with no compiler and no system
|
|
15
|
+
packages:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uvx hoarse # or: pipx install hoarse / uv tool install hoarse
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
From a checkout, for development:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv run hoarse
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Versions are semantic; see [`CHANGELOG.md`](CHANGELOG.md) and
|
|
28
|
+
[`docs/plan/versioning.md`](docs/plan/versioning.md).
|
|
29
|
+
|
|
30
|
+
Development is split across five tracks — see [`docs/plan/parallel-tracks.md`](docs/plan/parallel-tracks.md).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 1. Goals and non-goals
|
|
2
|
+
|
|
3
|
+
## 1.1 What Hoarse is
|
|
4
|
+
|
|
5
|
+
A desktop email client for Linux, macOS and Windows, written in Python, that stays responsive on mail accounts far larger than the ones most clients are tested against.
|
|
6
|
+
|
|
7
|
+
Three things define it:
|
|
8
|
+
|
|
9
|
+
1. **It is laid out like Thunderbird.** A three-pane window, a folder tree, a dense sortable message list, a reader pane, tabs, a quick filter. People migrating from Thunderbird should not have to relearn where anything is.
|
|
10
|
+
2. **It looks like whatever you want.** The entire appearance — palette, metrics, borders, icons, row density, fonts — comes from a declarative theme pack. Hoarse ships a modern light and dark pack and a retro pack, and a third party can write more without touching Python.
|
|
11
|
+
3. **It does not slow down.** The design target is a single account holding **more than one million messages**, and every interaction budget in [chapter 14](14-quality.md) is written against that corpus, not against a 5,000-message test account.
|
|
12
|
+
|
|
13
|
+
## 1.2 Who it is for
|
|
14
|
+
|
|
15
|
+
Someone with a large, long-lived mail archive — a decade or two of mailing lists, work mail and personal mail — who wants it local, searchable, fast and under their own control. They are comfortable installing a tool with `uv`. They care about the client not leaking their reading habits to senders.
|
|
16
|
+
|
|
17
|
+
## 1.3 Goals
|
|
18
|
+
|
|
19
|
+
| ID | Goal |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `GOA-1` | A folder containing 1,000,000 messages MUST open, scroll, sort and filter without the interface stalling. |
|
|
22
|
+
| `GOA-2` | The interface MUST never block on the network. Every view reads from the local store only. |
|
|
23
|
+
| `GOA-3` | Every action that changes server state MUST apply locally first and be replayed to the server afterwards, with undo. |
|
|
24
|
+
| `GOA-4` | Hoarse MUST support IMAP, IMAPS, POP3 and SMTP, with multiple accounts and multiple sending identities per account. |
|
|
25
|
+
| `GOA-5` | Hoarse MUST run on Linux, macOS and Windows from one code base, with no platform-specific forks of the UI. |
|
|
26
|
+
| `GOA-6` | The appearance MUST be fully determined by a theme pack, and a retro pack MUST look genuinely different from a modern one — not merely recoloured. |
|
|
27
|
+
| `GOA-7` | Remote content in mail MUST be blocked by default; no message may cause a network request without the user asking for it. |
|
|
28
|
+
| `GOA-8` | Hoarse MUST be installable and runnable with `uv` alone, with no compiler, system package or build step required of the user. |
|
|
29
|
+
| `GOA-9` | Importing an existing archive (mbox, Maildir, a Thunderbird profile) MUST work by streaming, at any size. |
|
|
30
|
+
| `GOA-10` | Hoarse MUST collect no telemetry and MUST make no network connection that the user has not configured. |
|
|
31
|
+
|
|
32
|
+
## 1.4 Non-goals
|
|
33
|
+
|
|
34
|
+
These are out of scope for the specification. They are listed so that nobody has to re-litigate them.
|
|
35
|
+
|
|
36
|
+
| Not in scope | Why |
|
|
37
|
+
|---|---|
|
|
38
|
+
| **Calendar and tasks (CalDAV)** | A second protocol stack, a second store schema, recurrence, timezones and a whole second UI. It roughly doubles the project. Use a dedicated calendar client. |
|
|
39
|
+
| **Contact sync (CardDAV)** | Follows the calendar decision. The local address book in [chapter 13](13-features.md) is in scope; synchronising it is not. |
|
|
40
|
+
| **OpenPGP and S/MIME** | Key management, trust models, per-recipient policy and a UI for all three are a large sub-project in their own right, and a client that encrypts badly is worse than one that does not encrypt. **Note:** the original argument also rested on having no crypto primitives, which stopped being true when `cryptography` was admitted ([`A-07`](02-decisions.md)). This is now a scope decision, not a capability one, and can be revisited on its own merits. |
|
|
41
|
+
| **Exchange EWS / Microsoft Graph** | A large, awkward API surface with shifting authentication. Microsoft 365 accounts with IMAP enabled are supported through IMAP + OAuth2; accounts with IMAP disabled by policy are not reachable. |
|
|
42
|
+
| **RSS feeds and NNTP newsgroups** | Thunderbird carries them for historical reasons. They share almost nothing with the mail path. |
|
|
43
|
+
| **A mobile client** | The architecture ([chapter 3](03-architecture.md)) does not preclude one; nothing here is written for it. |
|
|
44
|
+
| **Cross-account unified folders** | See [D-14](02-decisions.md#d-14). Accounts are strictly separate, which is what keeps every list query on a single index. |
|
|
45
|
+
| **A web or server mode** | Hoarse is a desktop application. The engine process is not a daemon for other users. |
|
|
46
|
+
|
|
47
|
+
## 1.5 What success looks like
|
|
48
|
+
|
|
49
|
+
Hoarse v1.0 is done when all of the following hold on a real account of at least one million messages, on all three platforms:
|
|
50
|
+
|
|
51
|
+
- Cold start to a painted window showing the last-viewed folder, with no network, in under 800 ms (`PRF-1`).
|
|
52
|
+
- Switching to a 1,000,000-message folder paints the first screen in under 100 ms (`PRF-2`).
|
|
53
|
+
- Scrolling that folder holds 60 fps with no frame over 16.7 ms (`PRF-4`).
|
|
54
|
+
- A local search returns its first results in under 150 ms (`PRF-6`).
|
|
55
|
+
- Marking read, flagging, moving and deleting feel instantaneous and are undoable (`PRF-8`, `OPL-9`).
|
|
56
|
+
- An incremental resync of every folder on a QRESYNC-capable server completes in under two seconds (`PRF-11`).
|
|
57
|
+
- A fresh install reaches a usable inbox from `uvx hoarse` with no compiler and no system packages (`PKG-1`).
|
|
58
|
+
|
|
59
|
+
Everything else is negotiable. These are not.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# 2. Decisions
|
|
2
|
+
|
|
3
|
+
Each decision below is **binding**. A work package that needs one of them changed must stop and say so rather than working around it ([brief rules](../plan/brief-template.md)).
|
|
4
|
+
|
|
5
|
+
Decisions carry a date and the alternatives that were rejected, so that a future reader can tell whether the reasoning still holds. Several of them were close calls; those are marked **revisit** with the condition that would reopen them.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## D-01 — User interface: PySide6, Qt Widgets
|
|
10
|
+
|
|
11
|
+
**Decided 2026-10-20.** The UI is built on **PySide6 (Qt 6.12+) using Qt Widgets**, with custom-painted item delegates for the message list.
|
|
12
|
+
|
|
13
|
+
Qt Widgets gives the strongest model/view framework available to Python for dense tabular data, native menus and shortcuts on all three platforms, and a mature accessibility bridge. PySide6 6.12.0 ships `cp310-abi3` wheels covering Linux x86-64 and aarch64, macOS universal2, and Windows amd64 and arm64, which means one wheel per platform works across Python 3.10–3.15. It is LGPLv3, which imposes no obligation on Hoarse's own licensing.
|
|
14
|
+
|
|
15
|
+
*Rejected:* **Qt Quick/QML** — a better story for skinning and animation, but its views are weak for dense tabular data and the list virtualisation would be hand-rolled; GPU driver variance across three desktop platforms is a support cost we do not want on the critical path. **GTK4/libadwaita** — excellent on Linux, materially worse to package and to live with on Windows and macOS. **A web UI in a native shell** — the most flexible theming, but it puts a Python↔JS bridge between the user and a million-row list.
|
|
16
|
+
|
|
17
|
+
**Consequence:** the message list is a `QTableView`, *not* a `QTreeView`. See [D-06](#d-06).
|
|
18
|
+
|
|
19
|
+
## D-02 — Dependencies: pure Python, plus Qt, plus a named allowlist
|
|
20
|
+
|
|
21
|
+
**Decided 2026-10-20. Amended 2026-10-20 to admit `nh3`.**
|
|
22
|
+
|
|
23
|
+
Hoarse's dependencies MUST be the standard library, a pure-Python package, or a member of the **compiled allowlist** below. The allowlist is closed: adding to it is a specification change, decided against the admission criteria, not a judgement call made inside a work package.
|
|
24
|
+
|
|
25
|
+
### The compiled allowlist
|
|
26
|
+
|
|
27
|
+
| Package | Why it is admitted | Governs |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **PySide6** (Essentials; Addons under the `webengine` extra) | There is no credible pure-Python path to a native desktop UI with a model/view framework that survives a million rows | [10](10-ui.md), [11](11-theming.md) |
|
|
30
|
+
| **nh3** 0.3.7+ | Python bindings over the Rust `ammonia` crate. Security-critical, actively maintained, and the component we are least well placed to write ourselves | [09](09-mime-render.md) |
|
|
31
|
+
| **cryptography** (and **cffi**) | Reached transitively through `keyring` → `secretstorage` on Linux, and admitted on the same grounds as nh3: we cannot write AES safely ourselves. PyCA-maintained, abi3 wheels on every target, a real security-response history | [12](12-accounts-security.md) |
|
|
32
|
+
|
|
33
|
+
### Admission criteria
|
|
34
|
+
|
|
35
|
+
A compiled package may be admitted only when **all** of these hold:
|
|
36
|
+
|
|
37
|
+
1. **Wheels on every target** — Linux (glibc and musl, x86-64 and aarch64), macOS (universal2), Windows (amd64 and arm64), with no build step for the user.
|
|
38
|
+
2. **A stable ABI** (`abi3`), so a new CPython release does not strand the project.
|
|
39
|
+
3. **No transitive compiled dependencies.**
|
|
40
|
+
4. **Either** we cannot write it safely ourselves, **or** the pure-Python version misses a `PRF` budget by more than 3× after optimisation.
|
|
41
|
+
5. An active maintainer and a security-response history.
|
|
42
|
+
|
|
43
|
+
### Why nh3 qualifies
|
|
44
|
+
|
|
45
|
+
Verified 2026-10-20 against PyPI and by installing it on this machine:
|
|
46
|
+
|
|
47
|
+
- **27 wheels**, `cp38-abi3`, covering manylinux and musllinux on x86-64, aarch64, armv7l, i686, ppc64, ppc64le, s390x and riscv64; macOS universal2; and Windows win32, amd64 **and** arm64. That is broader coverage than PySide6's own wheels. 0.8–1.5 MB each.
|
|
48
|
+
- **Zero transitive dependencies.** MIT licensed. Installs in seconds with `uv`.
|
|
49
|
+
- `abi3` means one wheel spans 3.8 through 3.15+, with free-threaded (`cp314t`) builds also published.
|
|
50
|
+
|
|
51
|
+
And it passes the bar that matters: tested against this chapter's own hard rules, **nh3 leaked nothing** across 28 adversarial cases — script and style stripping, every `on*` form, entity-encoded and control-character scheme smuggling, `vbscript:`/`file:`/`data:` in `href`, SVG, CSS `expression()`, remote `url()`, `-moz-binding`, `position:fixed`, and the `noscript`/`math` mXSS mutations. Its URL handling was checked against a real Chromium URL parser: it strips exactly the characters a browser strips (tab, LF, CR, leading space — all of which *do* resolve to an executable `javascript:`) and leaves the ones a browser percent-encodes into an inert relative path (`NUL`, `\x01`, `\x0b`).
|
|
52
|
+
|
|
53
|
+
*Rejected:* writing the sanitiser ourselves on `html.parser`. It was the original decision, and it made the single highest-risk component in the application something we maintained alone, against an adversary who gets to pick the input. `bleach` is not an option — final release, unmaintained since June 2026, on top of the unmaintained `html5lib`.
|
|
54
|
+
|
|
55
|
+
### What Hoarse still writes
|
|
56
|
+
|
|
57
|
+
Admitting nh3 removes the sanitiser core, not the pipeline around it:
|
|
58
|
+
|
|
59
|
+
| Component | Why it is still ours | Where |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Remote-content blocking and counting | Product policy, not sanitising. Implemented in nh3's `attribute_filter` callback | [09 §9.4](09-mime-render.md) |
|
|
62
|
+
| `<style>` block parsing and inlining | nh3 **discards** `<style>` blocks rather than inlining them, and a large share of real mail styles itself that way (`MIM-18`) | [09 §9.4](09-mime-render.md) |
|
|
63
|
+
| `data:` URL type and size caps | nh3 allows a `data:` scheme wholesale; the 2 MiB and image-type caps are ours | [09 §9.4](09-mime-render.md) |
|
|
64
|
+
| Depth and node caps | Denial-of-service bounds, applied before and after nh3 | [09 §9.4](09-mime-render.md) |
|
|
65
|
+
| Full-text search | SQLite FTS5 via stdlib `sqlite3`. `tantivy` is **not** admitted — see the note below | [05](05-search.md) |
|
|
66
|
+
| IMAP engine | A purpose-built asyncio client; see [D-03](#d-03) | [07](07-protocols.md) |
|
|
67
|
+
| Fast header parsing | A streaming header parser for bulk import; the stdlib `email` module is too slow for a million messages | [09](09-mime-render.md) |
|
|
68
|
+
| Credential vault fallback | ChaCha20-Poly1305 in pure Python against RFC 8439 vectors, for small secrets when no OS keychain is available | [12](12-accounts-security.md) |
|
|
69
|
+
|
|
70
|
+
**Permitted pure-Python dependencies.** `keyring` and its platform backends (`jeepney` on Linux, `pywin32-ctypes` on Windows; macOS shells out to `/usr/bin/security`).
|
|
71
|
+
|
|
72
|
+
### How `cryptography` came to be admitted
|
|
73
|
+
|
|
74
|
+
Worth recording, because it was found rather than designed. The layering guard (`QA-2`) caught it on its first run, contradicting what an earlier draft of this chapter claimed: **`keyring` is not pure Python on Linux.** It hard-depends on `secretstorage`, which depends on `cryptography`, which depends on `cffi`:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
keyring ──(sys_platform == "linux")──► secretstorage ──► cryptography ──► cffi ──► pycparser
|
|
78
|
+
[compiled] [compiled] [pure]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The alternatives were writing our own `net/secrets/` over `jeepney` and `ctypes`, or making the system keyring an opt-in extra on Linux and defaulting to Hoarse's own vault. Both keep the allowlist at two entries; both make the common Linux case worse, and the first puts three hand-written platform paths on the credential route — which is precisely where a subtle bug is most expensive.
|
|
82
|
+
|
|
83
|
+
**Decided 2026-10-20: admit it.** Criterion 4 applies exactly as it did for nh3 — we cannot write AES safely ourselves — and `cryptography` is the most scrutinised package in the Python ecosystem for this job.
|
|
84
|
+
|
|
85
|
+
**Knock-on, flagged deliberately.** OpenPGP and S/MIME were ruled out of scope partly because pure Python meant hand-written RSA, ECC and AES ([01 §1.4](01-goals.md#14-non-goals)). That argument is now weaker: the primitives are present. The non-goal **still stands** on its remaining grounds — key management, trust models, and a UI for them are a large sub-project regardless of who supplies the maths — but it is now a scope decision rather than a capability one, and can be revisited on its own merits.
|
|
86
|
+
|
|
87
|
+
**On `tantivy` specifically.** It meets criteria 1–3 and 5, and it is several times faster than FTS5. It is **not** admitted, because nh3 and tantivy differ in kind: nh3 is a pure function with no state, while tantivy is a second persistent store that must stay consistent with SQLite across crashes, eviction and migration — it would reopen [chapter 5](05-search.md) and add a whole class of recovery requirements. Admitting it is a decision to take deliberately, not a consequence of admitting nh3. **Revisit** if `PRF-6` proves unreachable with FTS5 on the 1M corpus.
|
|
88
|
+
|
|
89
|
+
## D-03 — IMAP: a purpose-built asyncio engine
|
|
90
|
+
|
|
91
|
+
**Decided 2026-10-20.** Hoarse implements its own IMAP client, on asyncio, supporting CONDSTORE and **QRESYNC** (RFC 7162), `COMPRESS=DEFLATE`, command pipelining, literal streaming, `ESEARCH`, `MOVE`, `UIDPLUS`, `OBJECTID`, `SPECIAL-USE`, `LIST-STATUS` and `NOTIFY`.
|
|
92
|
+
|
|
93
|
+
No Python IMAP library supports QRESYNC. IMAPClient 4.1.0 is synchronous, built on `imaplib`, offers CONDSTORE only through `enable()`, and has no COMPRESS. aioimaplib 2.0.3 is asynchronous and actively maintained but still lists STARTTLS and most of `AUTHENTICATE` as unimplemented. QRESYNC is the single lever that turns resync cost from *per message* into *per mailbox*; at a million messages that is the difference between a two-second resync and a two-minute one. It cannot be bolted on from outside a library's parser.
|
|
94
|
+
|
|
95
|
+
*Rejected:* building on IMAPClient with raw commands (fastest to a working client, permanent ceiling on resync cost); extending aioimaplib upstream (inherits someone else's parser and release cadence); a driver interface shipping IMAPClient first (defers the long pole, but the engine's scheduling and connection-pool design is shaped by the protocol client, so the interface would be guessed rather than derived).
|
|
96
|
+
|
|
97
|
+
**This is the largest single item in the project.** It is budgeted accordingly in [the roadmap](15-roadmap.md).
|
|
98
|
+
|
|
99
|
+
## D-04 — Two processes: UI and engine
|
|
100
|
+
|
|
101
|
+
**Decided 2026-10-20.** Hoarse runs as **two processes**: `hoarse-ui` (PySide6, read-only on the store) and `hoarse-engine` (asyncio, sole writer). They communicate over a local socket.
|
|
102
|
+
|
|
103
|
+
PySide6 does not support free-threaded Python, so the GIL is a given. In a single process, every burst of MIME parsing, HTML sanitising, index building or database writing competes with the UI thread for the interpreter — which is exactly the mechanism behind scroll stutter in Python GUI applications. Separating the processes removes the contention entirely, gives crash isolation (an engine fault does not take the window down), and lets the engine be restarted, upgraded or debugged independently.
|
|
104
|
+
|
|
105
|
+
*Rejected:* one process with worker threads (much simpler, but GIL contention is the known failure mode); one process plus a `ProcessPoolExecutor` (helps the CPU-bound bursts, leaves the sync loop in the UI process); a three-tier engine-plus-worker-pool (more headroom than v1 needs).
|
|
106
|
+
|
|
107
|
+
The engine MAY use a `ProcessPoolExecutor` internally for bulk import and initial index builds. That is an implementation detail, not a third architectural tier.
|
|
108
|
+
|
|
109
|
+
## D-05 — Store: SQLite for metadata, a second SQLite database for bodies
|
|
110
|
+
|
|
111
|
+
**Decided 2026-10-20.** Per account: one `meta.db` holding envelopes, flags, folders, threads, addresses, parts, the op-log and the search index; and **sharded `blobs/NNN.db` files** holding message bodies and parts as compressed BLOBs.
|
|
112
|
+
|
|
113
|
+
SQLite is measurably faster than the filesystem for BLOBs under about 256 KB and uses roughly 20% less disk for them, because it avoids per-file `open`/`close` and filesystem block rounding. It also brings crash safety, incremental blob I/O and `VACUUM` that we would otherwise hand-write. Splitting bodies into their own file keeps `meta.db` small and hot in page cache — which is what makes list scrolling fast.
|
|
114
|
+
|
|
115
|
+
Blob databases are **sharded and sealed**: the writer appends to the newest shard until it reaches a size cap (default 8 GB), then seals it and opens a new one. Sealed shards are read-only, so they never need `VACUUM`, and they back up incrementally. A blob locator is the pair `(shard_id, rowid)`.
|
|
116
|
+
|
|
117
|
+
*Rejected:* hand-rolled append-only pack files (better dedup and compression control, but fsync ordering, CRC, crash recovery and compaction are a sub-project in themselves); Maildir files (great interoperability, but a million inodes per account and poor small-file behaviour on NTFS and APFS); blobs with spillover to files above a threshold (two code paths for one job).
|
|
118
|
+
|
|
119
|
+
## D-06 — Message list: `QTableView` over a windowed model
|
|
120
|
+
|
|
121
|
+
**Decided 2026-10-20.** The message list is a `QTableView` with fixed row metrics and a single full-width custom delegate. Threading is **materialised in the store** and rendered as indentation by the delegate.
|
|
122
|
+
|
|
123
|
+
`QTreeView` builds an internal item structure for every visible row and is well documented as slow at large item counts; `QTableView` computes geometry from fixed section sizes and handles millions of rows. Keeping the model flat means the threaded view is still a single keyset-paginated index range read.
|
|
124
|
+
|
|
125
|
+
The model never holds the whole folder: it reports the true row count and materialises a sliding window of rows around the viewport. See [chapter 10](10-ui.md).
|
|
126
|
+
|
|
127
|
+
## D-07 — Search: SQLite FTS5, headers always, bodies inside the offline window
|
|
128
|
+
|
|
129
|
+
**Decided 2026-10-20.** Subject and addresses are indexed for **every** message. Body text is indexed for exactly those messages whose bodies are stored locally under the account's body-retention policy ([D-08](#d-08)). Anything outside that is searched on the server and merged into the result list.
|
|
130
|
+
|
|
131
|
+
This bounds the index — the usual failure mode of local mail search is an index that grows without limit — and it matches the body policy exactly, so there is one rule to explain rather than two. Verified available in CPython 3.14.4 (SQLite 3.46.1): FTS5 with `contentless_delete=1`, the `trigram` tokenizer for substring search, and `fts5vocab` for the query planner's selectivity estimates.
|
|
132
|
+
|
|
133
|
+
*Rejected:* headers only (no offline body search at all); indexing every body unconditionally (unbounded index, permanent background indexer); an opt-in body index (a setting that only exists because we could not decide).
|
|
134
|
+
|
|
135
|
+
## D-08 — Offline policy: per account, with a window by default
|
|
136
|
+
|
|
137
|
+
**Decided 2026-10-20.** Headers are synchronised for every message in every account. Bodies are fetched according to a **per-account, per-folder policy**, defaulting to a rolling window (90 days) plus anything the user opens, evicted by LRU under a disk cap.
|
|
138
|
+
|
|
139
|
+
People with several accounts use them differently: a full mirror for a small work account, a 30-day window for a million-message archive, headers-only for a high-volume mailing-list account. One global policy serves none of those well.
|
|
140
|
+
|
|
141
|
+
*Rejected:* a single global window; full mirror by default (30–100 GB and hours of first sync before the client is usable); headers-only by default (every read needs the network).
|
|
142
|
+
|
|
143
|
+
## D-09 — HTML rendering: safe by default, full fidelity on request
|
|
144
|
+
|
|
145
|
+
**Decided 2026-10-20.** Mail renders by default through Hoarse's own sanitiser into a `QTextDocument` — fast, offline, no Chromium, no network. A per-message and per-sender **"render fully"** action switches to QtWebEngine with JavaScript disabled, a strict CSP, remote content blocked and a custom scheme handler for inline parts.
|
|
146
|
+
|
|
147
|
+
The default path costs nothing and leaks nothing. The escape hatch exists because a meaningful share of real mail is built with CSS that Qt's rich-text engine does not implement, and telling the user "this message cannot be displayed correctly" is not an acceptable answer.
|
|
148
|
+
|
|
149
|
+
QtWebEngine lives in `PySide6-Addons` (~250 MB), so it is an **extra**: `hoarse[webengine]`. A build without it degrades gracefully to "show original source".
|
|
150
|
+
|
|
151
|
+
*Rejected:* QtWebEngine for everything (correct, but ~150 MB and a render process per view, for a default path that does not need it); `QTextDocument` only (a lot of mail renders visibly wrong, with no recourse); plain-text first (safest and most retro-appropriate, but the switch gets used constantly).
|
|
152
|
+
|
|
153
|
+
## D-10 — Feature scope: address book and filters, not calendar or crypto
|
|
154
|
+
|
|
155
|
+
**Decided 2026-10-20.** In scope: a local **address book** with recipient autocomplete, and **filters, rules and junk control**. Out of scope: calendar, CalDAV, CardDAV, OpenPGP and S/MIME. See [non-goals](01-goals.md#14-non-goals).
|
|
156
|
+
|
|
157
|
+
## D-11 — Protocols: IMAP, POP3, SMTP, JMAP, ManageSieve, local mailboxes
|
|
158
|
+
|
|
159
|
+
**Decided 2026-10-20.** Beyond the required IMAP/IMAPS/POP3/SMTP, Hoarse also implements **JMAP**, **ManageSieve** (RFC 5804), and streaming **import and export** of mbox, Maildir and `.eml`, including Thunderbird profile import. Exchange EWS and Microsoft Graph are out of scope.
|
|
160
|
+
|
|
161
|
+
JMAP is a second account driver behind the same interface, with no MIME-level sync logic at all — `Email/changes` returns exactly what changed. Fastmail, Stalwart, Cyrus and Apache James speak it, and Thunderbird is rolling it out. ManageSieve is small, well-specified, and means filters keep running when Hoarse is closed. Import is the migration path: without it, nobody with a million messages can switch.
|
|
162
|
+
|
|
163
|
+
## D-12 — OAuth2: the client is implemented, the registration is the user's
|
|
164
|
+
|
|
165
|
+
**Decided 2026-10-20.** Hoarse implements OAuth2 fully — loopback redirect with PKCE (S256), the device-code flow, refresh-ahead, `XOAUTH2` and `OAUTHBEARER` — but ships **no client IDs**. The user registers their own application with Google or Microsoft and pastes in the client ID.
|
|
166
|
+
|
|
167
|
+
Google's full-mail IMAP scope is a *restricted* scope: a publicly distributed client ID requires app verification including a third-party security assessment, with real cost and real calendar time before a first release. An open-source client also cannot keep a client secret secret. Pushing registration to the user removes the whole problem and leaves Hoarse's release schedule under its own control.
|
|
168
|
+
|
|
169
|
+
**This is a real usability cost** and must be mitigated by documentation, not hidden: the setup wizard walks through registration step by step, per provider, with direct links.
|
|
170
|
+
|
|
171
|
+
*Rejected:* shipping our own IDs for both (best onboarding, gated on Google verification); shipping Microsoft's only (half a solution with two code paths); app passwords only (Microsoft 365 is unreachable, as basic auth for IMAP and POP is already disabled there).
|
|
172
|
+
|
|
173
|
+
**Revisit** once Hoarse has a stable release and a maintainer willing to own the verification process.
|
|
174
|
+
|
|
175
|
+
## D-13 — Theming: declarative theme packs, no Python `QStyle`
|
|
176
|
+
|
|
177
|
+
**Decided 2026-10-20.** Appearance comes from declarative theme packs: palette, metrics, borders, icons, fonts, density. The engine turns a pack into a `QPalette`, a generated stylesheet, an icon set and a set of precomputed delegate parameters.
|
|
178
|
+
|
|
179
|
+
Hoarse MUST NOT subclass `QStyle` or `QProxyStyle` in Python on any painting path. Every virtual call crosses the C++/Python boundary, once per primitive, per widget, per repaint — which is precisely the budget the message list needs. Custom painting is confined to a small, known set of delegates and widget subclasses that read pre-resolved theme values.
|
|
180
|
+
|
|
181
|
+
*Rejected:* theme packs plus full period chrome (frameless windows, drawn title bars, bitmap fonts — a much larger painting surface and a per-theme golden-image suite); native-first with only a density setting (does not deliver the retro half of the brief); stylesheets alone (QSS cannot change pixel metrics or primitive painting, so "retro" would be a colour scheme).
|
|
182
|
+
|
|
183
|
+
**Revisit** for period chrome once the theme engine is stable and the golden-image infrastructure exists.
|
|
184
|
+
|
|
185
|
+
## D-14 — Accounts are strictly separate
|
|
186
|
+
|
|
187
|
+
**Decided 2026-10-20.** Each account has its own folder tree, its own database files, and its own views. There are **no cross-account unified folders**.
|
|
188
|
+
|
|
189
|
+
This is a performance decision as much as a UI one. Every message-list query hits exactly one account's indexes with no merging, which keeps the hardest query shape in the application — an ordered window over a million rows — on a single covering index. It also means an account can be added, removed, resynchronised or corrupted without touching any other.
|
|
190
|
+
|
|
191
|
+
*Rejected:* Thunderbird's per-account trees plus optional unified folders (familiar, but every unified view is a merged cursor across N indexes); unified-first (modern, but makes per-account operations awkward and every query a merge).
|
|
192
|
+
|
|
193
|
+
**Consequence:** search is per account too. The address book is the one genuinely global store, and it holds no messages.
|
|
194
|
+
|
|
195
|
+
## D-15 — Python 3.14 floor, 3.15 as the recommended runtime
|
|
196
|
+
|
|
197
|
+
**Decided 2026-10-20.** `requires-python = ">=3.14"`. Hoarse tracks 3.15 as the recommended runtime and MUST pass CI on both.
|
|
198
|
+
|
|
199
|
+
3.14 brings `compression.zstd` into the standard library (PEP 784) — under [D-02](#d-02) that is the only way to get zstd at all, and it is a markedly better ratio and speed than zlib for body blobs. It also brings `imaplib.IMAP4.idle()`, the best interpreter performance to date, and officially supported free-threading, which the engine process can use even though the UI process cannot. Because `uv` provisions the interpreter, a high floor costs users nothing.
|
|
200
|
+
|
|
201
|
+
Verified on CPython 3.14.4: `compression.zstd` present; SQLite 3.46.1 with FTS5, `contentless_delete=1`, the `trigram` tokenizer and `fts5vocab`.
|
|
202
|
+
|
|
203
|
+
## D-16 — Distribution: `uv` first, native installers later
|
|
204
|
+
|
|
205
|
+
**Decided 2026-10-20.** v1 ships as a `uv`-managed install — `uvx hoarse` or `uv tool install hoarse` — which pulls the right interpreter and the Qt wheels on all three platforms. Native installers are a later milestone ([M8](15-roadmap.md)).
|
|
206
|
+
|
|
207
|
+
Under [D-02](#d-02) there is nothing to compile, so `uv` alone is a genuinely complete installation story for the audience Hoarse is aimed at. Shipping installers from day one would mean a three-OS build matrix, code-signing certificates and notarisation before the application is even stable.
|
|
208
|
+
|
|
209
|
+
*Rejected:* PyInstaller bundles alongside `uv` from the start; Briefcase; `uv` as the only channel, forever.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Assumptions
|
|
214
|
+
|
|
215
|
+
Assumptions are not decisions: they are things taken as true that nobody has confirmed. Each one names what would falsify it.
|
|
216
|
+
|
|
217
|
+
| ID | Assumption | Falsified by |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `A-01` | The design target is **one account over 1,000,000 messages**, with a handful of smaller accounts alongside it. Totals beyond ~3M messages across all accounts are out of scope for the performance budgets. | A user corpus materially larger than this |
|
|
220
|
+
| `A-02` | Hoarse's own licence is not yet fixed. PySide6's LGPLv3 works for either an open-source or a proprietary licence, so no decision is forced. | A licence choice that conflicts with LGPLv3 dynamic-linking terms |
|
|
221
|
+
| `A-03` | Target servers are Dovecot, Cyrus, Gmail, Microsoft 365, Stalwart and Zimbra. QRESYNC is assumed present on Dovecot, Cyrus and Stalwart, and absent on Gmail (which has `X-GM-*` instead). | The capability survey in [M2](15-roadmap.md) |
|
|
222
|
+
| `A-04` | The pure-Python HTML sanitiser can keep up with rendering one message at a time (budget `PRF-7`), since it never runs over a million messages at once. | A benchmark over a real mail corpus |
|
|
223
|
+
| `A-05` | `keyring` is available and working on the great majority of target systems; the encrypted-file vault is a fallback, not the main path. | Field reports from Linux users without a Secret Service provider |
|
|
224
|
+
| `A-06` | A million-row account fits comfortably in `meta.db` at roughly 0.5–0.8 GB plus 0.2–0.4 GB of header index. | The `tools/corpus` census in [M1](15-roadmap.md) |
|
|
225
|
+
| `A-07` | **Resolved 2026-10-20.** `cryptography` and `cffi` are admitted to the compiled allowlist; `keyring` with `secretstorage` is the credential path on Linux. See the note above. | — |
|