fix(favicon): fall back to apex domain when subdomain hosts no icon

Senders on a subdomain that hosts no favicon (e.g. mail.example.com) left
feeds blank because both the direct /favicon.ico and the DuckDuckGo lookup
were tried only against the full subdomain. Resolution now walks up to the
apex via Domain.parents() and caches the result under the original sender
domain.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-25 23:49:43 +02:00
1 parent 4d3a94d1ec
commit 44fcbfc4f6
5 files changed
+100 -10

No files matched your search

@@ -76,6 +76,28 @@ describe("cacheFaviconForDomain", () => {
expect(icon?.contentType).toBe("image/x-icon");
});
it("falls back to the apex domain when the subdomain has no icon", async () => {
const env = createMockEnv() as unknown as Env;
server.use(
http.get("https://mail.acme.test/favicon.ico", () =>
HttpResponse.error(),
),
http.get("https://icons.duckduckgo.com/ip3/mail.acme.test.ico", () =>
HttpResponse.text("", { status: 404 }),
),
http.get("https://acme.test/favicon.ico", () =>
imageResponse(PNG, "image/vnd.microsoft.icon"),
),
);
await cacheFaviconForDomain("mail.acme.test", env);
// Cached under the original sender domain, so reads still hit.
const icon = await getCachedIcon("mail.acme.test", env);
expect(icon?.contentType).toBe("image/vnd.microsoft.icon");
expect(new Uint8Array(icon!.bytes)).toEqual(PNG);
});
it("writes a negative entry when no icon is found", async () => {
const env = createMockEnv() as unknown as Env;
server.use(
+18 -10
View File
@@ -6,6 +6,7 @@ import {
MAX_ICON_BYTES,
} from "../config/constants";
import { IconRepository } from "./icon-repository";
import { Domain } from "../domain/value-objects/domain";
import { EmailAddress } from "../domain/value-objects/email-address";
import { logger } from "./logger";
@@ -65,16 +66,23 @@ async function fetchIconFrom(
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.
// Walk the sending subdomain up to its apex so a sender like
// `mail.example.com` falls back to `example.com`'s favicon.
const hosts = Domain.parse(domain)
?.parents()
.map((d) => d.value) ?? [domain];
for (const host of hosts) {
const candidates = [
`https://${host}/favicon.ico`,
`https://icons.duckduckgo.com/ip3/${host}.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;