feat(favicon): per-feed icon from the last sender's domain

Resolve each feed's most recent sender domain and serve its favicon at
GET /favicon/:feedId, falling back to the project icon. Icons are fetched
in the background on ingestion (direct /favicon.ico then a DuckDuckGo
fallback), cached base64 in KV keyed by domain with a 1-week TTL so the
fetch only fires when absent. Exposed via RSS <image> / Atom <icon>/<logo>
and rendered in the admin feed list, plus a landing-page feature card.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-23 14:05:14 +02:00
1 parent d299c8891d
commit eb12f21894
19 files changed
+592 -30

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { server, createMockEnv } from "../test/setup";
import {
cacheFaviconForDomain,
extractEmailDomain,
getCachedIcon,
} from "./favicon-fetcher";
import { iconKey } from "./storage";
import { MAX_ICON_BYTES } from "../config/constants";
import type { Env } from "../types";
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]);
function imageResponse(bytes: Uint8Array, contentType = "image/png") {
return new HttpResponse(bytes, { headers: { "Content-Type": contentType } });
}
describe("extractEmailDomain", () => {
it("parses a bare address", () => {
expect(extractEmailDomain("[email protected]")).toBe("github.com");
});
it("parses a display-form address", () => {
expect(extractEmailDomain("GitHub <[email protected]>")).toBe("github.com");
});
it("strips a trailing dot and lowercases", () => {
expect(extractEmailDomain("[email protected].")).toBe("example.com");
});
it("returns null when there is no address", () => {
expect(extractEmailDomain("not an email")).toBeNull();
});
});
describe("cacheFaviconForDomain", () => {
it("caches the direct /favicon.ico when available", async () => {
const env = createMockEnv() as unknown as Env;
server.use(
http.get("https://github.com/favicon.ico", () => imageResponse(PNG)),
);
await cacheFaviconForDomain("github.com", env);
const record = await env.EMAIL_STORAGE.get(iconKey("github.com"), "json");
expect(record).toMatchObject({ contentType: "image/png" });
expect((record as { data: string }).data).toBeTruthy();
expect(record).not.toHaveProperty("fetchedAt");
const icon = await getCachedIcon("github.com", env);
expect(icon?.contentType).toBe("image/png");
expect(new Uint8Array(icon!.bytes)).toEqual(PNG);
});
it("falls back to DuckDuckGo when the direct icon 404s", async () => {
const env = createMockEnv() as unknown as Env;
server.use(
http.get("https://acme.test/favicon.ico", () =>
HttpResponse.text("nope", { status: 404 }),
),
http.get("https://icons.duckduckgo.com/ip3/acme.test.ico", () =>
imageResponse(PNG, "image/x-icon"),
),
);
await cacheFaviconForDomain("acme.test", env);
const icon = await getCachedIcon("acme.test", env);
expect(icon?.contentType).toBe("image/x-icon");
});
it("writes a negative entry when no icon is found", async () => {
const env = createMockEnv() as unknown as Env;
server.use(
http.get("https://nope.test/favicon.ico", () =>
HttpResponse.text("", { status: 404 }),
),
http.get("https://icons.duckduckgo.com/ip3/nope.test.ico", () =>
HttpResponse.text("", { status: 404 }),
),
);
await cacheFaviconForDomain("nope.test", env);
const record = await env.EMAIL_STORAGE.get(iconKey("nope.test"), "json");
expect(record).toEqual({ data: null, contentType: "" });
expect(await getCachedIcon("nope.test", env)).toBeNull();
});
it("rejects oversized responses as negative", async () => {
const env = createMockEnv() as unknown as Env;
const big = new Uint8Array(MAX_ICON_BYTES + 1);
server.use(
http.get("https://big.test/favicon.ico", () => imageResponse(big)),
http.get("https://icons.duckduckgo.com/ip3/big.test.ico", () =>
HttpResponse.text("", { status: 404 }),
),
);
await cacheFaviconForDomain("big.test", env);
expect(await getCachedIcon("big.test", env)).toBeNull();
});
it("rejects non-image content types as negative", async () => {
const env = createMockEnv() as unknown as Env;
server.use(
http.get("https://html.test/favicon.ico", () =>
HttpResponse.text("<html>", {
headers: { "Content-Type": "text/html" },
}),
),
http.get("https://icons.duckduckgo.com/ip3/html.test.ico", () =>
HttpResponse.text("", { status: 404 }),
),
);
await cacheFaviconForDomain("html.test", env);
expect(await getCachedIcon("html.test", env)).toBeNull();
});
it("short-circuits when an entry already exists (no outbound fetch)", async () => {
const env = createMockEnv() as unknown as Env;
// Pre-seed a record; with MSW onUnhandledRequest:"error", any fetch fails.
await env.EMAIL_STORAGE.put(
iconKey("cached.test"),
JSON.stringify({ data: null, contentType: "" }),
);
await expect(
cacheFaviconForDomain("cached.test", env),
).resolves.toBeUndefined();
});
it("never throws on network errors", async () => {
const env = createMockEnv() as unknown as Env;
server.use(
http.get("https://err.test/favicon.ico", () => HttpResponse.error()),
http.get("https://icons.duckduckgo.com/ip3/err.test.ico", () =>
HttpResponse.error(),
),
);
await expect(
cacheFaviconForDomain("err.test", env),
).resolves.toBeUndefined();
});
});
+131
View File
@@ -0,0 +1,131 @@
import { Env } from "../types";
import {
ICON_FETCH_TIMEOUT_MS,
ICON_TTL_SECONDS,
MAX_ICON_BYTES,
} from "../config/constants";
import { iconKey } from "./storage";
import { logger } from "../lib/logger";
interface IconRecord {
data: string | null; // base64 icon bytes, or null for a negative cache entry
contentType: string;
}
/**
* Extract the lowercased domain from a `from` value, accepting either a bare
* address (`[email protected]`) or a display form (`Name <[email protected]>`). Returns null when
* no plausible address can be parsed.
*/
export function extractEmailDomain(from: string): string | null {
const match = from.match(/[^\s<>@]+@([^\s<>@]+\.[^\s<>@]+)/);
if (!match) return null;
const domain = match[1].trim().toLowerCase().replace(/\.+$/, "");
return domain || null;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
async function fetchIconFrom(
url: string,
): Promise<{ buffer: ArrayBuffer; contentType: string } | null> {
const res = await fetch(url, {
redirect: "follow",
signal: AbortSignal.timeout(ICON_FETCH_TIMEOUT_MS),
headers: { "User-Agent": "kill-the-news/1.0" },
});
if (!res.ok) return null;
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) return null;
const buffer = await res.arrayBuffer();
if (buffer.byteLength === 0 || buffer.byteLength > MAX_ICON_BYTES)
return null;
return { buffer, contentType: contentType.split(";")[0].trim() };
}
async function resolveIcon(
domain: string,
): Promise<{ buffer: ArrayBuffer; contentType: string } | null> {
const candidates = [
`https://${domain}/favicon.ico`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
];
for (const url of candidates) {
try {
const icon = await fetchIconFrom(url);
if (icon) return icon;
} catch {
// Try the next candidate; network/timeout errors must never propagate.
}
}
return null;
}
/**
* Resolve and cache the favicon for a sender domain. Idempotent and never
* throws: if a (success or negative) cache entry already exists it returns
* immediately, so callers can fire this on every email without refetching.
* The KV TTL is the sole expiry mechanism.
*/
export async function cacheFaviconForDomain(
domain: string,
env: Env,
): Promise<void> {
try {
const key = iconKey(domain);
const existing = await env.EMAIL_STORAGE.get(key, "text");
if (existing !== null) return; // present (incl. negative) → nothing to do
const icon = await resolveIcon(domain);
const record: IconRecord = icon
? {
data: arrayBufferToBase64(icon.buffer),
contentType: icon.contentType,
}
: { data: null, contentType: "" };
await env.EMAIL_STORAGE.put(key, JSON.stringify(record), {
expirationTtl: ICON_TTL_SECONDS,
});
} catch (error) {
logger.warn("Favicon cache failed", { domain, error: String(error) });
}
}
/**
* Read a cached icon for a domain. Returns null on a miss or a negative entry.
*/
export async function getCachedIcon(
domain: string,
env: Env,
): Promise<{ bytes: ArrayBuffer; contentType: string } | null> {
const record = (await env.EMAIL_STORAGE.get(
iconKey(domain),
"json",
)) as IconRecord | null;
if (!record || record.data === null) return null;
return {
bytes: base64ToArrayBuffer(record.data),
contentType: record.contentType,
};
}
+23
View File
@@ -72,6 +72,17 @@ describe("generateRssFeed", () => {
expect(result).toContain("<title>Test Newsletter</title>");
});
it("includes the per-feed icon as the channel <image>", () => {
const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<image>");
expect(result).toContain(`${BASE_URL}/favicon/${FEED_ID}`);
});
it("includes <enclosure> element for email with attachment", () => {
const result = generateRssFeed(
mockFeedConfig,
@@ -172,6 +183,18 @@ describe("generateAtomFeed", () => {
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
});
it("includes the per-feed icon as <icon> and <logo>", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
const iconUrl = `${BASE_URL}/favicon/${FEED_ID}`;
expect(result).toContain(`<icon>${iconUrl}</icon>`);
expect(result).toContain(`<logo>${iconUrl}</logo>`);
});
it("contains <feed> root element", () => {
const result = generateAtomFeed(
mockFeedConfig,
+5
View File
@@ -23,9 +23,14 @@ function buildFeed(
feedId: string,
selfUrl?: { rss?: string; atom?: string },
): Feed {
const iconUrl = `${baseUrl}/favicon/${feedId}`;
const feed = new Feed({
title: feedConfig.title,
description: feedConfig.description || "",
// Per-feed icon derived from the last sender's domain (self-falls-back to
// the project icon). image → RSS <image>/Atom <logo>; favicon → Atom <icon>.
image: iconUrl,
favicon: iconUrl,
// Computed dynamically so the id is always canonical regardless of what
// was stored in KV at feed-creation time (which may have used a stale domain).
id: `${baseUrl}/rss/${feedId}`,
+7
View File
@@ -7,6 +7,13 @@ import {
} from "../types";
import { MAX_METADATA_EMAILS } from "../config/constants";
/**
* KV key for a domain's cached favicon (shared across feeds from the same sender).
*/
export function iconKey(domain: string): string {
return `icon:${domain}`;
}
/**
* Store email data in KV
*/