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:
@@ -11,7 +11,12 @@ that for every user, whatever the way the rows went:
|
||||
a sent message (its draft is gone) or a composer closed without saving;
|
||||
* message/attachment files no row refers to;
|
||||
* attachment previews (thumbnails) of attachments that are gone;
|
||||
* streaming copies past their keep time (app/indexer/attachments.py).
|
||||
* streaming copies past their keep time (app/indexer/attachments.py);
|
||||
* cached raw messages of a hosted mailbox beyond the cache's room
|
||||
(app/indexer/rawstore.py).
|
||||
|
||||
It also recounts threads whose unread/message counters disagree with their
|
||||
messages, and drops threads left empty (app/threading_/threader.py).
|
||||
|
||||
A file younger than an hour is left alone: it may belong to work in progress
|
||||
(a message being stored, a sent message waiting for its Sent copy).
|
||||
@@ -31,6 +36,7 @@ from sqlalchemy import delete, or_, select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.indexer import attachments as att_store
|
||||
from app.indexer import rawstore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,9 +100,9 @@ def _old(path: Path, now: float) -> bool:
|
||||
async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
||||
"""Clean one user's stored files; returns what was removed."""
|
||||
from app.db import session_scope
|
||||
from app.models import Attachment, ComposeAttachment, Draft, Message
|
||||
from app.models import Account, Attachment, ComposeAttachment, Draft, Message
|
||||
|
||||
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0}
|
||||
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
|
||||
async with session_scope() as db:
|
||||
# Uploads no draft refers to, once a week old.
|
||||
used = _draft_upload_ids(
|
||||
@@ -116,8 +122,14 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
||||
async with session_scope() as db:
|
||||
# Every file still referred to. Attachments count only where their file
|
||||
# is their only copy (not found in the message, or not yet looked for).
|
||||
# A hosted mailbox's messages are kept by its mail server: their raw
|
||||
# file here (left by a Sent copy filed late) is not needed.
|
||||
referenced = set(
|
||||
await db.scalars(select(Message.blob_sha256).where(Message.user_id == user_id))
|
||||
await db.scalars(
|
||||
select(Message.blob_sha256)
|
||||
.join(Account, Account.id == Message.account_id)
|
||||
.where(Message.user_id == user_id, Account.kind != "native")
|
||||
)
|
||||
)
|
||||
referenced |= set(
|
||||
await db.scalars(
|
||||
@@ -140,6 +152,12 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
||||
await db.scalars(select(Attachment.blob_sha256).where(Attachment.user_id == user_id))
|
||||
)
|
||||
|
||||
# Counters out of step with their messages (see repair_threads).
|
||||
from app.threading_.threader import repair_threads
|
||||
|
||||
async with session_scope() as db:
|
||||
stats["threads_repaired"] = await repair_threads(db, user_id)
|
||||
|
||||
root = Path(get_settings().blob_root) / str(user_id)
|
||||
if not root.is_dir():
|
||||
return stats
|
||||
@@ -168,6 +186,7 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
||||
cache = root / "cache"
|
||||
if cache.is_dir():
|
||||
att_store.prune_cache(cache)
|
||||
rawstore.prune(user_id)
|
||||
return stats
|
||||
|
||||
|
||||
@@ -178,7 +197,7 @@ async def sweep(user_id: uuid.UUID | None = None) -> dict[str, int]:
|
||||
|
||||
async with session_scope() as db:
|
||||
users = [user_id] if user_id is not None else list(await db.scalars(select(User.id)))
|
||||
total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0}
|
||||
total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
|
||||
for uid in users:
|
||||
try:
|
||||
stats = await sweep_user(uid)
|
||||
|
||||
Reference in New Issue
Block a user