Email: one branded HTML layout with a text part, and invites can be emailed (#5917)

Each email was a subject and a plain-text f-string at its call site.
mail_templates.py now holds one layout: the site name, a heading, the
message, a button for the link and a footer saying who sent it and why.
Every email (password reset, test, invite) is a function returning its
subject, text and HTML. mailer.send sends them as multipart/alternative,
text first.

The HTML is email-shaped: tables, inline styles, no images or fonts, and
every value from settings or a person escaped.

POST /api/invites takes send_email. It is refused before anything is made
when email is off or the invite names no address. The link is built from
the public address. A failed send leaves the invite standing with
email_error, so the admin still gets the link once. The invite form offers
"Email the link to …" when email is on and an address is filled in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 13:43:13 -04:00
co-authored by Claude Opus 5.5
parent 5cc71c3050
commit c0de50d85d
9 changed files with 291 additions and 33 deletions
+27 -1
View File
@@ -2029,7 +2029,8 @@ async def test_a_forgotten_password_is_reset_from_an_emailed_link(app_client, db
await mailer.drain()
assert [m["To"] for m in outbox] == ["guest@example.test"]
text = outbox[0].get_content()
text = outbox[0].get_body(preferencelist=("plain",)).get_content()
assert "reset-password?token=" in outbox[0].get_body(preferencelist=("html",)).get_content()
link = re.search(r"https://notes\.example\.test/reset-password\?token=([\w-]+)", text)
assert link, text
async with session_scope() as fresh:
@@ -2055,6 +2056,31 @@ async def test_reset_emails_stop_at_the_cap_without_saying_so(app_client, db, mo
assert len(outbox) == 3 # reset_emails_per_account defaults to 3
async def test_an_invite_is_emailed_to_its_address_with_a_working_link(app_client, db, monkeypatch):
await _mail_off()
outbox = _outbox(monkeypatch)
await _admin_with_invite(app_client)
# Refused, and nothing made, while email is off or there is no one to send to.
off = await app_client.post("/api/invites", json={"email": "friend@example.test", "send_email": True})
assert off.status_code == 400
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
nobody = await app_client.post("/api/invites", json={"send_email": True})
assert nobody.status_code == 400
made = await app_client.post("/api/invites", json={"email": "friend@example.test", "days": 7, "send_email": True})
assert made.status_code == 201
body = await made.get_json()
assert body["emailed"] is True
assert [m["To"] for m in outbox] == ["friend@example.test"]
text = outbox[0].get_body(preferencelist=("plain",)).get_content()
assert f"https://notes.example.test/register?invite={body['token']}" in text
assert "within 7 days" in text
registered = await _register("friend@example.test", body["token"])
assert registered.status_code == 201
async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client, db, monkeypatch):
await _mail_off()
outbox = _outbox(monkeypatch)
+43
View File
@@ -0,0 +1,43 @@
from email import message_from_bytes
from email.policy import default
from inkwell import mail_templates
from inkwell.mailer import MailSettings, build_message
LINK = "https://notes.example.test/reset-password?token=abc-123"
def test_every_email_carries_its_link_in_both_parts():
for email in (
mail_templates.password_reset("Inkwell", "a@example.test", LINK),
mail_templates.invite("Inkwell", "Sam", LINK, 7),
):
assert LINK in email.text
assert f'href="{LINK}"' in email.html
def test_text_from_settings_and_people_is_escaped_in_the_html():
email = mail_templates.invite('<b>Ink</b> & "co"', "<script>x</script>", LINK, 1)
assert "<script>" not in email.html
assert "&lt;b&gt;Ink&lt;/b&gt; &amp;" in email.html
# The text part is plain text, so it is left as written.
assert "<script>x</script>" in email.text
assert "within a day" in email.text
def test_the_html_fetches_nothing():
"""No remote images or fonts: the app sends with no outbound network, and a remote
image in an email is a tracking pixel."""
html = mail_templates.password_reset("Inkwell", "a@example.test", LINK).html
assert "<img" not in html
assert "src=" not in html
assert "@import" not in html
def test_the_message_is_text_first_then_html():
cfg = MailSettings("smtp.example.test", 587, "starttls", "", "", "inkwell@example.test", "https://x")
msg = build_message(cfg, "a@example.test", mail_templates.test_message("Inkwell", "https://x"))
parsed = message_from_bytes(msg.as_bytes(), policy=default)
assert parsed.get_content_type() == "multipart/alternative"
assert [p.get_content_type() for p in parsed.iter_parts()] == ["text/plain", "text/html"]
assert parsed["Subject"] == "Inkwell test email"