diff --git a/CHANGELOG.md b/CHANGELOG.md index e3df849..d4920f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ verbatim as the GitHub Release notes — so what you write here is what ships. button carries the subscribe/subscription hint only in its visible text (e.g. "Yes, subscribe me to this mailing list.") over an opaque tracking-redirect href — previously the link scored zero and the email was missed. +- Sender favicons now recover from a transient miss: a failed favicon lookup is + cached negatively for 6 hours instead of a full week, so a domain whose icon + was momentarily unavailable (e.g. not yet indexed upstream) is retried on the + next email instead of staying blank for days. ## [0.3.1] - 2026-05-25 diff --git a/src/config/constants.ts b/src/config/constants.ts index da0ff9b..33ce189 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -31,6 +31,13 @@ export const STATS_KEY = "stats:counters"; /** Default TTL for a cached per-domain favicon (seconds). */ export const ICON_TTL_SECONDS = 7 * 24 * 60 * 60; // 1 week +/** + * TTL for a *negative* favicon cache entry (seconds). Kept short so a transient + * miss (e.g. DuckDuckGo not having indexed the domain yet) self-heals within + * hours instead of blacklisting the domain for a full week. + */ +export const ICON_NEGATIVE_TTL_SECONDS = 6 * 60 * 60; // 6 hours + /** Maximum accepted favicon size (bytes); larger responses are rejected. */ export const MAX_ICON_BYTES = 100 * 1024; // 100 KB diff --git a/src/infrastructure/favicon-fetcher.test.ts b/src/infrastructure/favicon-fetcher.test.ts index 478a9c1..408a6bd 100644 --- a/src/infrastructure/favicon-fetcher.test.ts +++ b/src/infrastructure/favicon-fetcher.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { http, HttpResponse } from "msw"; import { server, createMockEnv } from "../test/setup"; import { @@ -6,7 +6,12 @@ import { extractEmailDomain, getCachedIcon, } from "./favicon-fetcher"; -import { MAX_ICON_BYTES } from "../config/constants"; +import { IconRepository } from "./icon-repository"; +import { + ICON_NEGATIVE_TTL_SECONDS, + ICON_TTL_SECONDS, + MAX_ICON_BYTES, +} from "../config/constants"; const iconKey = (domain: string) => `icon:${domain}`; import type { Env } from "../types"; @@ -89,6 +94,45 @@ describe("cacheFaviconForDomain", () => { expect(await getCachedIcon("nope.test", env)).toBeNull(); }); + it("gives a negative entry a short TTL so transient misses self-heal", async () => { + const env = createMockEnv() as unknown as Env; + const put = vi.spyOn(IconRepository.prototype, "put"); + server.use( + http.get("https://transient.test/favicon.ico", () => + HttpResponse.text("", { status: 404 }), + ), + http.get("https://icons.duckduckgo.com/ip3/transient.test.ico", () => + HttpResponse.text("", { status: 404 }), + ), + ); + + await cacheFaviconForDomain("transient.test", env); + + expect(put).toHaveBeenCalledWith( + "transient.test", + expect.any(String), + ICON_NEGATIVE_TTL_SECONDS, + ); + put.mockRestore(); + }); + + it("gives a positive entry the full TTL", async () => { + const env = createMockEnv() as unknown as Env; + const put = vi.spyOn(IconRepository.prototype, "put"); + server.use( + http.get("https://hit.test/favicon.ico", () => imageResponse(PNG)), + ); + + await cacheFaviconForDomain("hit.test", env); + + expect(put).toHaveBeenCalledWith( + "hit.test", + expect.any(String), + ICON_TTL_SECONDS, + ); + put.mockRestore(); + }); + it("rejects oversized responses as negative", async () => { const env = createMockEnv() as unknown as Env; const big = new Uint8Array(MAX_ICON_BYTES + 1); diff --git a/src/infrastructure/favicon-fetcher.ts b/src/infrastructure/favicon-fetcher.ts index c927b74..60a1c5a 100644 --- a/src/infrastructure/favicon-fetcher.ts +++ b/src/infrastructure/favicon-fetcher.ts @@ -1,6 +1,7 @@ import { Env } from "../types"; import { ICON_FETCH_TIMEOUT_MS, + ICON_NEGATIVE_TTL_SECONDS, ICON_TTL_SECONDS, MAX_ICON_BYTES, } from "../config/constants"; @@ -102,7 +103,8 @@ export async function cacheFaviconForDomain( } : { data: null, contentType: "" }; - await repo.put(domain, JSON.stringify(record), ICON_TTL_SECONDS); + const ttl = icon ? ICON_TTL_SECONDS : ICON_NEGATIVE_TTL_SECONDS; + await repo.put(domain, JSON.stringify(record), ttl); } catch (error) { logger.warn("Favicon cache failed", { domain, error: String(error) }); }