- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings section, mail templates (Compose > More > Templates), a Subscriptions folder with bulk unsubscribe/delete, and tracker blocking with a Privacy toggle (on by default) - Own dialogs instead of browser popups; emoji picker in compose; compose windows survive a visit to Settings - Sentence completion learned from the user's sent mail (no AI) - Interface in English and Polish (one file per language, English as the fallback), picked from the browser on first run, then kept - Email translation on the server via LibreTranslate: Translate button and "Translate to...", messages in several languages translated part by part, names/addresses/emoji/file names/phone numbers kept, an animated line while translating - Antispam "Mark as spam" loads with the message, not when the menu opens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
570 lines
26 KiB
Python
570 lines
26 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)
|
|
# Users may invite friends (app/identity/invitations.py); the invitation
|
|
# email's From address (empty: postmaster@APP_DOMAIN), subject and HTML
|
|
# template (empty: the built-in ones).
|
|
invitations_enabled: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
|
invite_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
|
invite_subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
|
invite_template: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
|
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)
|
|
# Snooze: out of the Inbox until this time (NULL when not snoozed). A message
|
|
# that arrives after ``snoozed_at`` wakes it early; ``unsnoozed_at`` (when it
|
|
# last woke) puts it back at the top of the Inbox.
|
|
snoozed_until: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
|
snoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
|
unsnoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
|
|
|
|
|
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)
|
|
# Mailing-list headers, for the Subscriptions page. ``list_unsubscribe`` is
|
|
# NULL until read (a backfill job fills older mail), '' when there is none.
|
|
list_id: Mapped[str | None] = mapped_column(Text)
|
|
list_unsubscribe: Mapped[str | None] = mapped_column(Text)
|
|
# RFC 8058: the sender takes a one-click unsubscribe POST.
|
|
list_unsubscribe_post: Mapped[bool] = mapped_column(
|
|
nullable=False, server_default=text("false")
|
|
)
|
|
# The language of its text (app/translation.py), found the first time it is
|
|
# shown: NULL not looked at yet, '' could not tell.
|
|
content_language: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
|
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"))
|
|
# Tracking pixels taken out of this HTML (shown as "N trackers blocked").
|
|
trackers_blocked: 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 Invitation(Base):
|
|
"""A single-use link that lets one person sign up, sent by a user."""
|
|
|
|
__tablename__ = "invitations"
|
|
|
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
|
inviter_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
# sha256 of the token in the link; the token itself is only in the email.
|
|
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
|
invitee_name: Mapped[str] = mapped_column(Text, nullable=False)
|
|
invitee_email: Mapped[str] = mapped_column(Text, nullable=False)
|
|
created_at: Mapped[dt.datetime] = _now()
|
|
expires_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
|
used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
|
used_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
|
)
|
|
|
|
|
|
class SubscriptionState(Base):
|
|
"""What a user did about a bulk sender on the Subscriptions page."""
|
|
|
|
__tablename__ = "subscription_states"
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
# The sender group: "list:<List-Id>" or "from:<address>".
|
|
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
|
unsubscribed_at: Mapped[dt.datetime] = _now()
|
|
method: Mapped[str] = mapped_column(Text, nullable=False) # one_click | mailto | web
|
|
|
|
|
|
class MailTemplate(Base):
|
|
"""A saved reply, inserted from the compose window."""
|
|
|
|
__tablename__ = "mail_templates"
|
|
|
|
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)
|
|
subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
|
body_html: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
|
created_at: Mapped[dt.datetime] = _now()
|
|
updated_at: Mapped[dt.datetime] = _now()
|
|
|
|
|
|
class MessageTranslation(Base):
|
|
"""A message's HTML translated into a language (app/translation.py), kept so
|
|
it is translated once. ``images``: with remote images shown or not."""
|
|
|
|
__tablename__ = "message_translations"
|
|
|
|
message_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
target: Mapped[str] = mapped_column(Text, primary_key=True)
|
|
images: Mapped[bool] = mapped_column(primary_key=True)
|
|
source: Mapped[str] = mapped_column(Text, nullable=False)
|
|
html: Mapped[str] = mapped_column(Text, nullable=False)
|
|
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
created_at: Mapped[dt.datetime] = _now()
|
|
|
|
|
|
class CompletionPhrase(Base):
|
|
"""Sentence completion (app/completion.py): after these words, the user
|
|
usually writes this — seen ``count`` times in their sent mail."""
|
|
|
|
__tablename__ = "completion_phrases"
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
context: Mapped[str] = mapped_column(Text, primary_key=True) # normalized words
|
|
continuation: Mapped[str] = mapped_column(Text, primary_key=True) # as written
|
|
count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
|
|
|
class CompletionIndexState(Base):
|
|
"""When a user's completion index was last built."""
|
|
|
|
__tablename__ = "completion_index_state"
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
built_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
|
|
|
|
|
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",
|
|
]
|