M6 hosted mailboxes (off by default); dark message mode; fixes
Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)
Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings
Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,9 @@ decoded on its own). It is taken out of the raw message when wanted:
|
||||
* :func:`dedupe_existing` — the one-time pass removing the separate copies
|
||||
stored before, each only after checking it can be taken out of its message.
|
||||
|
||||
Blocking (parsing, disk): call these in a thread.
|
||||
Blocking (parsing, disk): call these in a thread — after
|
||||
:func:`app.indexer.rawstore.ensure` for the message, which fetches a hosted
|
||||
mailbox's message from its mail server when it is not cached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +25,7 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from app.indexer import blobstore
|
||||
from app.indexer import blobstore, rawstore
|
||||
from app.indexer.mime import load_message, part_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,8 +47,8 @@ def load(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None)
|
||||
legacy = _legacy(user_id, sha)
|
||||
if legacy is not None:
|
||||
return legacy.read_bytes()
|
||||
raw = blobstore.blob_path(user_id, message_sha)
|
||||
if part_index == NOT_IN_MESSAGE or not raw.exists():
|
||||
raw = rawstore.locate(user_id, message_sha)
|
||||
if part_index == NOT_IN_MESSAGE or raw is None:
|
||||
raise FileNotFoundError(sha)
|
||||
payload = part_payload(load_message(path=raw), part_index, sha)
|
||||
if payload is None:
|
||||
@@ -82,6 +84,11 @@ def prune_cache(cache: Path) -> None:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def has_file(user_id: uuid.UUID, sha: str) -> bool:
|
||||
"""Whether :func:`file` can answer without reading the message."""
|
||||
return _legacy(user_id, sha) is not None or (_cache_dir(user_id) / sha).exists()
|
||||
|
||||
|
||||
def file(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> Path:
|
||||
"""A file holding the attachment, to stream from. Raises FileNotFoundError."""
|
||||
legacy = _legacy(user_id, sha)
|
||||
@@ -167,9 +174,9 @@ async def dedupe_existing(batch: int = 200, *, user_id: uuid.UUID | None = None)
|
||||
|
||||
located: dict[uuid.UUID, int] = {}
|
||||
for (owner, msg_sha), atts in by_message.items():
|
||||
raw = blobstore.blob_path(owner, msg_sha)
|
||||
raw = rawstore.locate(owner, msg_sha)
|
||||
try:
|
||||
indexes = await asyncio.to_thread(_part_indexes, raw) if raw.exists() else {}
|
||||
indexes = await asyncio.to_thread(_part_indexes, raw) if raw is not None else {}
|
||||
except Exception: # noqa: BLE001 - unreadable message: keep the copies
|
||||
logger.warning("attachments: cannot parse %s", raw, exc_info=True)
|
||||
indexes = {}
|
||||
|
||||
Reference in New Issue
Block a user