From d69519f4462c1c1504f33f2bbeeb137ea83ea832 Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Fri, 24 Jul 2026 13:03:40 -0700 Subject: [PATCH] higher res icons --- package.json | 3 +- pnpm-lock.yaml | 7 +- src/app/api/avatar/route.ts | 128 +++++++++++++++++-------- src/lib/__tests__/avatarImage.test.ts | 38 ++++++++ src/lib/__tests__/senderAvatar.test.ts | 2 +- src/lib/avatarImage.ts | 31 ++++++ src/lib/senderAvatar.ts | 4 +- 7 files changed, 167 insertions(+), 46 deletions(-) create mode 100644 src/lib/__tests__/avatarImage.test.ts create mode 100644 src/lib/avatarImage.ts diff --git a/package.json b/package.json index 3b518f6..d8b68f7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "next-auth": "^5.0.0-beta.30", "pino": "^10.3.1", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "sharp": "^0.34.5" }, "devDependencies": { "@playwright/test": "^1.61.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc7267a..a483c53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + sharp: + specifier: ^0.34.5 + version: 0.34.5 devDependencies: '@playwright/test': specifier: ^1.61.1 @@ -2719,8 +2722,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -4557,7 +4559,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - optional: true shebang-command@2.0.0: dependencies: diff --git a/src/app/api/avatar/route.ts b/src/app/api/avatar/route.ts index 5ba4667..8111256 100644 --- a/src/app/api/avatar/route.ts +++ b/src/app/api/avatar/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { auth } from "@/auth"; +import { renderAvatarImage } from "@/lib/avatarImage"; import { avatarDomainCandidates, normalizeAvatarDomain, @@ -7,8 +8,8 @@ import { export const runtime = "nodejs"; -const AVATAR_SIZE = "128"; -const MAX_IMAGE_BYTES = 256 * 1024; +const MAX_SOURCE_BYTES = 1024 * 1024; +const MAX_OUTPUT_BYTES = 512 * 1024; const imageHeaders = { "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800", @@ -26,6 +27,64 @@ function emptyResponse(status: number) { }); } +interface AvatarSource { + image: ArrayBuffer; + source: "high-resolution" | "favicon"; +} + +async function fetchImage( + url: URL, + source: AvatarSource["source"], + allowedHosts: Set, +): Promise { + const upstream = await fetch(url, { + headers: { + Accept: "image/svg+xml,image/png,image/webp,image/jpeg,image/*;q=0.8", + }, + next: { revalidate: 86_400 }, + signal: AbortSignal.timeout(4_000), + }); + + const finalHost = new URL(upstream.url).hostname; + const contentType = upstream.headers.get("content-type")?.split(";")[0]; + const contentLength = Number(upstream.headers.get("content-length") ?? 0); + if ( + !upstream.ok || + !allowedHosts.has(finalHost) || + !contentType?.startsWith("image/") || + contentLength > MAX_SOURCE_BYTES + ) { + return null; + } + + const image = await upstream.arrayBuffer(); + if (image.byteLength === 0 || image.byteLength > MAX_SOURCE_BYTES) { + return null; + } + + return { image, source }; +} + +async function fetchHighResolutionIcon( + domain: string, +): Promise { + const url = new URL(`https://favicon.im/${encodeURIComponent(domain)}`); + url.searchParams.set("larger", "true"); + url.searchParams.set("throw-error-on-404", "true"); + return fetchImage( + url, + "high-resolution", + new Set(["favicon.im", "a.favicon.im"]), + ); +} + +async function fetchFavicon(domain: string): Promise { + const url = new URL("https://www.google.com/s2/favicons"); + url.searchParams.set("domain", domain); + url.searchParams.set("sz", "256"); + return fetchImage(url, "favicon", new Set(["www.google.com"])); +} + export async function GET(request: NextRequest) { const session = await auth(); const smokeTest = process.env.MAIL_BROWSER_SMOKE_TESTS === "1"; @@ -36,46 +95,35 @@ export async function GET(request: NextRequest) { ); if (!domain) return emptyResponse(400); - for (const candidate of avatarDomainCandidates(domain)) { - try { - const upstreamUrl = new URL("https://www.google.com/s2/favicons"); - upstreamUrl.searchParams.set("domain", candidate); - upstreamUrl.searchParams.set("sz", AVATAR_SIZE); - - const upstream = await fetch(upstreamUrl, { - headers: { - Accept: "image/png,image/webp,image/jpeg,image/*;q=0.8", - }, - next: { revalidate: 86_400 }, - signal: AbortSignal.timeout(3_500), - }); - - const contentType = upstream.headers.get("content-type")?.split(";")[0]; - const contentLength = Number(upstream.headers.get("content-length") ?? 0); - if ( - !upstream.ok || - !contentType?.startsWith("image/") || - contentType === "image/svg+xml" || - contentLength > MAX_IMAGE_BYTES - ) { - continue; - } + const candidates = avatarDomainCandidates(domain); + const resolvers = [fetchHighResolutionIcon, fetchFavicon]; - const image = await upstream.arrayBuffer(); - if (image.byteLength === 0 || image.byteLength > MAX_IMAGE_BYTES) { - continue; - } + for (const resolver of resolvers) { + for (const candidate of candidates) { + try { + const result = await resolver(candidate); + if (!result) continue; + + const image = await renderAvatarImage(result.image); + if (image.byteLength === 0 || image.byteLength > MAX_OUTPUT_BYTES) { + continue; + } - return new Response(image, { - status: 200, - headers: { - ...imageHeaders, - "Content-Length": String(image.byteLength), - "Content-Type": contentType, - }, - }); - } catch { - // Missing, slow, or unavailable artwork is an expected fallback case. + const body = new Uint8Array(image.byteLength); + body.set(image); + + return new Response(body.buffer, { + status: 200, + headers: { + ...imageHeaders, + "Content-Length": String(image.byteLength), + "Content-Type": "image/png", + "X-Avatar-Source": result.source, + }, + }); + } catch { + // Missing, slow, or malformed artwork is an expected fallback case. + } } } diff --git a/src/lib/__tests__/avatarImage.test.ts b/src/lib/__tests__/avatarImage.test.ts new file mode 100644 index 0000000..a42904b --- /dev/null +++ b/src/lib/__tests__/avatarImage.test.ts @@ -0,0 +1,38 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import sharp from "sharp"; +import { AVATAR_PIXEL_SIZE, renderAvatarImage } from "../avatarImage"; + +describe("renderAvatarImage", () => { + it("normalizes a small raster icon to a dense square PNG", async () => { + const source = await sharp({ + create: { + width: 32, + height: 32, + channels: 4, + background: "#24292f", + }, + }) + .png() + .toBuffer(); + + const result = await renderAvatarImage(source); + const metadata = await sharp(result).metadata(); + + assert.equal(metadata.format, "png"); + assert.equal(metadata.width, AVATAR_PIXEL_SIZE); + assert.equal(metadata.height, AVATAR_PIXEL_SIZE); + }); + + it("rasterizes vector artwork at the full output resolution", async () => { + const vector = Buffer.from( + '', + ); + + const result = await renderAvatarImage(vector); + const metadata = await sharp(result).metadata(); + + assert.equal(metadata.width, AVATAR_PIXEL_SIZE); + assert.equal(metadata.height, AVATAR_PIXEL_SIZE); + }); +}); diff --git a/src/lib/__tests__/senderAvatar.test.ts b/src/lib/__tests__/senderAvatar.test.ts index a7b5b92..a927559 100644 --- a/src/lib/__tests__/senderAvatar.test.ts +++ b/src/lib/__tests__/senderAvatar.test.ts @@ -147,7 +147,7 @@ describe("SenderAvatar", () => { assert.equal(first, second); assert.match(first, />GO { + const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : input; + + return sharp(Buffer.from(bytes), { + failOn: "warning", + limitInputPixels: 4096 * 4096, + }) + .rotate() + .resize(AVATAR_PIXEL_SIZE, AVATAR_PIXEL_SIZE, { + fit: "contain", + background: { r: 255, g: 255, b: 255, alpha: 0 }, + kernel: sharp.kernel.lanczos3, + withoutEnlargement: false, + }) + .png({ + compressionLevel: 9, + palette: false, + }) + .toBuffer(); +} diff --git a/src/lib/senderAvatar.ts b/src/lib/senderAvatar.ts index fc12363..7cada41 100644 --- a/src/lib/senderAvatar.ts +++ b/src/lib/senderAvatar.ts @@ -132,7 +132,9 @@ export function senderAvatarDomain(email: string): string | null { export function senderAvatarUrl(from: EmailAddress[] | null): string | null { const domain = senderAvatarDomain(from?.[0]?.email ?? ""); - return domain ? `/api/avatar?domain=${encodeURIComponent(domain)}` : null; + return domain + ? `/api/avatar?domain=${encodeURIComponent(domain)}&v=2` + : null; } /** -- 2.51.2