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>
141 lines
5.1 KiB
Python
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
|