fix(favicon): short TTL for negative favicon cache entries

A failed favicon lookup was cached for a full week (same TTL as a
success), so a transient miss (e.g. the icon not yet indexed upstream)
blacklisted the domain for days. Cache negatives for 6 hours instead so
the next email retries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 22:44:35 +02:00
parent bb9fce72ff
commit 5f13126b35
4 changed files with 60 additions and 3 deletions
+4
View File
@@ -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
+7
View File
@@ -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
+46 -2
View File
@@ -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);
+3 -1
View File
@@ -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) });
}