feat: add sender blocklist with priority matching and quick-add dropdown

- 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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-22 23:09:53 +02:00
parent 7b2b98d693
commit 4a4c276859
8 changed files with 568 additions and 22 deletions
+125
View File
@@ -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");
});
});
});