Productivity features, UI translations, local email translation

- 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>
This commit is contained in:
2026-10-18 20:00:31 +02:00
co-authored by Claude Opus 5.5
parent 463f96ad57
commit 8a1b9979e9
83 changed files with 8677 additions and 293 deletions
+54
View File
@@ -0,0 +1,54 @@
"""Templates: saved replies from the compose window's More menu."""
from __future__ import annotations
from tests.api.conftest import HDR, UserCtx
async def test_save_list_replace_delete(user_ctx: UserCtx) -> None:
c = user_ctx.client
assert (await c.get("/api/templates")).json() == []
resp = await c.post(
"/api/templates",
json={"name": " Thanks ", "subject": "Re: offer", "body_html": "<p>Thank <b>you</b></p>"},
headers=HDR,
)
assert resp.status_code == 200, resp.text
first = resp.json()
assert first["name"] == "Thanks" and first["subject"] == "Re: offer"
# Same name, any case: replaces rather than adding a second one.
resp = await c.post(
"/api/templates", json={"name": "thanks", "body_html": "<p>Cheers</p>"}, headers=HDR
)
assert resp.json()["id"] == first["id"]
await c.post("/api/templates", json={"name": "Apology", "body_html": "x"}, headers=HDR)
listed = (await c.get("/api/templates")).json()
assert [t["name"] for t in listed] == ["Apology", "thanks"]
assert listed[1]["body_html"] == "<p>Cheers</p>"
assert (await c.delete(f"/api/templates/{first['id']}", headers=HDR)).status_code == 200
assert [t["name"] for t in (await c.get("/api/templates")).json()] == ["Apology"]
async def test_template_html_is_cleaned(user_ctx: UserCtx) -> None:
resp = await user_ctx.client.post(
"/api/templates",
json={
"name": "x",
"body_html": '<p onclick="evil()">Hi<script>alert(1)</script>'
'<img src="x" onerror="evil()"><a href="javascript:evil()">l</a></p>',
},
headers=HDR,
)
html = resp.json()["body_html"]
assert "script" not in html and "onclick" not in html and "onerror" not in html
assert "javascript:" not in html and "Hi" in html
async def test_template_needs_a_name(user_ctx: UserCtx) -> None:
resp = await user_ctx.client.post(
"/api/templates", json={"name": " ", "body_html": "x"}, headers=HDR
)
assert resp.status_code == 400