From 74d0d3d88e982e6b12e20180cdf3afce3c5d738c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 16 Oct 2026 14:30:18 -0400 Subject: [PATCH] Email: the Inkwell logo in the header 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 --- pyproject.toml | 4 ++++ src/inkwell/assets/email-logo.png | Bin 0 -> 5844 bytes src/inkwell/mail_templates.py | 32 +++++++++++++++++++++++------- src/inkwell/mailer.py | 8 +++++++- tests/test_mail_templates.py | 31 +++++++++++++++++++++++------ 5 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 src/inkwell/assets/email-logo.png diff --git a/pyproject.toml b/pyproject.toml index a7f06e7..6f82d67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/inkwell/assets/email-logo.png b/src/inkwell/assets/email-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..204fcf0d98b19d1ca788e7a4618ed0ea9aa90170 GIT binary patch literal 5844 zcmV;_7AxtAP) zr>NtC&xHZ!x#75sf;u?hjHm;Oq9`hYio*k06a*1uTo{&7Hrc`s*~rp$-iH>FkWRWf z>9y+qJ|*4N)u+49sk&8l>t00yC|;P|#!BH75!GHH?FH3dK^6)hLK7epNQrlxMp(*# z0u=iZ3I+BkWSyYaDp;d6tI~Vy*ck61RXpCb=*!$zmQ_Ab!2lHff#c#4hY@$1QOp;Z zr>ye1zTUgH#X5kP6@Yx1n_f)$MIu}eoDXZyNOi z$Y0XpWNpa^;7TAp+V5vLLJ{zeU+K|}dTv`2%{Pi#0Te84-dX$gaDm@pw)RHdSimgc zPrhC|zK+HlM7#hBKR>30cv7BI$W_r;zu`)vkSSJ*^@q&vyLLo!#Su$T5p>a#7DEvk z4K$9_dJUtJ14_xG>09!~t0DL!vBHQHz@aa)+xoSd3Y-~<^%+J8%XGP1mEJFJLw#4I z>$`%&FIx`sYqcDc{6_3_x4i0KMT>HV)OSU7-UG;dyhW)w&jZ8i8-!uhk%-6(zHNEK z>)Ir$Qvt}Vc4= z;xddF5iTxF$sS{8(U8XgMT>JT7noN6s0<^vl&HZOy|;f*>oT=HS-iMKYYX`X$ckuW zh7l98Uzc-wZC8wIYy!!iC$cW5i-M#(jCxY(*%Dm5;Z0&}6YM#|NMa%$t4rqDJHM;^V6{Df!v7E^`3>h$`1K#FKUm4ZFI#r-h_#9c z+$Ufd3CQmkeM+O=+twa_ph}{9#2R_@V}{`%9*?p{RDR&d9$?SXtVRv|soMeD9f=Hg z%P&+k^hvzK2#-)$M0ty2d+*MNN?ZE|R?6j=IHJf%+&jMcq z7(L9wqhZQy#e#3DE|c9fR#8TWLUr&eQSC9i09Ts%g<|A*O=)@X=N}zv(K<)ioTqqZ za`lshIn9zgZv?BoSCA9U9>9GjeXM!vb%kXGfBxL=!r=ZwxgYKut0+8FeVLqQ(Ty?M zlu{>o6(o5}&Im`jA4a{ZdHerr-t1*Hf+ubZRK|qqik0iCFWblmz9gUUN`z)!0#lG0 zE>Kn;=)*H-S?&A$$8WPZrb*!VVBECe*GD_HN$%zk0GfsxXc+G}jOeqQ{8yYKoOiZR zS`Nd-B5M-=Lpa9Gsz~SsBe{OSl=*B?PXUrR#wVP^QzEfKV>;a&m7f6kF z6h`!vl`HOgRx$VM+LK>v7=4#TW(It{8eW@K`|=JcfVAZ8&SZon0uR0vI{5)^zF0Wx z6d}J*asLat#y_{SzHw(hEZ`P17A z+98=^_5cp`?q`Z|(?TbIidT5|Y5ssI0LPg9Rn?nSYSX8SFytJ;vS8$RO`*-% zb+fae$Eo!QG69(aaDcrBv3=0nv>}YSOHc}4nW6b=mHocJgZl^W4R8{NeH?dsc!Fk+ z&6#okbgRX&O@;sbta#y_@H)c(wx6@w|6mFr>3z6R@z16968X2k5-#W&=;(IOSVc*h z{rBnC0ot~zV~`2V6hIQ&wNLTbKkWU4*;(+&O@Ra0=ikz-*%;o|f5<>*9sC|<3LuF+ z^QPipVd&+whUf0KXygl=C7SqAc*&pB3@+*8yyg#>0!UiltW|ut&|V_naDmX{RH3K@ zMvT|^!`zq;y~Il9QQ&H23Lptt7CbRYAvQM3C*%gk^#OSNADWHZ!`u2dX$V6GI&v(U&VWM(K%J;AwKe=r4*bl#e+$=e$`nLD+GTL%UHet3dr z*FJj%(77F4(DQK8J3INo6hKlbDT7yLgihr2G#GoYpA;{=KVJnM@~|d_hn%1Em6>CJ zBr;{TB5!Z#*hTf9*K25mh zH-V0B_y1Mfx=v8pfdV+~MCfsopnuSW>EZvR9u0rF$Diy|9@U8{fCM-DD{Aexf9-if zzpg@Y35*yQen&Te2X3(FeOldPOlGD465L0NYW|5@=LmneHjwd8y{6f)J^b|#>L=WE zk-OUcm6h{ZoE+-24MS(%B>~xL3a-cBCf%o;+hiL*Ou$sp6Kcy|)d3hj_ zA9_i%V~@S{rI~^7t1$(Tpi0VM`ReKgkm?Qe@lWvzGrv%LT+11vqrLu#=9xDX`|?9C z>lUT}64Z*-D(Jvr9f1L{nwm+mjxYs~pcZ{s{jnaW3O8I}ArhF` z{Xl{J^&Q_rc>8goP0Pr|=mh2TllqLbQ-?`i&JV>F0X$HEKuS#&+g zobwOG6hH!6U8w+`xznOq<3N|_zkd+=wU_h}9=|m(-Ki0pDS!m@!}>r0TshF9{~5xe zQn+WVr^Z>^0ot?_9=S>A+tqCK!(j>_!B`fo-4yuy@bDyK-c+pL8hrVTw7^y2n;krS zVKkYqEW!NaBXy)p^CK^7I<_I`@aMh;aK$+mw+<4rvtk^igQ&vAIcA@kAU5YIF1$}u zbg1TK8u}(TF@#0104oo~kyCNt>21V~n2$rVDdanQMe`W?6xId5bDt z-PIbNzSAUsESLg_vsY&-zFuARvD*e&bn6hGc-+PmKpd^#3S*~M{RvLUg<(U?iMd!Y z1rR5PM{uh=NTi&3`H;ex&$$r@d)Pqrs!0 zZ7bovtA$^mWq!W;Fa;2XrzdM$b9BYhQ@2}O>EL;Mqs=sdY}&9bu;ouQPrg?5BAO8% zQvf!7{}Xi@c{$n&i(%d7#4F5XVG6(|UwXVFs99s5X#&-iDFB;#oSK+(XLUWv40cr? zrT}cpNQ1xJ>nE#Gyd$a5I!72Y%xwMZ!<+>Q)1Ct`eSur)(XU+oIv^h`l#lK+Xy1-2A8@5#)!8w+^9yswh=yH;9dV8U7 z7vY#D@rfnbnF6TA+D(e-3l*O&uX$*~M4%LOYAXypGcXHeZu59YlsrrU9Hp!rK3Jfc zItRY{G5Pw~XdSp}ea&x#bI%krH$Vuc0De(g4paWAd3C0;J?&_sYnKamUT$$wFH;CL zF$DlD`CjqptD2v-#5ocx9)7=??JeBl;-h?in4O&CjVg*KT?#(;U0&0 za&!T_H&59<>vQuZ!mm!Rp8QA8+~{OmcEZAy?)kjn@B*0mg}sm0sVxkzbU`b>P;t$p znzftUl?(tMFLKvsc5DG`-G%Kdoe$mQuQE3OBd@x5yv=zl+|k@S%&`Tq%x2T2XJ_He zlPmuJV1Z)h=Z^haLMkYNe{YC)6tUyjUwXw_dj&A;ii&oBNf|sl#j#(BZ7bHsJA&A8 z?63Xf&!G#Tb&k-zV?_bHGgl>LpPoqj@#lC)5Ic@7fX#WKuWAnLUiJUUvmE<%gk{qX za}1EwL}1tc&_Cj@&#E}{^V1gCuq~QVC6By4rT~&!acSr=ds4hYmlG@gpZm2Vzlu63 zDsfMrAV>b{hiqK@%5ARjdMf_^?#JlGlWg2H=a?f4z|M$mZCh5|%5B&lrC^efWtk?B zS-w#le6>9=_bG2;+v%fN1f46dN z0YKm;`IYP99fc83u5AJt2ABdcT+S4L;cBJ;3|BJ+V7Qto0K?Tx0T{04$O1@r={T2> zlF`8Q22E)-FK_jkp(RXH%uLX9;JC10%d_dJv-;CM~?l1H>)jb8E6nnHn9NQz_qhZ1W z?w$nBuI?R$Cw`xNGhWt*DAsC0IeH+x#+fkXF{{c5a7I-Z_7q3l&pl zD`tNMtJW+0?%L~<>V>ZDg^PO&g9nJ~w)&N6(XACg;o_W)Ktt|25GCc}uC2OIXv+ZpVM8pj1n-jN%{95!Cz}~2YWf)N>`*pcoqg2hO_G=v(F1`1*%~*0H=B$vBd_*aIQ+mI= z4Ye&4a%e=u(OW=_L)$SN%60vbB6TE8n}kY2&n#wD~~SIz?v~ z(e+J9TFHQ{Zu<_{eL+~mqi1#7cOawG-xt)J@MANK=n+^LZt_DN<_A#ycNJtD@Ldc{ z3-`5#5gqcOPkGM|H~FEC6J)3hek{q@y5njQ8H+hZYQ&EycrIgW-sP(2mg2gspx$%g z6fVxb6x2i@tG??pj4&Kfid!;z?x#o>e$U>0`GyyV!)HsJ}gr}6i45a%J3Y21xh^$lSwSuhC=vC>x ew{4DhkpB<9eJc9t!tQ4P0000` 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'' - f'' + f'' f'{escape(site)}' "" f'' @@ -100,7 +118,7 @@ def _render( f'color:{MUTED}">{escape(footer)}' "" ) - 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: diff --git a/src/inkwell/mailer.py b/src/inkwell/mailer.py index 98dfa51..953ce75 100644 --- a/src/inkwell/mailer.py +++ b/src/inkwell/mailer.py @@ -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 diff --git a/tests/test_mail_templates.py b/tests/test_mail_templates.py index d8123bb..86f2caf 100644 --- a/tests/test_mail_templates.py +++ b/tests/test_mail_templates.py @@ -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 "" + assert logo.get_content() == mail_templates.logo_png() assert parsed["Subject"] == "Inkwell test email"