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>
155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
"""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
|