Email: the Inkwell logo in the header
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 19s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 48s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 19s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 48s
The header showed only a yellow dot before the site name. It now shows the app icon, embedded in the message as an inline part (cid:inkwell-logo, the HTML part becoming multipart/related) rather than linked: most clients block remote images, the app fetches nothing (rule 164), and Gmail drops SVG and data: images. The PNG is a copy of frontend/public/icon-192.png, shipped as package data; a test keeps the two identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,10 @@ dev = [
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
# The email logo (mail_templates.py) ships inside the package.
|
||||||
|
inkwell = ["assets/*.png"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
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
|
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.
|
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
|
It fetches nothing: the app sends with no outbound network beyond the mail server
|
||||||
network beyond the mail server (rule 164), and a remote image is a tracking pixel
|
(rule 164), and a remote image is a tracking pixel anyway. The one image, the
|
||||||
anyway. Everything taken from settings or from a person (the site name, an address,
|
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.
|
a link) is escaped before it goes into the HTML.
|
||||||
|
|
||||||
Links come from Settings → General → Public address, never from the request: a
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from functools import cache
|
||||||
from html import escape
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# The brand yellow (`brand` in tailwind.config.js) and the app's neutral greys. The
|
# 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
|
# 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"
|
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)
|
@dataclass(frozen=True)
|
||||||
class Email:
|
class Email:
|
||||||
subject: str
|
subject: str
|
||||||
text: str
|
text: str
|
||||||
html: 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)
|
@dataclass(frozen=True)
|
||||||
@@ -88,8 +105,9 @@ def _render(
|
|||||||
'style="max-width:520px;background:#FFFFFF;border-radius:12px;'
|
'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'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'<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};'
|
f'<img src="cid:{LOGO_CID}" width="28" height="28" alt="" '
|
||||||
'margin-right:8px;vertical-align:middle"></span>'
|
'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>'
|
f'<span style="font-size:15px;font-weight:700;color:{INK};vertical-align:middle">{escape(site)}</span>'
|
||||||
"</td></tr>"
|
"</td></tr>"
|
||||||
f'<tr><td style="padding:28px">'
|
f'<tr><td style="padding:28px">'
|
||||||
@@ -100,7 +118,7 @@ def _render(
|
|||||||
f'color:{MUTED}">{escape(footer)}</td></tr>'
|
f'color:{MUTED}">{escape(footer)}</td></tr>'
|
||||||
"</table></td></tr></table></body></html>"
|
"</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:
|
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:
|
def build_message(cfg: MailSettings, to: str, email: Email) -> EmailMessage:
|
||||||
"""multipart/alternative: the text part first, the HTML part last, so a client
|
"""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 = EmailMessage()
|
||||||
msg["From"] = cfg.sender
|
msg["From"] = cfg.sender
|
||||||
msg["To"] = to
|
msg["To"] = to
|
||||||
msg["Subject"] = email.subject
|
msg["Subject"] = email.subject
|
||||||
msg.set_content(email.text)
|
msg.set_content(email.text)
|
||||||
msg.add_alternative(email.html, subtype="html")
|
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
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import re
|
||||||
from email import message_from_bytes
|
from email import message_from_bytes
|
||||||
from email.policy import default
|
from email.policy import default
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from inkwell import mail_templates
|
from inkwell import mail_templates
|
||||||
from inkwell.mailer import MailSettings, build_message
|
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():
|
def test_the_html_fetches_nothing():
|
||||||
"""No remote images or fonts: the app sends with no outbound network, and a remote
|
"""No remote images or fonts: the app sends with no outbound network, and a remote
|
||||||
image in an email is a tracking pixel."""
|
image in an email is a tracking pixel. The one image is the logo, carried inside
|
||||||
html = mail_templates.password_reset("Inkwell", "a@example.test", LINK).html
|
the message and named by cid."""
|
||||||
assert "<img" not in html
|
email = mail_templates.password_reset("Inkwell", "a@example.test", LINK)
|
||||||
assert "src=" not in html
|
assert re.findall(r'src="([^"]*)"', email.html) == [f"cid:{mail_templates.LOGO_CID}"]
|
||||||
assert "@import" not in html
|
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():
|
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"))
|
msg = build_message(cfg, "a@example.test", mail_templates.test_message("Inkwell", "https://x"))
|
||||||
parsed = message_from_bytes(msg.as_bytes(), policy=default)
|
parsed = message_from_bytes(msg.as_bytes(), policy=default)
|
||||||
assert parsed.get_content_type() == "multipart/alternative"
|
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"
|
assert parsed["Subject"] == "Inkwell test email"
|
||||||
|
|||||||
Reference in New Issue
Block a user