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:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user