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:
@@ -0,0 +1,154 @@
|
||||
"""The user's own mailbox as a webmail account (SPEC App. D §D.4).
|
||||
|
||||
Every user has one ``kind = 'native'`` account: their handle's mailbox on the
|
||||
hosted mail server. It is created with the user (and, for users from before,
|
||||
by :func:`ensure_all`, run by provisioning), cannot be removed, and its server
|
||||
settings are not the user's to change.
|
||||
|
||||
No password of the user's is stored for it: the worker signs in as itself,
|
||||
acting for the user (``user@domain%webmail-worker``, §D.3), over the Docker
|
||||
network (or loopback in dev) — TLS, with the certificate left unchecked since
|
||||
the traffic never leaves the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.hosted import enabled, worker_address
|
||||
from app.models import Account, User
|
||||
from app.providers.imap_smtp import ProviderConfig
|
||||
from app.security.crypto import encrypt_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KIND = "native"
|
||||
|
||||
|
||||
def is_native(account: Account) -> bool:
|
||||
return account.kind == KIND
|
||||
|
||||
|
||||
def _server(host: str, port: int) -> dict[str, object]:
|
||||
return {"host": host, "port": port, "tls": "ssl", "auth": "password"}
|
||||
|
||||
|
||||
def internal_tls() -> ssl.SSLContext:
|
||||
"""TLS to the mail server on the internal network: its certificate is for
|
||||
its public name (or self-signed), not the container's."""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def native_provider_config(email: str) -> ProviderConfig:
|
||||
"""IMAP/SMTP to the user's mailbox, signed in as the worker acting for them.
|
||||
|
||||
Read from the settings, not the account row: the mail server's address is
|
||||
the deployment's, and may change."""
|
||||
settings = get_settings()
|
||||
return ProviderConfig(
|
||||
imap_host=settings.stalwart_imap_host,
|
||||
imap_port=settings.stalwart_imap_port,
|
||||
imap_tls="ssl",
|
||||
smtp_host=settings.stalwart_smtp_host,
|
||||
smtp_port=settings.stalwart_smtp_port,
|
||||
smtp_tls="ssl",
|
||||
username=f"{email}%{worker_address()}",
|
||||
password=settings.stalwart_master_secret,
|
||||
tls_context=internal_tls(),
|
||||
internal=True,
|
||||
)
|
||||
|
||||
|
||||
async def native_account(db: AsyncSession, user_id: uuid.UUID) -> Account | None:
|
||||
account: Account | None = await db.scalar(
|
||||
select(Account).where(Account.user_id == user_id, Account.kind == KIND)
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
async def ensure(db: AsyncSession, user: User) -> Account | None:
|
||||
"""The user's native account, created (and its first sync queued by the
|
||||
caller, see :func:`ensure_and_sync`) if missing. None when hosted
|
||||
mailboxes are off. Flushes, does not commit."""
|
||||
if not enabled():
|
||||
return None
|
||||
existing = await native_account(db, user.id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# An account for the same address added by hand: the same address at
|
||||
# another provider (the mail before it moved here — e.g. while the domain
|
||||
# still points elsewhere). It stays as it is; the mailbox account is added
|
||||
# once it is removed (app/api/accounts.py).
|
||||
same = await db.scalar(
|
||||
select(Account.id).where(Account.user_id == user.id, Account.email == user.handle)
|
||||
)
|
||||
if same is not None:
|
||||
logger.info("hosted: %s already has an account for its address; not added", user.handle)
|
||||
return None
|
||||
settings = get_settings()
|
||||
imap = _server(settings.stalwart_imap_host, settings.stalwart_imap_port)
|
||||
smtp = _server(settings.stalwart_smtp_host, settings.stalwart_smtp_port)
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email=user.handle,
|
||||
kind=KIND,
|
||||
imap_config=imap,
|
||||
smtp_config=smtp,
|
||||
secret_ct=encrypt_secret(b""), # nothing: the worker signs in as itself
|
||||
discovery_src="hosted",
|
||||
state="backfilling",
|
||||
sync_window="all",
|
||||
# The mail server's own spam filter files into Junk: show it.
|
||||
sync_spam=True,
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
return account
|
||||
|
||||
|
||||
async def ensure_and_sync(db: AsyncSession, user: User) -> Account | None:
|
||||
""":func:`ensure`, committed, and the account's first sync queued if new."""
|
||||
before = await native_account(db, user.id)
|
||||
account = await ensure(db, user)
|
||||
await db.commit()
|
||||
if account is not None and before is None:
|
||||
from app.queue import enqueue_job
|
||||
|
||||
await enqueue_job("sync_bootstrap_account", str(account.id))
|
||||
return account
|
||||
|
||||
|
||||
async def ensure_all() -> int:
|
||||
"""Give every user without one their native account (users from before
|
||||
hosted mailboxes). Returns how many were created."""
|
||||
from app.db import session_scope
|
||||
|
||||
created = 0
|
||||
async with session_scope() as db:
|
||||
users = list(
|
||||
await db.scalars(
|
||||
select(User).where(
|
||||
~select(Account.id)
|
||||
.where(Account.user_id == User.id, Account.kind == KIND)
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
)
|
||||
for user in users:
|
||||
async with session_scope() as db:
|
||||
fresh = await db.get(User, user.id)
|
||||
if fresh is None:
|
||||
continue
|
||||
await ensure_and_sync(db, fresh)
|
||||
created += 1
|
||||
logger.info("hosted: mailbox account for %s", fresh.handle)
|
||||
return created
|
||||
Reference in New Issue
Block a user