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:
+41
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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]");
|
||||
|
||||
@@ -48,4 +48,8 @@ async def set_rules(body: RulesIn, user_id: CurrentUserId) -> dict[str, object]:
|
||||
async def bundle(filename: str) -> FileResponse:
|
||||
if filename != "settings.js":
|
||||
raise HTTPException(status_code=404)
|
||||
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"}
|
||||
)
|
||||
|
||||
@@ -52,4 +52,8 @@ async def add_note(body: NoteIn, user_id: CurrentUserId) -> dict[str, str]:
|
||||
@router.get("/ui/page.js")
|
||||
async def page_bundle() -> FileResponse:
|
||||
"""Serve the page's ESM bundle for the frontend loader (SPEC §11.3)."""
|
||||
return FileResponse(_UI / "page.js", 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 / "page.js", media_type="text/javascript", headers={"Cache-Control": "no-cache"}
|
||||
)
|
||||
|
||||
@@ -179,4 +179,8 @@ async def delete_label(name: str, user_id: CurrentUserId) -> list[str]:
|
||||
async def bundle(filename: str) -> FileResponse:
|
||||
if filename not in _BUNDLES:
|
||||
raise HTTPException(status_code=404)
|
||||
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"}
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ function injectStyles() {
|
||||
s.id = "lb-styles";
|
||||
s.textContent = `
|
||||
.lb-sec{position:relative;padding:4px 0 0}
|
||||
.lb-head{display:flex;align-items:center;gap:5px;padding:10px 8px 2px 12px}
|
||||
.lb-head{display:flex;align-items:center;gap:5px;padding:24px 8px 2px 12px}
|
||||
.lb-headbtn{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:rgb(var(--app-faint));font:inherit;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}
|
||||
.lb-add{margin-left:auto;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:2px 6px;border-radius:4px;font-size:15px;line-height:1}
|
||||
.lb-add:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
|
||||
Reference in New Issue
Block a user