AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user