Files
webmail-client/backend/app/identity/app_passwords.py
T
lukasandClaude Opus 5.5 b88855931e 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>
2026-10-12 19:25:10 +02:00

141 lines
5.1 KiB
Python

"""App passwords: one generated password per mail app (SPEC App. D §D.5).
A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox
over IMAP/SMTP, and a calendar or contacts app to the DAV server — never with
the account password (no 2FA code can be asked for there), always with one of
these. Each gets a password of its own, shown once, that can be
revoked without touching the main password or the other apps. The webmail
itself never accepts one.
With hosted mailboxes on, the mail server makes the password (``app_`` and 36
more letters and digits: its format names the credential) and checks it for
IMAP/SMTP itself; its argon2id hash is kept here too, for the DAV server, with
the server's id to revoke it there. Without, it is 16 random letters, for DAV
only. Either way the hash is of the bare password: apps send exactly that, so
it is copied and entered without the spaces it is shown with.
"""
from __future__ import annotations
import secrets
import string
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app import hosted
from app.identity.passwords import hash_password, verify_password
from app.models import User, UserAppPassword
LENGTH = 16 # 26^16 ≈ 2^75
SERVER_LENGTH = 40 # the mail server's: "app_" + base32 of a 4-byte id and 18 random bytes
MAX_PER_USER = 25
NAME_MAX = 60
_ALPHABET = string.ascii_lowercase
def generation_key(user_id: uuid.UUID) -> str:
"""Redis counter bumped whenever the user's app passwords change: DAV
sign-ins cached under the old value stop counting (app/dav/auth.py)."""
return f"apppw:gen:{user_id}"
async def _changed(user_id: uuid.UUID) -> None:
from app.redis_client import get_redis
await get_redis().incr(generation_key(user_id))
class TooMany(Exception):
"""The user already has MAX_PER_USER app passwords."""
def generate() -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(LENGTH))
async def list_for(db: AsyncSession, user_id: uuid.UUID) -> list[UserAppPassword]:
rows = await db.scalars(
select(UserAppPassword)
.where(UserAppPassword.user_id == user_id)
.order_by(UserAppPassword.created_at.desc())
)
return list(rows)
async def create(db: AsyncSession, user_id: uuid.UUID, name: str) -> tuple[UserAppPassword, str]:
"""A new app password named ``name``; returns the row and the password
itself, which is never available again."""
count = await db.scalar(
select(func.count()).select_from(UserAppPassword).where(UserAppPassword.user_id == user_id)
)
if (count or 0) >= MAX_PER_USER:
raise TooMany
name = name.strip()[:NAME_MAX]
server_id: str | None = None
if hosted.enabled():
from app.hosted import admin as mail_admin
handle = await db.scalar(select(User.handle).where(User.id == user_id))
server_id, password = await mail_admin.create_app_password(str(handle), name)
else:
password = generate()
row = UserAppPassword(
user_id=user_id, name=name, password_hash=hash_password(password), server_id=server_id
)
db.add(row)
await db.commit()
return row, password
async def _revoke_on_server(
db: AsyncSession, user_id: uuid.UUID, rows: list[UserAppPassword]
) -> None:
"""Revoke on the mail server too (first: if it cannot be reached, the
password keeps working there, so nothing is revoked here either)."""
server_ids = [r.server_id for r in rows if r.server_id]
if server_ids and hosted.enabled():
from app.hosted import admin as mail_admin
handle = await db.scalar(select(User.handle).where(User.id == user_id))
await mail_admin.delete_app_passwords(str(handle), server_ids)
async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool:
rows = list(
await db.scalars(
select(UserAppPassword).where(
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
)
)
)
await _revoke_on_server(db, user_id, rows)
result = await db.execute(
delete(UserAppPassword).where(
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
)
)
await db.commit()
await _changed(user_id)
return bool(result.rowcount) # type: ignore[attr-defined]
async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
"""Every app signs in again (the main password was reset)."""
await _revoke_on_server(db, user_id, await list_for(db, user_id))
await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id))
await db.commit()
await _changed(user_id)
async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None:
"""The app password ``password`` matches, if any — for the mail server's
sign-in and for tests; never for the webmail's own sign-in."""
if len(password) not in (LENGTH, SERVER_LENGTH):
return None
for row in await list_for(db, user_id):
if verify_password(row.password_hash, password):
return row
return None