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:
2026-10-12 19:25:10 +02:00
co-authored by Claude Opus 5.5
parent d45fbf2c44
commit b88855931e
62 changed files with 2427 additions and 188 deletions
+31 -5
View File
@@ -132,6 +132,12 @@ class ProviderConfig:
smtp_tls: str # "ssl" | "starttls" | "plain"
username: str
password: str # plaintext; caller decrypts per §10.2
# TLS settings other than the default (certificate checked): the hosted
# mail server on the internal network (app/hosted/native.py).
tls_context: ssl.SSLContext | None = None
# The hosted mail server: every user's mailbox is on that one host, so the
# per-host connection cap (which spares outside providers) does not apply.
internal: bool = False
class ImapSmtpProvider(MailProvider):
@@ -169,8 +175,11 @@ class ImapSmtpProvider(MailProvider):
account = loop_semaphore(
"imap_account", f"{cfg.username}@{cfg.imap_host}", _PER_ACCOUNT_CONN_CAP
)
host = loop_semaphore("imap_host", cfg.imap_host, get_settings().per_host_conn_cap)
await account.acquire() # consistent order (account, then host) avoids deadlock
if cfg.internal:
self._conn_slots = [account]
return
host = loop_semaphore("imap_host", cfg.imap_host, get_settings().per_host_conn_cap)
try:
await host.acquire()
except BaseException:
@@ -193,7 +202,10 @@ class ImapSmtpProvider(MailProvider):
imap: aioimaplib.IMAP4
if cfg.imap_tls == "ssl":
imap = _IMAP4_SSL(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
host=cfg.imap_host,
port=cfg.imap_port,
timeout=_CONNECT_TIMEOUT,
ssl_context=cfg.tls_context,
)
else:
imap = _IMAP4(host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT)
@@ -356,6 +368,15 @@ class ImapSmtpProvider(MailProvider):
resp = await imap.uid_search("ALL")
return set(parse_search_uids(resp.lines))
async def fetch_raw(self, mailbox: str, uid: int) -> bytes | None:
"""One message's raw bytes, or None if it is not (or no longer) there."""
await self._select(mailbox)
imap = await self._connect()
for message in await self._fetch_messages(imap, [uid]):
if message.uid == uid:
return message.raw
return None
async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]:
if not uids:
return []
@@ -474,12 +495,12 @@ class ImapSmtpProvider(MailProvider):
"""Create a mailbox on the server (idempotent — ignores 'already exists')."""
imap = await self._connect()
with contextlib.suppress(Exception):
await imap.create(path)
await imap.create(self._wire(path))
async def move(self, uid: int, src: str, dst: str) -> int:
await self._select(src)
imap = await self._connect()
resp = await imap.uid("move", str(uid), dst)
resp = await imap.uid("move", str(uid), self._wire(dst))
if resp.result != "OK":
raise ProviderConnectError(f"MOVE {uid} -> {dst} failed")
for line in resp.lines:
@@ -521,7 +542,11 @@ class ImapSmtpProvider(MailProvider):
cfg = self._cfg
use_tls = cfg.smtp_tls == "ssl"
start_tls = cfg.smtp_tls == "starttls"
host_slot = loop_semaphore("smtp_host", cfg.smtp_host, get_settings().per_host_conn_cap)
host_slot = (
contextlib.nullcontext()
if cfg.internal
else loop_semaphore("smtp_host", cfg.smtp_host, get_settings().per_host_conn_cap)
)
try:
async with host_slot: # bound simultaneous sockets to the SMTP host (§4)
await aiosmtplib.send(
@@ -534,6 +559,7 @@ class ImapSmtpProvider(MailProvider):
password=cfg.password,
use_tls=use_tls,
start_tls=start_tls,
tls_context=cfg.tls_context,
timeout=_CONNECT_TIMEOUT,
)
except aiosmtplib.SMTPException as exc: