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:
@@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.concurrency import loop_semaphore
|
||||
from app.config import get_settings
|
||||
from app.indexer import blobstore
|
||||
from app.indexer import blobstore, rawstore
|
||||
from app.indexer.mime import ParsedEmail, parse_message, parse_message_file
|
||||
from app.indexer.sanitize import sanitize_html
|
||||
from app.models import Attachment, BodyPart, KnownRecipient, Message
|
||||
from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.search.base import SearchDoc
|
||||
from app.threading_.normalize import synthesize_message_id
|
||||
@@ -118,6 +118,13 @@ async def _resolve_message_id(
|
||||
return parsed_id
|
||||
|
||||
|
||||
async def _keeps_raw(session: AsyncSession, account_id: uuid.UUID) -> bool:
|
||||
"""Whether the raw message is kept for good: not for a hosted mailbox, whose
|
||||
mail server holds it (it is only cached, App. D §D.6 item 2)."""
|
||||
kind = await session.scalar(select(Account.kind).where(Account.id == account_id))
|
||||
return kind != "native"
|
||||
|
||||
|
||||
async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail:
|
||||
"""Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7).
|
||||
|
||||
@@ -128,8 +135,9 @@ async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmai
|
||||
settings = get_settings()
|
||||
threshold = settings.stream_parse_mb * 1024 * 1024
|
||||
async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency):
|
||||
if len(raw) > threshold:
|
||||
return parse_message_file(blobstore.blob_path(user_id, sha))
|
||||
path = rawstore.locate(user_id, sha) if len(raw) > threshold else None
|
||||
if path is not None:
|
||||
return parse_message_file(path)
|
||||
return parse_message(raw)
|
||||
|
||||
|
||||
@@ -154,7 +162,10 @@ async def persist_message(
|
||||
if existing is not None:
|
||||
return None
|
||||
|
||||
sha = blobstore.store(user_id, remote.raw)
|
||||
if await _keeps_raw(session, account_id):
|
||||
sha = blobstore.store(user_id, remote.raw)
|
||||
else:
|
||||
sha = rawstore.put_cached(user_id, remote.raw)
|
||||
parsed = await _parse_bounded(user_id, sha, remote.raw)
|
||||
|
||||
message_id_hdr = await _resolve_message_id(
|
||||
|
||||
Reference in New Issue
Block a user