feat: reader-compat batch — JSON Feed, OPML export, conditional GET, dedup
Batch of four reader-facing improvements (TODO "Compat lecteurs + dedup"): - JSON Feed at /json/:feedId (feed lib .json1()); all formats cross-link - OPML export at /admin/opml (admin-protected; the registry lists every feed URL, so it must not be public) - Conditional GET on /rss + /atom: strong ETag + Last-Modified, 304 on If-None-Match/If-Modified-Since, validators shared via http-cache.ts - Duplicate-send dedup in ingestion: match by Message-ID, fall back to a SHA-256 of normalized subject+content; a duplicate is a no-op and bumps the new emails_deduplicated counter (status page + /api/v1/stats) 429 tests green, tsc clean, build dry-run OK. Docs (README/CLAUDE/TODO + landing cards) updated. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
334713fbd9
commit
0abd5f306c
23 files changed
+1015
-11
No files matched your search
@@ -595,6 +595,134 @@ describe("processEmail — attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEmail — deduplication", () => {
|
||||
let env: ReturnType<typeof createMockEnv>;
|
||||
|
||||
beforeEach(async () => {
|
||||
env = createMockEnv();
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores only one email when the same Message-ID is delivered twice", async () => {
|
||||
const headers = { "Message-ID": "<[email protected]>" };
|
||||
await processEmail(makeInput({ headers }), env as any);
|
||||
await processEmail(makeInput({ headers }), env as any);
|
||||
|
||||
const metadata = await env.EMAIL_STORAGE.get(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
expect(metadata.emails).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("increments emails_deduplicated counter on the second delivery", async () => {
|
||||
const headers = { "Message-ID": "<[email protected]>" };
|
||||
await processEmail(makeInput({ headers }), env as any);
|
||||
await processEmail(makeInput({ headers }), env as any);
|
||||
|
||||
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||
expect(counters.emails_deduplicated).toBe(1);
|
||||
});
|
||||
|
||||
it("deduplicates by hash when no Message-ID header is present", async () => {
|
||||
const input = makeInput({
|
||||
subject: "Weekly Digest",
|
||||
content: "<p>Same content</p>",
|
||||
});
|
||||
await processEmail(input, env as any);
|
||||
await processEmail(input, env as any);
|
||||
|
||||
const metadata = await env.EMAIL_STORAGE.get(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
expect(metadata.emails).toHaveLength(1);
|
||||
|
||||
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||
expect(counters.emails_deduplicated).toBe(1);
|
||||
});
|
||||
|
||||
it("does not deduplicate emails with different subjects (no Message-ID)", async () => {
|
||||
await processEmail(
|
||||
makeInput({ subject: "First", content: "<p>body</p>" }),
|
||||
env as any,
|
||||
);
|
||||
await processEmail(
|
||||
makeInput({ subject: "Second", content: "<p>body</p>" }),
|
||||
env as any,
|
||||
);
|
||||
|
||||
const metadata = await env.EMAIL_STORAGE.get(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
expect(metadata.emails).toHaveLength(2);
|
||||
|
||||
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||
expect(counters.emails_deduplicated).toBe(0);
|
||||
});
|
||||
|
||||
it("does not false-positive against pre-feature entries lacking messageId/dedupHash", async () => {
|
||||
// Seed a legacy metadata entry with no messageId or dedupHash
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [
|
||||
{
|
||||
key: `feed:${VALID_FEED_ID}:999`,
|
||||
subject: "Old Subject",
|
||||
receivedAt: 999,
|
||||
size: 50,
|
||||
// intentionally no messageId, no dedupHash
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// A new, distinct email should be stored without triggering false dedup
|
||||
const res = await processEmail(
|
||||
makeInput({ subject: "New Distinct Email", content: "<p>fresh</p>" }),
|
||||
env as any,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const metadata = await env.EMAIL_STORAGE.get(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
expect(metadata.emails).toHaveLength(2);
|
||||
|
||||
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||
expect(counters.emails_deduplicated).toBe(0);
|
||||
});
|
||||
|
||||
it("returns { ok: true } for a genuine duplicate (not a rejection)", async () => {
|
||||
const headers = { "Message-ID": "<[email protected]>" };
|
||||
await processEmail(makeInput({ headers }), env as any);
|
||||
const res = await processEmail(makeInput({ headers }), env as any);
|
||||
expect(res).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("stores messageId and dedupHash in the email metadata entry", async () => {
|
||||
const headers = { "Message-ID": "<[email protected]>" };
|
||||
await processEmail(
|
||||
makeInput({ subject: "Sub", content: "<p>c</p>", headers }),
|
||||
env as any,
|
||||
);
|
||||
|
||||
const metadata = await env.EMAIL_STORAGE.get(
|
||||
`feed:${VALID_FEED_ID}:metadata`,
|
||||
"json",
|
||||
);
|
||||
expect(metadata.emails[0].messageId).toBe("<[email protected]>");
|
||||
expect(typeof metadata.emails[0].dedupHash).toBe("string");
|
||||
expect(metadata.emails[0].dedupHash).toHaveLength(64); // SHA-256 hex
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEmail — monitoring counters", () => {
|
||||
it("increments emails_received and sets last_email_at on success", async () => {
|
||||
const env = createMockEnv();
|
||||
@@ -709,6 +837,7 @@ describe("processEmail — unsubscribe capture", () => {
|
||||
it("keeps one entry per sender and overwrites with the latest URL", async () => {
|
||||
await processEmail(
|
||||
makeInput({
|
||||
subject: "Issue 1 from A",
|
||||
senders: ["[email protected]"],
|
||||
headers: {
|
||||
"list-unsubscribe": "<https://one.com/u/1>",
|
||||
@@ -719,6 +848,7 @@ describe("processEmail — unsubscribe capture", () => {
|
||||
);
|
||||
await processEmail(
|
||||
makeInput({
|
||||
subject: "Issue 1 from B",
|
||||
senders: ["[email protected]"],
|
||||
headers: {
|
||||
"list-unsubscribe": "<https://two.com/u/1>",
|
||||
@@ -729,6 +859,7 @@ describe("processEmail — unsubscribe capture", () => {
|
||||
);
|
||||
await processEmail(
|
||||
makeInput({
|
||||
subject: "Issue 2 from A",
|
||||
senders: ["[email protected]"],
|
||||
headers: {
|
||||
"list-unsubscribe": "<https://one.com/u/2>",
|
||||
|
||||
Reference in new issue
Block a user