From c0de50d85d67e46c35bbcbdeee69e56601487414 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 16 Oct 2026 13:43:13 -0400 Subject: [PATCH] Email: one branded HTML layout with a text part, and invites can be emailed (#5917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/public-hosting.md | 3 +- frontend/src/components/InviteList.vue | 34 ++++-- src/inkwell/invites_api.py | 28 ++++- src/inkwell/mail_templates.py | 150 +++++++++++++++++++++++++ src/inkwell/mailer.py | 19 ++-- src/inkwell/password_resets.py | 11 +- src/inkwell/settings_api.py | 8 +- tests/test_integration.py | 28 ++++- tests/test_mail_templates.py | 43 +++++++ 9 files changed, 291 insertions(+), 33 deletions(-) create mode 100644 src/inkwell/mail_templates.py create mode 100644 tests/test_mail_templates.py diff --git a/docs/public-hosting.md b/docs/public-hosting.md index 11ca407..8bb0408 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -28,7 +28,8 @@ on a LAN. To let someone else in, send them an invite: **Settings → People → Invites** makes a link that works once, expires (a week by default), and can be pinned to one email address. It -lets that one person register while registration stays closed. +lets that one person register while registration stays closed. With email set up (below) and an +address filled in, **Create and email** sends the link to that address as well. **2. Terminate TLS in front of it, and forward the scheme.** The app marks the session cookie `Secure` and sends HSTS only when it can tell the request arrived over diff --git a/frontend/src/components/InviteList.vue b/frontend/src/components/InviteList.vue index 31eb7a8..27c628a 100644 --- a/frontend/src/components/InviteList.vue +++ b/frontend/src/components/InviteList.vue @@ -1,5 +1,5 @@ ", LINK, 1) + assert "" 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 "