"""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