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
@@ -0,0 +1,41 @@
"""antispam: which accounts are filtered, said explicitly
``enabled_accounts`` was "empty = every account", so turning every account off
could not be stored: it read back as all on. Now NULL is every account (the
default, including accounts added later), a list is exactly those accounts, and
an empty list is none. Existing empty lists were "every account": they become
NULL.
Revision ID: antispam0002
Revises: antispam0001
Create Date: 2026-10-11
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "antispam0002"
down_revision: str | None = "antispam0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts DROP NOT NULL")
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts DROP DEFAULT")
op.execute(
"UPDATE plg_antispam_settings SET enabled_accounts = NULL WHERE enabled_accounts = '{}'"
)
def downgrade() -> None:
op.execute(
"UPDATE plg_antispam_settings SET enabled_accounts = '{}' WHERE enabled_accounts IS NULL"
)
op.execute(
"ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts SET DEFAULT '{}'"
)
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts SET NOT NULL")
@@ -18,7 +18,7 @@ _SDK = sdk("antispam")
# §C.3 defaults, used when a user has never opened the settings page.
DEFAULTS = {
"enabled_accounts": [], # empty = every account
"enabled_accounts": None, # every account
"move_threshold": 12.0,
"banner_threshold": 6.0,
"network_checks": False,
@@ -28,15 +28,16 @@ DEFAULTS = {
@dataclass(frozen=True)
class Settings:
enabled_accounts: list[str]
# None: every account (also ones added later); a list: exactly those —
# empty, none.
enabled_accounts: list[str] | None
move_threshold: float
banner_threshold: float
network_checks: bool
learn_from_provider_junk: bool
def covers(self, account_id: str) -> bool:
"""Empty list means every account — the common case stays cheap."""
return not self.enabled_accounts or account_id in self.enabled_accounts
return self.enabled_accounts is None or account_id in self.enabled_accounts
async def load_settings(user_id: uuid.UUID) -> Settings:
@@ -54,14 +55,14 @@ async def load_settings(user_id: uuid.UUID) -> Settings:
if row is None:
d: Any = DEFAULTS
return Settings(
enabled_accounts=[],
enabled_accounts=None,
move_threshold=d["move_threshold"],
banner_threshold=d["banner_threshold"],
network_checks=d["network_checks"],
learn_from_provider_junk=d["learn_from_provider_junk"],
)
return Settings(
enabled_accounts=[str(a) for a in (row[0] or [])],
enabled_accounts=None if row[0] is None else [str(a) for a in row[0]],
move_threshold=float(row[1]),
banner_threshold=float(row[2]),
network_checks=bool(row[3]),
@@ -56,9 +56,11 @@ async def get_settings(user_id: CurrentUserId) -> SettingsOut:
)
async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOut:
current = await policy.load_settings(user_id)
merged = {
merged: dict[str, Any] = {
"enabled_accounts": (
current.enabled_accounts
None
if body.all_accounts
else current.enabled_accounts
if body.enabled_accounts is None
else body.enabled_accounts
),
@@ -82,13 +84,15 @@ async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOu
# asyncpg binds arrays as Python lists — a "{a,b}" literal is rejected. Drop
# anything that is not a uuid rather than 500 on a malformed account id.
account_ids: list[uuid.UUID] = []
for raw in merged["enabled_accounts"]:
try:
account_ids.append(uuid.UUID(str(raw)))
except (ValueError, AttributeError, TypeError):
continue
merged["enabled_accounts"] = [str(a) for a in account_ids]
account_ids: list[uuid.UUID] | None = None
if merged["enabled_accounts"] is not None:
account_ids = []
for raw in merged["enabled_accounts"]:
try:
account_ids.append(uuid.UUID(str(raw)))
except (ValueError, AttributeError, TypeError):
continue
merged["enabled_accounts"] = [str(a) for a in account_ids]
async with _SDK.session(user_id) as s:
await s.execute(
@@ -308,4 +312,8 @@ async def bundle(filename: str) -> FileResponse:
raise HTTPException(
status_code=404, detail={"code": "not_found", "message": "no bundle"}
)
return FileResponse(_UI / filename, media_type="text/javascript")
# `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
# updated bundle is used at once instead of a copy the browser kept.
return FileResponse(
_UI / filename, media_type="text/javascript", headers={"Cache-Control": "no-cache"}
)
@@ -8,7 +8,8 @@ from pydantic import BaseModel, Field
class SettingsOut(BaseModel):
enabled_accounts: list[str]
# None: every account (also ones added later); a list: exactly those.
enabled_accounts: list[str] | None
move_threshold: float
banner_threshold: float
network_checks: bool
@@ -19,6 +20,8 @@ class SettingsIn(BaseModel):
"""Merge semantics: omitted fields keep their current value."""
enabled_accounts: list[str] | None = None
# True: every account again (enabled_accounts is then ignored).
all_accounts: bool | None = None
# Bounds are guard rails, not opinions: 0 would file everything as spam and
# the engine's own scale tops out well below 100.
move_threshold: float | None = Field(default=None, gt=0, le=100)
@@ -1,5 +1,6 @@
// antispam · settings.page slot (SPEC §C.6). Thresholds in plain language,
// per-account toggles, the allow/deny lists, the privacy switch, and stats.
// per-account toggles, the allow/deny lists, the privacy switch. (The engine's
// statistics stay available at GET /stats; the page no longer shows them.)
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
@@ -9,20 +10,17 @@ export default function mount(el, ctx) {
let settings = null;
let rules = [];
let accounts = [];
let stats = null;
let bootstrapNote = ""; // outcome of the last "learn from my Spam folder now" click
let disposed = false;
async function load() {
const [s, r, st] = await Promise.all([
const [s, r] = await Promise.all([
ctx.api.pluginFetch("/settings").catch(() => null),
ctx.api.pluginFetch("/rules").catch(() => []),
ctx.api.pluginFetch("/stats").catch(() => null),
]);
accounts = await ctx.api.fetch("/accounts").catch(() => []);
settings = s;
rules = r || [];
stats = st;
if (!disposed) render();
}
@@ -36,9 +34,10 @@ export default function mount(el, ctx) {
function accountRows() {
if (!accounts.length) return "";
// null: every account (the default, so an untouched install shows all
// ticked); a list: exactly those — empty, none.
const all = settings.enabled_accounts === null || settings.enabled_accounts === undefined;
const on = new Set(settings.enabled_accounts || []);
// Empty list means every account, so an untouched install shows all ticked.
const all = on.size === 0;
return accounts
.map(
(a) =>
@@ -58,20 +57,6 @@ export default function mount(el, ctx) {
.join("");
}
function statsBlock() {
if (!stats) return `<p class="wm-faint">Stats unavailable — is the engine running?</p>`;
const c = stats.counts || {};
const rowsOf = Object.entries(c)
.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`)
.join("");
const syms = (stats.top_symbols || [])
.map((s) => `<tr><td>${esc(s.symbol)}</td><td>${s.count}</td></tr>`)
.join("");
return `
<table class="wm-table"><tbody>${rowsOf || '<tr><td class="wm-faint">nothing classified yet</td><td></td></tr>'}</tbody></table>
${syms ? `<p class="wm-muted">Most common reasons</p><table class="wm-table"><tbody>${syms}</tbody></table>` : ""}`;
}
function render() {
if (!settings) {
el.innerHTML = `<p class="wm-faint">Loading…</p>`;
@@ -83,18 +68,18 @@ export default function mount(el, ctx) {
<p class="wm-muted">A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Spam, and the uncertain middle only gets a warning.</p>
<div class="wm-card">
<label class="wm-row">Move to Spam at score
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
</label>
<label class="wm-row">Warn with a banner at score
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="banner_threshold" value="${settings.banner_threshold}">
</label>
<div style="display:grid;grid-template-columns:max-content 7rem;gap:10px 12px;align-items:center;margin-bottom:10px">
<label for="as-move">Move to Spam at score</label>
<input id="as-move" class="wm-input" style="min-width:0;width:100%" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
<label for="as-banner">Warn with a banner at score</label>
<input id="as-banner" class="wm-input" style="min-width:0;width:100%" type="number" step="0.5" min="0.5" data-f="banner_threshold" value="${settings.banner_threshold}">
</div>
<p class="wm-faint">Higher is more permissive. A banner above the move score would never be seen, so it is capped there.</p>
</div>
<div class="wm-card">
<strong>Accounts</strong>
<p class="wm-faint">Tick none to filter every account.</p>
<p class="wm-faint">Only the ticked accounts are filtered. While all are ticked, accounts you add later are filtered too.</p>
${accountRows()}
</div>
@@ -115,12 +100,10 @@ export default function mount(el, ctx) {
<div class="wm-card">
<label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label>
<p class="wm-faint">Off by default. When on, the filter asks <strong>Spamhaus</strong> whether the sending server or a linked domain is known for spam, and <strong>DNSWL</strong> whether the sender is known-good. That reveals those addresses and domains to them — and to whichever DNS resolver you use. The message itself is never sent, and no fingerprint of it leaves your server.</p>
<label class="wm-row"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as spam</label>
<label class="wm-row" style="margin-top:16px"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as spam</label>
<div class="wm-row"><button class="wm-btn" data-bootstrap${settings.learn_from_provider_junk ? "" : " disabled"}>Learn from my Spam folder now</button><span class="wm-faint" data-bootstrap-note>${esc(bootstrapNote)}</span></div>
<p class="wm-faint">Otherwise this runs on its own once an hour, for accounts with at least 10 messages in Spam. Running it again is harmless: messages the filter already learned are skipped.</p>
</div>
<div class="wm-card"><strong>Activity</strong>${statsBlock()}</div>`;
</div>`;
el.querySelectorAll('input[type="number"][data-f]').forEach((i) =>
i.addEventListener("change", () => patch({ [i.getAttribute("data-f")]: Number(i.value) })),
@@ -131,9 +114,13 @@ export default function mount(el, ctx) {
el.querySelectorAll("[data-acc]").forEach((i) =>
i.addEventListener("change", () => {
const ticked = [...el.querySelectorAll("[data-acc]")].filter((x) => x.checked);
// All ticked is the same as "every account": store it as empty.
const ids = ticked.length === accounts.length ? [] : ticked.map((x) => x.getAttribute("data-acc"));
patch({ enabled_accounts: ids });
// All ticked is "every account" (accounts added later too); none ticked
// is none.
patch(
ticked.length === accounts.length
? { all_accounts: true }
: { enabled_accounts: ticked.map((x) => x.getAttribute("data-acc")) },
);
}),
);
const boot = el.querySelector("[data-bootstrap]");