From 4a4c276859d0495e9e520d15d70738a91a2773df Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Fri, 22 May 2026 23:09:53 +0200 Subject: [PATCH] feat: add sender blocklist with priority matching and quick-add dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `blocked_senders` field to FeedConfig (alongside existing `allowed_senders`) - Refactor sender matching to priority-based logic: exact block > exact allow > domain block > domain allow, enabling exceptions (e.g. allow toto@gmail.com despite blocking gmail.com) - Add `POST /admin/feeds/:feedId/sender-filter` endpoint for quick allow/block from email detail view; returns 409 on conflict with opposite list - Add ⋮ dropdown on From field in email detail with 4 options (allow/block sender/domain), inline success/error feedback - Add blocked_senders textarea to create/edit feed forms - 209 tests passing Co-Authored-By: Claude Sonnet 4.5 --- src/lib/email-processor.test.ts | 63 ++++++++++++++++ src/lib/email-processor.ts | 59 +++++++++------ src/routes/admin.test.ts | 125 ++++++++++++++++++++++++++++++++ src/routes/admin.tsx | 16 ++++ src/routes/admin/emails.tsx | 123 ++++++++++++++++++++++++++++++- src/routes/admin/feeds.tsx | 100 +++++++++++++++++++++++++ src/styles/utilities.css | 103 ++++++++++++++++++++++++++ src/types/index.ts | 1 + 8 files changed, 568 insertions(+), 22 deletions(-) diff --git a/src/lib/email-processor.test.ts b/src/lib/email-processor.test.ts index 3ada63b..e1d2ed2 100644 --- a/src/lib/email-processor.test.ts +++ b/src/lib/email-processor.test.ts @@ -86,6 +86,69 @@ describe("processEmail", () => { expect(res.status).toBe(200); }); + it("returns 403 when sender is in blocklist by exact address", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ blocked_senders: ["sender@example.com"] }), + ); + const res = await processEmail(makeInput(), env as any); + expect(res.status).toBe(403); + }); + + it("returns 403 when sender is in blocklist by domain", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ blocked_senders: ["example.com"] }), + ); + const res = await processEmail(makeInput(), env as any); + expect(res.status).toBe(403); + }); + + it("returns 200 when sender is not in blocklist", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ blocked_senders: ["other@example.com"] }), + ); + const res = await processEmail(makeInput(), env as any); + expect(res.status).toBe(200); + }); + + it("exact block takes precedence over domain allow", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ + allowed_senders: ["example.com"], + blocked_senders: ["sender@example.com"], + }), + ); + const res = await processEmail(makeInput(), env as any); + expect(res.status).toBe(403); + }); + + it("exact allow overrides domain block (exception use case)", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ + allowed_senders: ["sender@example.com"], + blocked_senders: ["example.com"], + }), + ); + const res = await processEmail(makeInput(), env as any); + expect(res.status).toBe(200); + }); + + it("exact block takes precedence over exact allow", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ + allowed_senders: ["sender@example.com"], + blocked_senders: ["sender@example.com"], + }), + ); + const res = await processEmail(makeInput(), env as any); + expect(res.status).toBe(403); + }); + it("stores email data and updates metadata in KV", async () => { await env.EMAIL_STORAGE.put( `feed:${VALID_FEED_ID}:config`, diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts index 5eeb129..43a28d9 100644 --- a/src/lib/email-processor.ts +++ b/src/lib/email-processor.ts @@ -35,23 +35,32 @@ function normalizeEmail(value: string): string { return value.trim().toLowerCase(); } -function senderMatchesAllowlist( +type SenderDecision = "blocked" | "allowed" | "neutral"; + +function evaluateSender( sender: string, - allowedSender: string, -): boolean { - if (!allowedSender) return false; + allowedSenders: string[], + blockedSenders: string[], +): SenderDecision { + const normalized = normalizeEmail(sender); + const domain = normalized.split("@")[1] || ""; - const normalizedSender = normalizeEmail(sender); + const normalizeDomain = (e: string) => (e.startsWith("@") ? e.slice(1) : e); - if (allowedSender.includes("@")) { - return normalizedSender === allowedSender; - } + const exactBlocked = blockedSenders.filter((e) => e.includes("@")); + const exactAllowed = allowedSenders.filter((e) => e.includes("@")); + const domainBlocked = blockedSenders + .filter((e) => !e.includes("@")) + .map(normalizeDomain); + const domainAllowed = allowedSenders + .filter((e) => !e.includes("@")) + .map(normalizeDomain); - const senderDomain = normalizedSender.split("@")[1] || ""; - const normalizedDomain = allowedSender.startsWith("@") - ? allowedSender.slice(1) - : allowedSender; - return senderDomain === normalizedDomain; + if (exactBlocked.includes(normalized)) return "blocked"; + if (exactAllowed.includes(normalized)) return "allowed"; + if (domain && domainBlocked.includes(domain)) return "blocked"; + if (domain && domainAllowed.includes(domain)) return "allowed"; + return "neutral"; } async function uploadAttachments( @@ -107,17 +116,25 @@ export async function validateEmail( const allowedSenders = (feedConfig.allowed_senders || []) .map(normalizeEmail) .filter(Boolean); - if (allowedSenders.length > 0) { - const senderAllowed = input.senders.some((sender) => - allowedSenders.some((allowedSender) => - senderMatchesAllowlist(sender, allowedSender), - ), - ); - if (!senderAllowed) { - logger.warn("Rejected email: sender not in allowlist", { + const blockedSenders = (feedConfig.blocked_senders || []) + .map(normalizeEmail) + .filter(Boolean); + + if (allowedSenders.length > 0 || blockedSenders.length > 0) { + const hasAllowlist = allowedSenders.length > 0; + const accepted = input.senders.some((sender) => { + const decision = evaluateSender(sender, allowedSenders, blockedSenders); + if (decision === "allowed") return true; + if (decision === "blocked") return false; + return !hasAllowlist; + }); + + if (!accepted) { + logger.warn("Rejected email: sender filter", { feedId, senders: input.senders, allowedSenders, + blockedSenders, }); return { ok: false, diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index d7a849b..aef75dd 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -563,4 +563,129 @@ describe("Admin Routes", () => { }); }); }); + + describe("Sender filter", () => { + let authCookie: string; + let feedId: string; + + beforeEach(async () => { + authCookie = await loginAndGetCookie(); + const createRes = await request("/admin/feeds/create", { + method: "POST", + headers: { + Cookie: authCookie, + "Content-Type": "application/json", + Origin: "https://test.getmynews.app", + }, + body: JSON.stringify({ title: "Filter Test Feed" }), + }); + const payload = (await createRes.json()) as { feedId: string }; + feedId = payload.feedId; + }); + + const post = (body: object, cookie = authCookie) => + request(`/admin/feeds/${feedId}/sender-filter`, { + method: "POST", + headers: { + Cookie: cookie, + "Content-Type": "application/json", + Origin: "https://test.getmynews.app", + }, + body: JSON.stringify(body), + }); + + it("adds exact email to allowlist", async () => { + const res = await post({ + action: "allow_sender", + value: "alice@example.com", + }); + expect(res.status).toBe(200); + expect(((await res.json()) as any).ok).toBe(true); + const cfg = (await mockEnv.EMAIL_STORAGE.get( + `feed:${feedId}:config`, + "json", + )) as any; + expect(cfg.allowed_senders).toContain("alice@example.com"); + }); + + it("adds domain to allowlist", async () => { + const res = await post({ action: "allow_domain", value: "example.com" }); + expect(res.status).toBe(200); + const cfg = (await mockEnv.EMAIL_STORAGE.get( + `feed:${feedId}:config`, + "json", + )) as any; + expect(cfg.allowed_senders).toContain("example.com"); + }); + + it("adds exact email to blocklist", async () => { + const res = await post({ action: "block_sender", value: "spam@bad.com" }); + expect(res.status).toBe(200); + const cfg = (await mockEnv.EMAIL_STORAGE.get( + `feed:${feedId}:config`, + "json", + )) as any; + expect(cfg.blocked_senders).toContain("spam@bad.com"); + }); + + it("adds domain to blocklist", async () => { + const res = await post({ action: "block_domain", value: "bad.com" }); + expect(res.status).toBe(200); + const cfg = (await mockEnv.EMAIL_STORAGE.get( + `feed:${feedId}:config`, + "json", + )) as any; + expect(cfg.blocked_senders).toContain("bad.com"); + }); + + it("returns 409 when value already exists in the opposite list", async () => { + await post({ action: "block_sender", value: "alice@example.com" }); + const res = await post({ + action: "allow_sender", + value: "alice@example.com", + }); + expect(res.status).toBe(409); + const data = (await res.json()) as any; + expect(data.ok).toBe(false); + expect(data.error).toMatch(/blocklist/); + }); + + it("is idempotent when value already in target list", async () => { + await post({ action: "allow_sender", value: "alice@example.com" }); + const res = await post({ + action: "allow_sender", + value: "alice@example.com", + }); + expect(res.status).toBe(200); + const cfg = (await mockEnv.EMAIL_STORAGE.get( + `feed:${feedId}:config`, + "json", + )) as any; + expect( + cfg.allowed_senders.filter((s: string) => s === "alice@example.com") + .length, + ).toBe(1); + }); + + it("returns 400 for invalid action", async () => { + const res = await post({ + action: "invalid_action", + value: "alice@example.com", + }); + expect(res.status).toBe(400); + }); + + it("normalizes value to lowercase", async () => { + const res = await post({ + action: "allow_sender", + value: "Alice@Example.COM", + }); + expect(res.status).toBe(200); + const cfg = (await mockEnv.EMAIL_STORAGE.get( + `feed:${feedId}:config`, + "json", + )) as any; + expect(cfg.allowed_senders).toContain("alice@example.com"); + }); + }); }); diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index c751011..cb55f9a 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -380,6 +380,22 @@ app.get("/", async (c) => { +
+ + + + Emails from these senders/domains are always rejected, even if + they match the allowlist. + +
+ diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index b8639ba..c4e18f8 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -72,6 +72,82 @@ const CopyField = ({ label, value, display }: CopyFieldProps) => ( ); +function extractSenderEmail(from: string): string { + const match = from.match(/<([^>]+@[^>]+)>/); + return match ? match[1].trim().toLowerCase() : from.trim().toLowerCase(); +} + +type SenderFieldProps = { + from: string; + feedId: string; +}; + +const SenderField = ({ from, feedId }: SenderFieldProps) => { + const senderEmail = extractSenderEmail(from); + const senderDomain = senderEmail.split("@")[1] || ""; + + return ( +
+ From: +
+ + {from} + +
+ + +
+
+
+ + + +
+
+ ); +}; + // ── View all emails for a feed ──────────────────────────────────────────────── emailsRouter.get("/feeds/:feedId/emails", async (c) => { @@ -397,6 +473,51 @@ emailsRouter.get("/emails/:emailKey", async (c) => { }; } catch (e) { /* cross-origin */ } }); + function toggleSenderFilter(btn) { + var dropdown = btn.closest('.sender-filter-dropdown'); + var isOpen = dropdown.hasAttribute('data-open'); + document.querySelectorAll('.sender-filter-dropdown[data-open]').forEach(function(d) { + d.removeAttribute('data-open'); + }); + if (!isOpen) dropdown.setAttribute('data-open', ''); + } + function showSenderFeedback(feedback, ok, msg) { + feedback.textContent = msg; + feedback.className = 'sender-filter-feedback ' + (ok ? 'sender-filter-feedback-ok' : 'sender-filter-feedback-error'); + setTimeout(function() { + feedback.textContent = ''; + feedback.className = 'sender-filter-feedback'; + }, 3000); + } + document.addEventListener('click', function(e) { + var item = e.target.closest('.sender-filter-item'); + if (item) { + var dropdown = item.closest('.sender-filter-dropdown'); + var action = item.dataset.action; + var value = (action === 'allow_sender' || action === 'block_sender') + ? dropdown.dataset.email + : dropdown.dataset.domain; + dropdown.removeAttribute('data-open'); + var feedback = dropdown.querySelector('.sender-filter-feedback'); + fetch('/admin/feeds/' + dropdown.dataset.feedId + '/sender-filter', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action, value: value }) + }).then(function(res) { + return res.json(); + }).then(function(data) { + showSenderFeedback(feedback, data.ok, data.ok ? 'Saved' : (data.error || 'Error')); + }).catch(function() { + showSenderFeedback(feedback, false, 'Network error'); + }); + return; + } + if (!e.target.closest('.sender-filter-dropdown')) { + document.querySelectorAll('.sender-filter-dropdown[data-open]').forEach(function(d) { + d.removeAttribute('data-open'); + }); + } + }); `; return c.html( @@ -424,7 +545,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => { label="Received:" value={new Date(emailData.receivedAt).toLocaleString()} /> - + { let language: string; let view: string; let allowedSenders: string[]; + let blockedSenders: string[]; if (isJson) { const body = await c.req.json>(); @@ -169,6 +177,11 @@ feedsRouter.post("/create", async (c) => { (body.allowedSenders as unknown[]).map(String), ) : []; + blockedSenders = Array.isArray(body.blockedSenders) + ? normalizeAllowedSenders( + (body.blockedSenders as unknown[]).map(String), + ) + : []; } else { const formData = await c.req.formData(); title = formData.get("title")?.toString() || ""; @@ -178,6 +191,9 @@ feedsRouter.post("/create", async (c) => { allowedSenders = parseAllowedSenders( formData.get("allowed_senders")?.toString() || "", ); + blockedSenders = parseAllowedSenders( + formData.get("blocked_senders")?.toString() || "", + ); } const parsedData = createFeedSchema.parse({ @@ -185,6 +201,7 @@ feedsRouter.post("/create", async (c) => { description, language, allowedSenders, + blockedSenders, }); const feedId = generateFeedId(); @@ -194,6 +211,7 @@ feedsRouter.post("/create", async (c) => { description: parsedData.description, language: parsedData.language, allowed_senders: parsedData.allowedSenders, + blocked_senders: parsedData.blockedSenders, created_at: Date.now(), updated_at: Date.now(), }; @@ -295,6 +313,24 @@ feedsRouter.get("/:feedId/edit", async (c) => { +
+ + + + Emails from these senders/domains are always rejected, even if + they match the allowlist. + +
+