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>
This commit is contained in:
2026-10-12 19:25:10 +02:00
co-authored by Claude Opus 5.5
parent d45fbf2c44
commit b88855931e
62 changed files with 2427 additions and 188 deletions
+14 -5
View File
@@ -131,6 +131,10 @@ class Account(Base):
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("''"))
@@ -161,9 +165,12 @@ class Mailbox(Base):
)
path: Mapped[str] = mapped_column(Text, nullable=False)
special_use: Mapped[str | None] = mapped_column(Text)
uidvalidity: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
highestmodseq: Mapped[int | None] = mapped_column(Integer)
last_seen_uid: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
# 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):
@@ -214,7 +221,7 @@ class Message(Base):
restore_mailbox_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="SET NULL")
)
uid: Mapped[int] = mapped_column(Integer, nullable=False)
uid: Mapped[int] = mapped_column(BigInteger, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("threads.id")
)
@@ -321,7 +328,7 @@ class Draft(Base):
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(Integer)
imap_uid: Mapped[int | None] = mapped_column(BigInteger)
updated_at: Mapped[dt.datetime] = _now()
@@ -367,6 +374,8 @@ class UserAppPassword(Base):
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):