Files
webmail-client/backend/app/models.py
T
lukasandClaude Opus 5.5 addb2232a1 Security hardening from the assessment; testing fixes
Security (ultimo-security-report.md):
- Security headers on every response, the app shell included: they lived at
  server level and nginx dropped them in any location with its own header.
  CSP, framing, nosniff, referrer, permissions (docker/nginx-security.conf).
- Plain HTTP forwarded by the reverse proxy is redirected to HTTPS; HSTS on
  HTTPS responses. server_tokens off.
- Cross-site state-changing /api requests refused (login CSRF), WebSocket too.
- Validation errors in the usual error shape, without the request model.
- New-password policy: max 128, common/own-address/repeated refused,
  breached-password check (HIBP k-anonymity, fails open).
- Message HTML: links keep only http(s)/mailto (sanitizer v6); the HTML
  endpoint carries its own no-script CSP.
- Outbound fetches pinned to the checked IP (DNS rebinding); external
  IMAP/SMTP servers re-checked before each connection; CGNAT and mapped
  addresses counted as non-public.

Fixes from testing:
- IDLE loop catches up before idling: mail that came while the worker was
  away no longer waits up to 10 minutes.
- Restore from Spam/Trash/Archive moves only the removed messages, not your
  reply in Sent.
- Narrow desktop list stacks sender/subject/snippet (subject was squeezed
  to nothing at the default width), with hover actions.
- Phone: wide emails scale to fit the screen.
- Singular forms ("1 message"); no zip button for a single attachment;
  app passwords no longer claim "never used" for mail apps.
- ruff format across the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:08:06 +02:00

451 lines
20 KiB
Python

"""SQLAlchemy ORM models mirroring the authoritative DDL in SPEC §4.
The Alembic initial migration is generated to match these models exactly.
Invariant I-7: every table carrying user data has an indexed ``user_id``.
"""
from __future__ import annotations
import datetime as dt
import uuid
from typing import Any
from sqlalchemy import (
BigInteger,
CheckConstraint,
ForeignKey,
Index,
Integer,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import (
ARRAY,
BYTEA,
CITEXT,
JSONB,
TIMESTAMP,
TSVECTOR,
UUID,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
def _uuid_pk() -> Mapped[uuid.UUID]:
return mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
def _now() -> Mapped[dt.datetime]:
return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()"))
class AppSettings(Base):
"""Instance-wide settings, exactly one row (id = 1).
Distinct from :func:`app.config.get_settings`, which is deployment environment
read at boot; these are options an administrator changes at runtime from the
admin panel. Locking this row serialises the registration decision below.
"""
__tablename__ = "app_settings"
__table_args__ = (
CheckConstraint("id = 1", name="ck_app_settings_singleton"),
CheckConstraint("default_quota_bytes > 0", name="ck_app_settings_quota_positive"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
# Whether POST /auth/register accepts new users. Closed automatically once the
# first account (the administrator) is created; an admin reopens it later.
registration_open: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
# Quota handed to the NEXT user created. Existing users keep whatever
# users.quota_bytes already says — changing this never resizes a mailbox.
default_quota_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("5368709120")
)
# Outgoing system mail (password-reset links): the SMTP account the server
# sends from. The password is vault-encrypted (like Account.secret_ct).
smtp_host: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("587"))
smtp_security: Mapped[str] = mapped_column(
Text, nullable=False, server_default=text("'starttls'")
) # starttls | ssl | none
smtp_username: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
smtp_secret_ct: Mapped[bytes | None] = mapped_column(BYTEA)
smtp_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
# Where users reach the app, e.g. https://mail.example.com — reset links are
# built from this, never from the request's Host header.
public_url: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
# The key pair push notifications are signed with (VAPID, RFC 8292): made on
# first use; the private half vault-encrypted, the public half as browsers
# take it (base64url, uncompressed P-256 point).
vapid_public: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
vapid_private_ct: Mapped[bytes | None] = mapped_column(BYTEA)
updated_at: Mapped[dt.datetime] = _now()
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = _uuid_pk()
handle: Mapped[str] = mapped_column(CITEXT, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
totp_secret: Mapped[bytes | None] = mapped_column(BYTEA)
recovery_email: Mapped[str | None] = mapped_column(CITEXT)
settings: Mapped[dict[str, object]] = mapped_column(
JSONB, nullable=False, server_default=text("'{}'")
)
# Storage quota in bytes; per-user so an admin can override it later.
# Default 5 GiB (5 * 1024^3).
quota_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("5368709120")
)
# The first account created on a fresh install bootstraps as administrator;
# the admin panel can grant it to others later.
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
# Set while an administrator has blocked the account (no sign-in, sessions
# refused); NULL when not blocked.
blocked_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
created_at: Mapped[dt.datetime] = _now()
class Account(Base):
__tablename__ = "accounts"
__table_args__ = (UniqueConstraint("user_id", "email", name="uq_accounts_user_email"),)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
email: Mapped[str] = mapped_column(CITEXT, nullable=False)
display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
color: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'#888888'"))
# Per-account HTML signature, inserted into new messages (and via compose menu).
signature: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False)
# external: any IMAP/SMTP server (the user's password in secret_ct);
# native: the user's own mailbox on the hosted mail server — permanent, no
# stored password (SPEC App. D §D.4; app/hosted/).
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'external'"))
discovery_src: Mapped[str] = mapped_column(Text, nullable=False)
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'"))
state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
# How much history to import (SPEC §12 M2): all | 3months | new. Drives the
# initial backfill; backfill_total/backfill_done feed the progress UX.
sync_window: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'all'"))
# Whether the Spam/Junk folder is synced (opt-in; SPEC §6.3 folder selection).
sync_spam: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
backfill_total: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
backfill_done: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_sync_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
# The server has not answered for a while ("unreachable"); cleared on success.
sync_error: Mapped[str | None] = mapped_column(Text)
sync_error_since: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
created_at: Mapped[dt.datetime] = _now()
class Mailbox(Base):
__tablename__ = "mailboxes"
__table_args__ = (UniqueConstraint("account_id", "path", name="uq_mailboxes_account_path"),)
id: Mapped[uuid.UUID] = _uuid_pk()
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
path: Mapped[str] = mapped_column(Text, nullable=False)
special_use: Mapped[str | None] = mapped_column(Text)
# IMAP ids are unsigned 32-bit (MODSEQ 63-bit): bigint, not integer.
uidvalidity: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
highestmodseq: Mapped[int | None] = mapped_column(BigInteger)
last_seen_uid: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
class Thread(Base):
__tablename__ = "threads"
__table_args__ = (Index("ix_threads_user_last", "user_id", text("last_message_at DESC")),)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
subject_norm: Mapped[str] = mapped_column(Text, nullable=False)
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
participants: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
account_ids: Mapped[list[uuid.UUID]] = mapped_column(
ARRAY(UUID(as_uuid=True)), nullable=False, server_default=text("'{}'")
)
message_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
unread_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
class Message(Base):
__tablename__ = "messages"
__table_args__ = (
UniqueConstraint("mailbox_id", "uid", name="uq_messages_mailbox_uid"),
Index("ix_messages_user_thread", "user_id", "thread_id"),
Index("ix_messages_account_msgid", "account_id", "message_id_hdr"),
Index("ix_messages_user_msgid", "user_id", "message_id_hdr"),
Index("ix_messages_body_indexed", "body_indexed", postgresql_using="gin"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
# The folder this message was in before being moved to Archive/Trash/Junk, so
# Restore can return it there (e.g. Sent) instead of the Inbox. NULL otherwise.
restore_mailbox_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="SET NULL")
)
uid: Mapped[int] = mapped_column(BigInteger, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("threads.id")
)
message_id_hdr: Mapped[str | None] = mapped_column(Text)
# The real header Message-ID as delivered (kept even when message_id_hdr is
# synthesized for a cross-account duplicate, §7.1). Identifies one delivery, so
# the same email fanned out to several accounts collapses to one row in a thread.
delivery_id: Mapped[str | None] = mapped_column(Text)
in_reply_to: Mapped[str | None] = mapped_column(Text)
references_hdr: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, server_default=text("'{}'")
)
subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
from_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
to_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
cc_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
bcc_addr: Mapped[list[dict[str, str]]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'")
)
date_hdr: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
flags: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, server_default=text("'{}'")
)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
has_remote_images: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
body_indexed: Mapped[str | None] = mapped_column(TSVECTOR)
class BodyPart(Base):
__tablename__ = "body_parts"
id: Mapped[uuid.UUID] = _uuid_pk()
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(Text, nullable=False) # text | html_sanitized
content: Mapped[str] = mapped_column(Text, nullable=False)
# SPEC §10.4: re-sanitize on render if the sanitizer version changed.
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = _uuid_pk()
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
filename: Mapped[str] = mapped_column(Text, nullable=False)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
content_id: Mapped[str | None] = mapped_column(Text)
# The content's hash (names its thumbnail, checks what is taken out). The
# bytes themselves live only inside the message's raw blob, at MIME part
# ``part_index`` (NULL for rows stored before that was recorded).
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
part_index: Mapped[int | None] = mapped_column(Integer)
class ComposeAttachment(Base):
"""A file uploaded for an outgoing message, referenced by a draft's payload."""
__tablename__ = "compose_attachments"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(Text, nullable=False)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[dt.datetime] = _now()
class Draft(Base):
__tablename__ = "drafts"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("threads.id")
)
reply_to_message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id")
)
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'reply'"))
payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
# UID of the mirrored copy in the account's IMAP Drafts folder (so the draft
# is visible/continuable on other mail clients). Replaced on each edit.
imap_uid: Mapped[int | None] = mapped_column(BigInteger)
updated_at: Mapped[dt.datetime] = _now()
class Outbox(Base):
__tablename__ = "outbox"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'undo_window'"))
send_after: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
error: Mapped[str | None] = mapped_column(Text)
# Retries after a send the server could not take (0021): state "retrying".
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
next_attempt_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
retry_until: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
class SavedSearch(Base):
__tablename__ = "saved_searches"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_saved_searches_user_name"),)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
query: Mapped[str] = mapped_column(Text, nullable=False)
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true"))
class UserAppPassword(Base):
"""A password for one mail app (IMAP/SMTP), revocable on its own (§D.5)."""
__tablename__ = "user_app_passwords"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(Text, nullable=False) # "Thunderbird on laptop"
password_hash: Mapped[str] = mapped_column(Text, nullable=False) # argon2id
created_at: Mapped[dt.datetime] = _now()
# Set by the mail server's sign-in, once there is one.
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
# The hosted mail server's id of the same password (§D.5), when it has one.
server_id: Mapped[str | None] = mapped_column(Text)
class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push)."""
__tablename__ = "push_subscriptions"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# The push service URL the browser handed out; posting to it reaches the device.
endpoint: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
# The browser's keys the message is encrypted to (RFC 8291), base64url.
p256dh: Mapped[str] = mapped_column(Text, nullable=False)
auth: Mapped[str] = mapped_column(Text, nullable=False)
# The sign-in it was made under: nothing is sent once that session is gone.
session_sid: Mapped[str] = mapped_column(Text, nullable=False)
user_agent: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
created_at: Mapped[dt.datetime] = _now()
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
class PluginKv(Base):
"""Namespaced key/value store for plugins (SPEC §11 ``plugin_kv``).
Core-owned infrastructure (not a ``plg_``-prefixed plugin table): every plugin
gets a slice keyed by (plugin, user_id, key). ``value`` is arbitrary JSON.
Invariant I-7 (user-data rows carry ``user_id``) holds here too."""
__tablename__ = "plugin_kv"
plugin: Mapped[str] = mapped_column(Text, primary_key=True)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
key: Mapped[str] = mapped_column(Text, primary_key=True)
value: Mapped[Any] = mapped_column(JSONB, nullable=False)
class KnownRecipient(Base):
__tablename__ = "known_recipients"
__table_args__ = (
Index(
"ix_known_recipients_user_email",
"user_id",
text("email text_pattern_ops"),
),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
email: Mapped[str] = mapped_column(CITEXT, primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
times_used: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_used_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
__all__ = [
"Account",
"Attachment",
"BodyPart",
"Draft",
"KnownRecipient",
"Mailbox",
"Message",
"Outbox",
"SavedSearch",
"Thread",
"User",
]