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
+6
View File
@@ -25,6 +25,7 @@ from app.indexer.sanitize import sanitize_html
from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
from app.providers.base import RemoteMessage
from app.search.base import SearchDoc
from app.subscriptions import read_list_headers
from app.threading_.normalize import synthesize_message_id
from app.threading_.threader import thread_message
@@ -167,6 +168,7 @@ async def persist_message(
else:
sha = rawstore.put_cached(user_id, remote.raw)
parsed = await _parse_bounded(user_id, sha, remote.raw)
list_headers = read_list_headers(remote.raw) # for the Subscriptions page
message_id_hdr = await _resolve_message_id(
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
@@ -207,6 +209,9 @@ async def persist_message(
has_attachments=parsed.has_attachments,
snippet=parsed.snippet,
blob_sha256=sha,
list_id=list_headers.list_id,
list_unsubscribe=list_headers.unsubscribe,
list_unsubscribe_post=list_headers.one_click,
)
session.add(message)
await session.flush()
@@ -224,6 +229,7 @@ async def persist_message(
kind="html_sanitized",
content=result.html,
sanitizer_v=result.sanitizer_v,
trackers_blocked=result.trackers_blocked,
)
)
+103 -2
View File
@@ -19,7 +19,10 @@ import nh3
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
# v5: this server's own inline-image URLs quoted in a message load from here.
# v6: links keep only http(s) and mailto (no data: pages).
SANITIZER_VERSION = 6
# v7: tracking pixels are removed (and counted), even when images are allowed.
# v8: CSS sizes in other units (in, em, %, pt) no longer read as pixels — v7
# took 0.31in signature logos for trackers.
SANITIZER_VERSION = 8
# This server's inline-image URL, however it ended up written: "/api/…",
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
@@ -154,6 +157,94 @@ class SanitizeResult:
html: str
has_remote_images: bool
sanitizer_v: int
trackers_blocked: int = 0
# --- Tracking pixels ---------------------------------------------------------------
# A remote image that is invisible (1×1, or hidden by CSS) has no use but to tell
# the sender the mail was opened, and by whom. A few well-known open-tracking URL
# shapes are caught whatever their size. Matching runs on the raw HTML before nh3:
# a miss only leaves an image in, and nh3 still cleans whatever remains.
_IMG_TAG = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
_ATTR = re.compile(r"""([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+)""")
_CSS_HIDDEN = re.compile(
r"display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d]*[1-9])",
re.IGNORECASE,
)
_TRACKER_URL = re.compile(
r"//[^/]*mailtrack\.io/"
r"|/track/open\b"
r"|/wf/open\b"
r"|/open\.(?:php|aspx|gif)\b"
r"|[?&]tracking[_-]?pixel=",
re.IGNORECASE,
)
_PX = re.compile(r"\s*(\d+(?:\.\d*)?|\.\d+)\s*(px)?\s*$", re.IGNORECASE)
def _px(value: str | None) -> float | None:
"""A size in pixels ("1", "1px"), or None for anything else: other units
(0.31in is a 30-pixel signature logo), auto, %, or malformed (".", "1.2.3")."""
if value is None:
return None
match = _PX.match(value)
return float(match.group(1)) if match else None
def _tiny(value: str | None) -> bool:
px = _px(value)
return px is not None and px <= 2
def _css(style: str) -> dict[str, str]:
"""An inline style's declarations, lower-cased, without !important."""
out: dict[str, str] = {}
for decl in style.split(";"):
name, _, value = decl.partition(":")
value = value.lower().replace("!important", "").strip()
if name.strip() and value:
out[name.strip().lower()] = value
return out
def is_tracking_pixel(tag: str) -> bool:
"""Whether an ``<img …>`` tag is a remote open-tracking pixel."""
attrs = {name.lower(): value.strip("\"'") for name, value in _ATTR.findall(tag)}
src = attrs.get("src", "").strip()
if not src.lower().startswith(("http:", "https:", "//")):
return False # inline (cid:/data:) images can't report anything
if _TRACKER_URL.search(src):
return True
style = attrs.get("style", "")
if _CSS_HIDDEN.search(style):
return True
# The size the browser draws: a style width/height overrides the attribute
# (and "auto", % or another unit is no tiny size); a max-width/max-height
# of a pixel or two caps it; height "auto" follows the width.
css = _css(style)
wide = _tiny(css["width"]) if "width" in css else _tiny(attrs.get("width"))
tall = _tiny(css["height"]) if "height" in css else _tiny(attrs.get("height"))
wide = wide or _tiny(css.get("max-width"))
tall = tall or _tiny(css.get("max-height"))
if css.get("height") == "auto":
tall = wide
return wide and tall
def strip_tracking_pixels(html: str) -> tuple[str, int]:
"""Remove tracking pixels from raw HTML; returns the HTML and how many."""
count = 0
def repl(match: re.Match[str]) -> str:
nonlocal count
if is_tracking_pixel(match.group(0)):
count += 1
return ""
return match.group(0)
return _IMG_TAG.sub(repl, html), count
def _sanitize_style(value: str, allow_remote: bool) -> str | None:
@@ -188,15 +279,24 @@ def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, boo
def sanitize_html(
html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False
html: str,
message_id: uuid.UUID,
*,
allow_remote_images: bool = False,
block_trackers: bool = True,
) -> SanitizeResult:
"""Sanitize ``html`` for the message ``message_id``.
Returns the safe HTML, whether any remote images were blocked, and the
sanitizer version. Pass ``allow_remote_images=True`` after the user approves
a sender to render remote images and CSS backgrounds (SPEC §9.4).
``block_trackers=False`` (Settings → Privacy) leaves tracking pixels in; they
are remote images like any other then.
"""
state = {"has_remote": False}
trackers = 0
if block_trackers:
html, trackers = strip_tracking_pixels(html)
def attribute_filter(tag: str, attr: str, value: str) -> str | None:
if attr == "style":
@@ -247,4 +347,5 @@ def sanitize_html(
html=cleaned,
has_remote_images=state["has_remote"],
sanitizer_v=SANITIZER_VERSION,
trackers_blocked=trackers,
)