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