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:
@@ -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 "<b>Ink</b> &" 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"
|
||||
Reference in New Issue
Block a user