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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user