Files

155 lines
5.3 KiB
Python
Raw Permalink Normal View History

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