diff --git a/src/app/api/avatar/route.ts b/src/app/api/avatar/route.ts new file mode 100644 index 0000000..5ba4667 --- /dev/null +++ b/src/app/api/avatar/route.ts @@ -0,0 +1,83 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/auth"; +import { + avatarDomainCandidates, + normalizeAvatarDomain, +} from "@/lib/senderAvatar"; + +export const runtime = "nodejs"; + +const AVATAR_SIZE = "128"; +const MAX_IMAGE_BYTES = 256 * 1024; + +const imageHeaders = { + "Cache-Control": "private, max-age=86400, stale-while-revalidate=604800", + "Cross-Origin-Resource-Policy": "same-origin", + "X-Content-Type-Options": "nosniff", +}; + +function emptyResponse(status: number) { + return new Response(null, { + status, + headers: { + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + }); +} + +export async function GET(request: NextRequest) { + const session = await auth(); + const smokeTest = process.env.MAIL_BROWSER_SMOKE_TESTS === "1"; + if (!session?.user && !smokeTest) return emptyResponse(401); + + const domain = normalizeAvatarDomain( + request.nextUrl.searchParams.get("domain") ?? "", + ); + 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 image = await upstream.arrayBuffer(); + if (image.byteLength === 0 || image.byteLength > MAX_IMAGE_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. + } + } + + return emptyResponse(404); +} diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx index ff948b2..addbc8e 100644 --- a/src/app/smoke-tests/SmokeHarness.tsx +++ b/src/app/smoke-tests/SmokeHarness.tsx @@ -15,7 +15,7 @@ const fixtureEmails: Email[] = [ threadId: "thread-maya", mailboxIds: { "mailbox-inbox": true }, subject: "Quarterly plan", - from: [{ name: "Maya Chen", email: "maya@example.test" }], + from: [{ name: "GitHub", email: "notifications@github.com" }], to: [{ name: "Phillip Carter", email: "phillip@example.test" }], cc: null, replyTo: null, @@ -36,7 +36,7 @@ const fixtureEmails: Email[] = [ threadId: "thread-release", mailboxIds: { "mailbox-inbox": true }, subject: "Release notes", - from: [{ name: "Noah Williams", email: "noah@example.test" }], + from: [{ name: "Noah Williams", email: "updates@missing-brand.com" }], to: [{ name: "Phillip Carter", email: "phillip@example.test" }], cc: null, replyTo: null, diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index efcefe2..f14e78f 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -1,5 +1,13 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; import { EmailAddress } from "@/lib/types"; -import { colorFor, initialsFor } from "@/lib/senderAvatar"; +import { + colorFor, + initialsFor, + senderAvatarDomain, + senderAvatarUrl, +} from "@/lib/senderAvatar"; interface Props { from: EmailAddress[] | null; @@ -10,10 +18,36 @@ export default function SenderAvatar({ from, size = 36 }: Props) { const sender = from?.[0] ?? null; const initials = initialsFor(from); const color = colorFor(sender?.email ?? ""); + const imageUrl = senderAvatarUrl(from); + const domain = senderAvatarDomain(sender?.email ?? ""); + const [loadedImageUrl, setLoadedImageUrl] = useState(null); + const [failedImageUrl, setFailedImageUrl] = useState(null); + const imageRef = useRef(null); + const imageState = + !imageUrl || failedImageUrl === imageUrl + ? "failed" + : loadedImageUrl === imageUrl + ? "loaded" + : "loading"; + + useEffect(() => { + const image = imageRef.current; + if (!imageUrl || !image?.complete) return; + + // A cached image can finish before React hydrates and attaches onLoad. + // Check it once after commit so it cannot remain invisibly "loading". + const frame = requestAnimationFrame(() => { + if (image.naturalWidth > 0) setLoadedImageUrl(imageUrl); + else setFailedImageUrl(imageUrl); + }); + return () => cancelAnimationFrame(frame); + }, [imageUrl]); return (
{initials} + {imageUrl && imageState !== "failed" && ( + // A plain image avoids running this already-small, same-origin asset + // through a second image optimizer. + // eslint-disable-next-line @next/next/no-img-element + setLoadedImageUrl(imageUrl)} + onError={() => setFailedImageUrl(imageUrl)} + /> + )}
); } diff --git a/src/lib/__tests__/senderAvatar.test.ts b/src/lib/__tests__/senderAvatar.test.ts index 4b4db79..a7b5b92 100644 --- a/src/lib/__tests__/senderAvatar.test.ts +++ b/src/lib/__tests__/senderAvatar.test.ts @@ -3,7 +3,15 @@ import assert from "node:assert/strict"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import SenderAvatar from "../../components/SenderAvatar"; -import { colorFor, initialsFor, PALETTE } from "../senderAvatar"; +import { + avatarDomainCandidates, + colorFor, + initialsFor, + normalizeAvatarDomain, + PALETTE, + senderAvatarDomain, + senderAvatarUrl, +} from "../senderAvatar"; import { EmailAddress } from "../types"; function addr(email: string, name?: string): EmailAddress { @@ -92,8 +100,42 @@ describe("initialsFor", () => { }); }); +describe("sender artwork", () => { + it("extracts a normalized sender domain", () => { + assert.equal( + senderAvatarDomain("updates@MAIL.GitHub.com"), + "mail.github.com", + ); + }); + + it("does not use mailbox-provider branding for a person", () => { + assert.equal(senderAvatarDomain("person@gmail.com"), null); + assert.equal(senderAvatarDomain("person@fastmail.com"), null); + assert.equal(senderAvatarUrl([addr("person@yahoo.com")]), null); + }); + + it("rejects malformed, private, and reserved domains", () => { + assert.equal(normalizeAvatarDomain("localhost"), null); + assert.equal(normalizeAvatarDomain("127.0.0.1"), null); + assert.equal(normalizeAvatarDomain("mail.example.test"), null); + assert.equal(normalizeAvatarDomain("bad_domain.com"), null); + }); + + it("tries a sender subdomain before its organization domain", () => { + assert.deepEqual(avatarDomainCandidates("tracking.usps.com"), [ + "tracking.usps.com", + "usps.com", + ]); + assert.deepEqual(avatarDomainCandidates("alerts.company.co.uk"), [ + "alerts.company.co.uk", + "company.co.uk", + ]); + assert.deepEqual(avatarDomainCandidates("github.com"), ["github.com"]); + }); +}); + describe("SenderAvatar", () => { - it("renders deterministic initials without external image requests", () => { + it("renders initials beneath a same-origin sender image", () => { const from = [addr("octocat@github.com", "GitHub Octocat")]; const first = renderToStaticMarkup( createElement(SenderAvatar, { from, size: 36 }), @@ -104,8 +146,22 @@ describe("SenderAvatar", () => { assert.equal(first, second); assert.match(first, />GO { + const markup = renderToStaticMarkup( + createElement(SenderAvatar, { + from: [addr("phillip@gmail.com", "Phillip Carter")], + size: 36, + }), + ); + + assert.match(markup, />PC>> 0; @@ -34,3 +78,80 @@ export function initialsFor(from: EmailAddress[] | null): string { } return s.email.slice(0, 2).toUpperCase(); } + +/** + * Normalize a hostname before it is sent to the fixed favicon provider. + * Internationalized hostnames are expected in their ASCII/punycode form. + */ +export function normalizeAvatarDomain(value: string): string | null { + const domain = value.trim().toLowerCase().replace(/\.$/, ""); + if ( + domain.length === 0 || + domain.length > 253 || + !domain.includes(".") || + RESERVED_SUFFIXES.some((suffix) => domain.endsWith(suffix)) + ) { + return null; + } + + const labels = domain.split("."); + if ( + labels.some( + (label) => + label.length === 0 || + label.length > 63 || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ) || + labels.every((label) => /^\d+$/.test(label)) + ) { + return null; + } + + return domain; +} + +/** + * Public mailbox domains identify the email host, not the sender. Showing a + * Gmail or Fastmail logo for a person would be more misleading than initials. + */ +function isPersonalMailboxDomain(domain: string): boolean { + for (const provider of PERSONAL_MAIL_DOMAINS) { + if (domain === provider || domain.endsWith(`.${provider}`)) return true; + } + return false; +} + +export function senderAvatarDomain(email: string): string | null { + const separator = email.lastIndexOf("@"); + if (separator < 1 || separator === email.length - 1) return null; + + const domain = normalizeAvatarDomain(email.slice(separator + 1)); + if (!domain || isPersonalMailboxDomain(domain)) return null; + return domain; +} + +export function senderAvatarUrl(from: EmailAddress[] | null): string | null { + const domain = senderAvatarDomain(from?.[0]?.email ?? ""); + return domain ? `/api/avatar?domain=${encodeURIComponent(domain)}` : null; +} + +/** + * Try the sender's exact host first, then its likely organization domain. + * This lets tracking.usps.com resolve to the icon published for usps.com. + */ +export function avatarDomainCandidates(value: string): string[] { + const domain = normalizeAvatarDomain(value); + if (!domain) return []; + + const labels = domain.split("."); + const lastTwo = labels.slice(-2).join("."); + const rootLabelCount = MULTI_PART_PUBLIC_SUFFIXES.has(lastTwo) ? 3 : 2; + const organizationDomain = + labels.length > rootLabelCount + ? labels.slice(-rootLabelCount).join(".") + : domain; + + return organizationDomain === domain + ? [domain] + : [domain, organizationDomain]; +} diff --git a/src/proxy.ts b/src/proxy.ts index 58ec9b5..4e58ee9 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,8 +1,7 @@ export { auth as proxy } from "@/auth"; export const config = { - // The smoke-test harness has its own server-side environment gate. Keeping - // it outside auth lets CI exercise the real client interactions without a - // mailbox account, while normal deployments still return a 404. - matcher: ["/((?!api/auth|api/health|_next/static|_next/image|favicon\\.ico|icon\\.svg|login|smoke-tests).*)"], + // The avatar route authenticates its own requests, and the smoke-test + // harness has its own server-side environment gate. + matcher: ["/((?!api/auth|api/avatar|api/health|_next/static|_next/image|favicon\\.ico|icon\\.svg|login|smoke-tests).*)"], }; diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts index e8802d2..30a95e6 100644 --- a/tests/smoke/mail.spec.ts +++ b/tests/smoke/mail.spec.ts @@ -1,5 +1,14 @@ import { expect, test } from "@playwright/test"; +const testAvatarSvg = + ''; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/avatar?*", (route) => + route.fulfill({ status: 404 }), + ); +}); + test("serves the themed favicon without an auth redirect", async ({ request, }) => { @@ -9,6 +18,35 @@ test("serves the themed favicon without an auth redirect", async ({ expect(response.headers()["content-type"]).toContain("image/svg+xml"); }); +test("loads sender artwork and falls back cleanly when it is unavailable", async ({ + page, +}) => { + await page.unroute("**/api/avatar?*"); + await page.route("**/api/avatar?*", (route) => { + const domain = new URL(route.request().url()).searchParams.get("domain"); + return domain === "github.com" + ? route.fulfill({ + status: 200, + contentType: "image/svg+xml", + body: testAvatarSvg, + }) + : route.fulfill({ status: 404 }); + }); + + await page.goto("/smoke-tests"); + + const brandedAvatar = page.locator('[data-avatar-domain="github.com"]'); + await expect(brandedAvatar).toHaveAttribute("data-avatar-state", "loaded"); + await expect(brandedAvatar.locator("img")).toHaveCSS("opacity", "1"); + + const fallbackAvatar = page.locator( + '[data-avatar-domain="missing-brand.com"]', + ); + await expect(fallbackAvatar).toHaveAttribute("data-avatar-state", "failed"); + await expect(fallbackAvatar).toContainText("NW"); + await expect(fallbackAvatar.locator("img")).toHaveCount(0); +}); + test("opens a conversation from a desktop click", async ({ page }) => { await page.goto("/smoke-tests");