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>
This commit is contained in:
2026-10-14 22:08:06 +02:00
co-authored by Claude Opus 5.5
parent 30b806e7da
commit addb2232a1
54 changed files with 1265 additions and 187 deletions
@@ -104,7 +104,8 @@ async def test_due_reminders_are_sent_once(ctx) -> None: # type: ignore[no-unty
assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder
assert sent[-1]["title"] == "Dentist"
assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4"
assert sent[-1]["url"].startswith("/p/calendar?date=2031-06-02&event=") and sent[-1]["sticky"] is True
assert sent[-1]["url"].startswith("/p/calendar?date=2031-06-02&event=")
assert sent[-1]["sticky"] is True
assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again
# The worker was down when the 10-minute reminder came due: sent late, once.
assert await run(MON_9 - timedelta(minutes=4)) == 1
+34 -17
View File
@@ -49,8 +49,12 @@ async def ctx() -> AsyncIterator[_Ctx]:
s.add(user)
await s.flush()
account = Account(
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={},
secret_ct=b"", discovery_src="manual",
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
s.add(account)
await s.flush()
@@ -64,12 +68,17 @@ async def ctx() -> AsyncIterator[_Ctx]:
s.add(thread)
await s.flush()
msg = Message(
user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1,
user_id=user.id,
account_id=account.id,
mailbox_id=mbox.id,
uid=1,
thread_id=thread.id,
subject="Your Invoice 123",
from_addr=[{"name": "Billing", "email": "billing@acme.com"}],
to_addr=[{"name": "Me", "email": "me@example.com"}],
flags=[], internal_date=datetime.now(UTC), blob_sha256="",
flags=[],
internal_date=datetime.now(UTC),
blob_sha256="",
)
s.add(msg)
await s.commit()
@@ -99,24 +108,30 @@ async def _flags(ctx: _Ctx) -> list[str]:
async def test_hook_applies_flag_on_match(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "subject", "contains": "invoice", "action": "read"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "subject", "contains": "invoice", "action": "read"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Seen" in await _flags(ctx)
async def test_hook_no_match_leaves_flags(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert await _flags(ctx) == []
async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Flagged" in await _flags(ctx) # "pin" == the \Flagged flag in this app
@@ -124,16 +139,16 @@ async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None:
async def test_hook_matches_on_to(ctx: _Ctx) -> None:
# Multi-account setups match on the recipient address.
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Seen" in await _flags(ctx)
async def test_hook_trash_moves_thread_to_trash(
ctx: _Ctx, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_hook_trash_moves_thread_to_trash(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[object, ...]] = []
async def fake_enqueue(name: str, *args: object, **kwargs: object) -> None:
@@ -141,8 +156,10 @@ async def test_hook_trash_moves_thread_to_trash(
# trash() enqueues the same job the UI Delete uses (imported from app.queue).
monkeypatch.setattr("app.queue.enqueue_job", fake_enqueue)
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert ("move_thread_to_folder_job", str(ctx.thread_id), "trash") in calls
+1 -3
View File
@@ -68,9 +68,7 @@ async def ctx() -> AsyncIterator[_Ctx]:
await c.client_a.aclose()
await c.client_b.aclose()
async with sm() as s:
await s.execute(
delete(User).where(User.id.in_([c.user_a, c.user_b]))
)
await s.execute(delete(User).where(User.id.in_([c.user_a, c.user_b])))
await s.execute(
text("DELETE FROM plg_hello_note WHERE user_id IN (:a, :b)"),
{"a": str(c.user_a), "b": str(c.user_b)},
+4 -3
View File
@@ -51,9 +51,10 @@ async def test_install_run_uninstall_dropdata_roundtrip() -> None:
# Table exists, RLS is enabled on it, and the per-plugin version table is present.
assert await _regclass("plg_demo_note") == "plg_demo_note"
assert await _scalar(
"SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'"
) is True
assert (
await _scalar("SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'")
is True
)
assert await _regclass("alembic_version_plg_demo") == "alembic_version_plg_demo"
# --drop-data removes the table AND the version table: zero residue.
+14 -4
View File
@@ -27,8 +27,12 @@ async def test_messages_header_reads_raw_header() -> None:
s.add(user)
await s.flush()
account = Account(
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={},
secret_ct=b"", discovery_src="manual",
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
s.add(account)
await s.flush()
@@ -44,8 +48,14 @@ async def test_messages_header_reads_raw_header() -> None:
sha = blobstore.store(user.id, raw.as_bytes())
msg = Message(
user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1,
subject="Weekly digest", flags=[], internal_date=datetime.now(UTC), blob_sha256=sha,
user_id=user.id,
account_id=account.id,
mailbox_id=mbox.id,
uid=1,
subject="Weekly digest",
flags=[],
internal_date=datetime.now(UTC),
blob_sha256=sha,
)
s.add(msg)
await s.commit()
@@ -44,9 +44,7 @@ async def test_ui_manifest_endpoint() -> None:
)
try:
# Unauthenticated request is rejected.
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as anon:
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as anon:
assert (await anon.get("/api/plugins/ui-manifest")).status_code == 401
resp = await client.get("/api/plugins/ui-manifest")