fix(email): give every mail an HTML alternative so its link is clickable
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.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Every outbound mail carries an HTML alternative, so its link is actually clickable.
|
||||
|
||||
The bodies are plain text and stay that way — but a bare URL in a text/plain mail is only a link if
|
||||
the CLIENT chooses to linkify it. Gmail/Outlook/Apple Mail do; lighter web clients render the body
|
||||
verbatim and leave the verification or password-reset link as dead text, which is the entire point of
|
||||
those two messages. So `_send` now attaches an HTML part generated from the same plain body.
|
||||
|
||||
These are pure-logic tests except the last one, which drives the real `_send` against a fake SMTP so
|
||||
the wire format itself is pinned, not just the helper.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app import email as email_mod
|
||||
|
||||
_SMTP_OK = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"user": "u",
|
||||
"password": "p",
|
||||
"from": "Siftlode <u@example.com>",
|
||||
}
|
||||
|
||||
|
||||
def test_a_bare_url_becomes_a_real_link():
|
||||
# THE regression: this is what a client that does not linkify plain text was leaving as dead text.
|
||||
out = email_mod._html_body(
|
||||
"Confirm your email:\n\nhttps://siftlode.example/#verify=tok123\n\n— Siftlode"
|
||||
)
|
||||
assert '<a href="https://siftlode.example/#verify=tok123">' in out
|
||||
|
||||
|
||||
def test_interpolated_content_cannot_inject_markup():
|
||||
# Bodies carry untrusted values — a requester's address in the admin notice, the operator contact.
|
||||
# Escaping must run BEFORE linkification, or a crafted address becomes markup in every admin's mail.
|
||||
out = email_mod._html_body('A request from <script>alert("x")</script> & co')
|
||||
assert "<script>" not in out
|
||||
assert "<script>" in out
|
||||
assert "& co" in out
|
||||
|
||||
|
||||
def test_a_query_string_keeps_its_escaped_ampersand_inside_the_href():
|
||||
# `&` is escaped to `&` before the linkifier sees it, which is also what an HTML attribute
|
||||
# wants — so the href must carry the escaped form and nothing may be trimmed off it.
|
||||
out = email_mod._html_body("https://siftlode.example/x?a=1&b=2")
|
||||
assert (
|
||||
'<a href="https://siftlode.example/x?a=1&b=2">https://siftlode.example/x?a=1&b=2</a>'
|
||||
in out
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body,href,trail",
|
||||
[
|
||||
("See https://siftlode.example/x.", "https://siftlode.example/x", "."),
|
||||
("See https://siftlode.example/x, then sign in", "https://siftlode.example/x", ","),
|
||||
("See (https://siftlode.example/x)", "https://siftlode.example/x", ")"),
|
||||
],
|
||||
)
|
||||
def test_sentence_punctuation_stays_out_of_the_href(body, href, trail):
|
||||
out = email_mod._html_body(body)
|
||||
assert f'<a href="{href}">{href}</a>{trail}' in out
|
||||
|
||||
|
||||
def test_a_token_ending_in_a_url_safe_character_is_not_trimmed():
|
||||
# token_urlsafe output can end in `-` or `_`; trimming either would hand the user a dead link.
|
||||
for tok in ("abc-", "abc_"):
|
||||
out = email_mod._html_body(f"https://siftlode.example/#reset={tok}")
|
||||
assert f'href="https://siftlode.example/#reset={tok}"' in out
|
||||
|
||||
|
||||
def test_blank_lines_become_paragraphs_and_single_breaks_stay_inline():
|
||||
out = email_mod._html_body("Hi,\n\nline one\nline two\n\n— Siftlode")
|
||||
assert out.count("<p>") == 3
|
||||
assert "<p>line one<br>line two</p>" in out
|
||||
|
||||
|
||||
def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
|
||||
# The wire format, not just the helper: text/plain stays the primary part and the source of
|
||||
# truth, with text/html alongside it.
|
||||
monkeypatch.setattr(email_mod, "_smtp", lambda: _SMTP_OK)
|
||||
sent = []
|
||||
|
||||
class _FakeSMTP:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def starttls(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def login(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def send_message(self, msg):
|
||||
sent.append(msg)
|
||||
|
||||
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
|
||||
|
||||
assert (
|
||||
email_mod.send_verify_email("someone@example.com", "https://siftlode.example/#verify=tok")
|
||||
is True
|
||||
)
|
||||
|
||||
(msg,) = sent
|
||||
assert msg.get_content_type() == "multipart/alternative"
|
||||
plain = msg.get_body(preferencelist=("plain",))
|
||||
rich = msg.get_body(preferencelist=("html",))
|
||||
assert plain is not None and rich is not None
|
||||
# The plain part must be byte-for-byte the template's own body — the HTML part is derived from
|
||||
# it, never a second copy that could drift.
|
||||
assert "https://siftlode.example/#verify=tok" in plain.get_content()
|
||||
assert "<a href=" not in plain.get_content()
|
||||
assert '<a href="https://siftlode.example/#verify=tok">' in rich.get_content()
|
||||
Reference in New Issue
Block a user