AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
5 changed files with 61 additions and 14 deletions
Showing only changes of commit 74d0d3d88e - Show all commits
+4
View File
@@ -27,6 +27,10 @@ dev = [
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
# The email logo (mail_templates.py) ships inside the package.
inkwell = ["assets/*.png"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+25 -7
View File
@@ -7,9 +7,11 @@ and the link.
The HTML is deliberately email-shaped rather than web-shaped. It uses tables and
inline styles, because many mail clients drop `<style>` blocks and ignore flexbox.
It uses no images or web fonts and fetches nothing: the app sends with no outbound
network beyond the mail server (rule 164), and a remote image is a tracking pixel
anyway. Everything taken from settings or from a person (the site name, an address,
It fetches nothing: the app sends with no outbound network beyond the mail server
(rule 164), and a remote image is a tracking pixel anyway. The one image, the
Inkwell logo, travels inside the message as an inline part (`cid:`), which mail
clients show without asking, unlike a remote image; SVG and `data:` images are
dropped by too many clients to rely on. Everything taken from settings or from a person (the site name, an address,
a link) is escaped before it goes into the HTML.
Links come from Settings → General → Public address, never from the request: a
@@ -17,8 +19,10 @@ forged Host header would otherwise mail a stranger a link to the forger's site.
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from functools import cache
from html import escape
from pathlib import Path
# The brand yellow (`brand` in tailwind.config.js) and the app's neutral greys. The
# button text is near-black for the same reason it is in the app: white on this
@@ -30,11 +34,24 @@ RULE = "#E5E5E5"
PAGE = "#F5F5F5"
# The app icon at 192 px, a copy of frontend/public/icon-192.png (a test keeps the two
# the same), shown at 28 px so it stays sharp on a high-density screen.
LOGO_CID = "inkwell-logo"
_LOGO_PATH = Path(__file__).parent / "assets" / "email-logo.png"
@cache
def logo_png() -> bytes:
return _LOGO_PATH.read_bytes()
@dataclass(frozen=True)
class Email:
subject: str
text: str
html: str
# Images the HTML shows by `cid:`, sent as inline parts beside it: {content id: PNG}.
images: dict[str, bytes] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -88,8 +105,9 @@ def _render(
'style="max-width:520px;background:#FFFFFF;border-radius:12px;'
f'border:1px solid {RULE};font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">'
f'<tr><td style="padding:20px 28px;border-bottom:1px solid {RULE}">'
f'<span style="display:inline-block;width:10px;height:10px;border-radius:5px;background:{BRAND};'
'margin-right:8px;vertical-align:middle"></span>'
f'<img src="cid:{LOGO_CID}" width="28" height="28" alt="" '
'style="display:inline-block;width:28px;height:28px;border:0;border-radius:6px;'
'margin-right:10px;vertical-align:middle">'
f'<span style="font-size:15px;font-weight:700;color:{INK};vertical-align:middle">{escape(site)}</span>'
"</td></tr>"
f'<tr><td style="padding:28px">'
@@ -100,7 +118,7 @@ def _render(
f'color:{MUTED}">{escape(footer)}</td></tr>'
"</table></td></tr></table></body></html>"
)
return Email(subject=subject, text=text, html=html)
return Email(subject=subject, text=text, html=html, images={LOGO_CID: logo_png()})
def password_reset(site: str, to: str, link: str) -> Email:
+7 -1
View File
@@ -57,13 +57,19 @@ async def mail_settings(db) -> MailSettings | None:
def build_message(cfg: MailSettings, to: str, email: Email) -> EmailMessage:
"""multipart/alternative: the text part first, the HTML part last, so a client
picks the HTML when it can and the text when it can't."""
picks the HTML when it can and the text when it can't. The HTML part carries its
images with it (multipart/related)."""
msg = EmailMessage()
msg["From"] = cfg.sender
msg["To"] = to
msg["Subject"] = email.subject
msg.set_content(email.text)
msg.add_alternative(email.html, subtype="html")
# The logo rides inside the message: the HTML part becomes multipart/related,
# holding the HTML and each image it names by cid.
html_part = msg.get_payload()[-1]
for cid, data in email.images.items():
html_part.add_related(data, "image", "png", cid=f"<{cid}>")
return msg
+25 -6
View File
@@ -1,5 +1,7 @@
import re
from email import message_from_bytes
from email.policy import default
from pathlib import Path
from inkwell import mail_templates
from inkwell.mailer import MailSettings, build_message
@@ -27,11 +29,19 @@ def test_text_from_settings_and_people_is_escaped_in_the_html():
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
image in an email is a tracking pixel. The one image is the logo, carried inside
the message and named by cid."""
email = mail_templates.password_reset("Inkwell", "a@example.test", LINK)
assert re.findall(r'src="([^"]*)"', email.html) == [f"cid:{mail_templates.LOGO_CID}"]
assert "@import" not in email.html and "<link" not in email.html
assert email.images == {mail_templates.LOGO_CID: mail_templates.logo_png()}
def test_the_email_logo_is_the_app_icon():
"""A copy, so the package carries it; this keeps the copy from drifting."""
icon = Path(__file__).resolve().parents[1] / "frontend" / "public" / "icon-192.png"
assert mail_templates.logo_png() == icon.read_bytes()
assert mail_templates.logo_png().startswith(b"\x89PNG")
def test_the_message_is_text_first_then_html():
@@ -39,5 +49,14 @@ def test_the_message_is_text_first_then_html():
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"]
text, related = parsed.iter_parts()
assert text.get_content_type() == "text/plain"
# The HTML travels with the logo it shows: multipart/related, html first.
assert related.get_content_type() == "multipart/related"
html, logo = related.iter_parts()
assert html.get_content_type() == "text/html"
assert f"cid:{mail_templates.LOGO_CID}" in html.get_content()
assert logo.get_content_type() == "image/png"
assert logo["Content-ID"] == f"<{mail_templates.LOGO_CID}>"
assert logo.get_content() == mail_templates.logo_png()
assert parsed["Subject"] == "Inkwell test email"