From 728b94b9f7dfee834194c636fe1cdf4d8b4d80a8 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 7 May 2026 17:26:16 -0400 Subject: [PATCH] dynamic email icons --- app/api/email-assets/[name]/route.ts | 68 +++++++++++++++++++++++++++ emails/post.tsx | 13 ++++- emails/shared.tsx | 16 +++++++ public/email-assets/comment.png | Bin 864 -> 0 bytes public/email-assets/quote.png | Bin 690 -> 0 bytes 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 app/api/email-assets/[name]/route.ts delete mode 100644 public/email-assets/comment.png delete mode 100644 public/email-assets/quote.png diff --git a/app/api/email-assets/[name]/route.ts b/app/api/email-assets/[name]/route.ts new file mode 100644 index 00000000..0d6ed45e --- /dev/null +++ b/app/api/email-assets/[name]/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import sharp from "sharp"; + +export const runtime = "nodejs"; + +// Path data mirrors `components/Icons/QuoteTiny.tsx` and +// `components/Icons/CommentTiny.tsx`. The icon components use +// `fill="currentColor"` for the web; here we bake the color in directly so +// the rendered PNG carries the resolved theme color. If the source icons +// are restyled, update these in lockstep. +const ICONS: Record< + string, + { viewBox: string; svg: (color: string) => string } +> = { + quote: { + viewBox: "0 0 16 16", + svg: (color) => + ``, + }, + comment: { + viewBox: "0 0 16 16", + svg: (color) => + ``, + }, +}; + +// Allow rgb()/rgba()/#hex/named CSS colors. Reject anything that could +// break out of the SVG attribute (quotes, angle brackets, backslashes). +const sanitizeColor = (input: string): string => { + if (input.length > 64) return "rgb(0, 0, 0)"; + if (/[<>"'\\]/.test(input)) return "rgb(0, 0, 0)"; + return input; +}; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ name: string }> }, +) { + const { name: rawName } = await params; + const name = rawName.replace(/\.png$/, ""); + const icon = ICONS[name]; + if (!icon) return new NextResponse(null, { status: 404 }); + + const { searchParams } = req.nextUrl; + const color = sanitizeColor(searchParams.get("color") ?? "rgb(0, 0, 0)"); + const sizeParam = parseInt(searchParams.get("size") ?? "16", 10); + const displaySize = + Number.isFinite(sizeParam) && sizeParam > 0 + ? Math.min(128, Math.max(8, sizeParam)) + : 16; + // Render at 2x so Retina mail clients (Apple Mail) stay crisp when the + // displays at `displaySize`. + const renderSize = displaySize * 2; + + const svg = `${icon.svg(color)}`; + + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + + return new NextResponse(png, { + headers: { + "Content-Type": "image/png", + // URL fully identifies the rendering (name + color + size), so the + // response is content-addressed — safe to cache forever. + "Cache-Control": "public, max-age=31536000, immutable", + "CDN-Cache-Control": "s-maxage=31536000, immutable", + }, + }); +} diff --git a/emails/post.tsx b/emails/post.tsx index 25c7b640..b4ade0cc 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -36,6 +36,7 @@ import { defaultEmailTheme, LeafletWatermark, MailHead, + makeEmailIconUrl, makeStaticUrl, resolveColors, type EmailTheme, @@ -392,7 +393,11 @@ export const PostEmail = (props: Partial = {}) => { See quotes @@ -408,7 +413,11 @@ export const PostEmail = (props: Partial = {}) => { See comments diff --git a/emails/shared.tsx b/emails/shared.tsx index 0da0c3c9..4dde8a1c 100644 --- a/emails/shared.tsx +++ b/emails/shared.tsx @@ -106,6 +106,22 @@ export const makeStaticUrl = (assetsBaseUrl: string) => { return (filename: string): string => `${base}/email-assets/${filename}`; }; +// Build a URL for the dynamic icon endpoint that bakes the theme color +// into a PNG at request time. We render PNG (not SVG) because Gmail's +// image proxy strips SVG for @gmail.com inboxes, and Microsoft removed +// SVG support across new Outlook + Outlook.com in late 2025. +export const makeEmailIconUrl = ( + assetsBaseUrl: string, + name: "quote" | "comment", + color: string, + size = 16, +): string => { + const base = assetsBaseUrl.replace(/\/$/, ""); + const params = new URLSearchParams({ color }); + if (size !== 16) params.set("size", String(size)); + return `${base}/api/email-assets/${name}.png?${params.toString()}`; +}; + // React's TypeScript types don't include the deprecated `bgcolor` HTML // attribute on , but every email client (especially Outlook) reads it // directly — so we spread it via a typed cast. Combined with an inline diff --git a/public/email-assets/comment.png b/public/email-assets/comment.png deleted file mode 100644 index 115320f925b0e23a99ff986603459f10e1fc035e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 864 zcmeAS@N?(olHy`uVBq!ia0y~yU{C~M4mJh`hVUnc@);NyI14-?iy0WiR6&^0Gf3qF z0|NtNage(c!@6@aFBupZSkfJR9T^xl_H+M9WMyDr`s(T87*fIbHrzW;%27a9fywNI z#|HmuW)B@3%f^Q+2h}<37(a6In0xpMY&xy?gJoXqq);{PBk7aB=}mNyTCd>b5cg2- z%7UL?681dyT>HXFpdeqHBNpa8n*I%R5 zCZA+CN!6NK^y}|FVb#ksT$To{Ol*!e(Vb#5(>ZA_Z_?a{1sX2}gl@z*oKQE({^H}{ zS8XF#{_S|Mi0^}W^Zfqj9LU+~v`}H$<(G2>Ik(y7{OX-u;<74wZwNGE3Kr zT}@U|6YluisNiezQ*`M)A(q>w{jO|A`rnV&+!sFaebT%+K?aIn{wr>%ndzgpH%9Nf zpx{S#j=!!mAMakBb?uhCi1@D{mHTb{KaZo-=gx0_fp31i8{I!&KEqd~{VHMd zqO9!KCH8|FgDMy|v8|ohfxn_1UvZz4ec6 z4xh_+ImE24!jb2|v7fo;9`B6XU#nBAJ5Rqp{_lN4$m-DSjS(C<2_K!=)U7X@)rFj$ zX7)^@*Rw?T>-uhj&cE`CNE*viyI20FUZPhZH?Ykvv?Yt#ZimjO9Yh*18R)*~2Yf^aj zv}&)~nd4KvQaipj^fgQu`dal@i1GIfw^Jtf-*iT~r@%HpnBkdcWISK|Ys((#FCrxExy0{*oR`jBV&eODAbtL(*?k}6Ehi`juD%*^s>7z?N%v&S)eYPG zUNWS3H0)IHS9oEb8-M-Lwa1T7pI*w->9kPcgz$==&r|l@&C@TAiCkL!8WpR~=`cxs(!PVaH_Q1&UQg&|*(LS8Wd3{R&P&p(+21d| z7hW_mAor`dC4=a_8osm2+MI0bUfSKCHe=Q;lgWE7>TgmpuSv-Idu_Kft7O67WD%Xp zjS*A2k2*=|DSFOhc*xV)ywA|rLH~jG>c<7rj!)$?lRea%tn6k!PTG6IZsS6W`{U$N`o#`ve{a|(~OKVUr({X*5O#Na3c0|SGntDnm{r-UW|boejj -- 2.51.2