mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user