From 7019e3afdf3c1e33e2c9dd06e29fed051eeda383 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 2 Sep 2026 03:13:23 +0200 Subject: [PATCH] fix(email): give every mail an HTML alternative so its link is clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bodies are text/plain only, so whether the URL in a verification or password-reset mail is a link at all was the client's decision. Gmail, Outlook and Apple Mail linkify plain text; lighter web clients render the body verbatim and leave the link as dead text — in a message whose entire purpose is that one link. _send now attaches an HTML part generated from the SAME plain body, rather than per-template HTML that would have to be kept in sync with it: one converter, all eleven templates, and text/plain stays the primary part and the source of truth. Escaping runs before linkification — bodies interpolate untrusted values (a requester's address, the operator contact) — so no caller can inject markup, and a query string's `&` is already `&` when it reaches the href, which is what an HTML attribute wants. Sentence-final punctuation is trimmed out of the href while url-safe token characters (`-`, `_`) are left alone. Inline styles only, no images, no external CSS, no tracking. --- backend/app/email.py | 47 ++++++++++ backend/tests/test_email_html_part.py | 119 ++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 backend/tests/test_email_html_part.py diff --git a/backend/app/email.py b/backend/app/email.py index b41f51d1..b0e9d451 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -10,7 +10,9 @@ verification links, password resets and access-request notices, and the operator to be a person telling them nothing arrived. """ +import html import logging +import re import smtplib import ssl from email.message import EmailMessage @@ -105,6 +107,48 @@ def _admin_contact() -> str | None: return next(iter(sorted(settings.admin_email_set)), None) +# A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail, +# Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the +# link as dead text — which, for a verification or password-reset mail, is the whole message. So +# every mail also carries an HTML alternative built from the SAME plain body (below), rather than +# per-template HTML that would have to be kept in sync with it. +# +# `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim +# keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping +# turns `&` into `&`, so a query string can legitimately end in a semicolon. +_URL_RE = re.compile(r"https?://[^\s<]+") + + +def _html_body(text: str) -> str: + """Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with + real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies + interpolate untrusted values (a requester's email address, the operator contact), so no caller + can inject markup, and `&` inside a URL is already `&` by the time it reaches the href, which + is what an HTML attribute wants anyway.""" + + def _anchor(m: re.Match[str]) -> str: + url = m.group(0) + trail = "" + while url and url[-1] in ".,!?)": + url, trail = url[:-1], url[-1] + trail + if not url: + return trail + return f'{url}{trail}' + + paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()] + rendered = "".join( + f"

{_URL_RE.sub(_anchor, p).replace(chr(10), '
')}

" for p in paragraphs + ) + # Inline styles only — many clients drop