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:
@@ -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`,
|
||||
|
||||
+38
-21
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,6 +380,22 @@ app.get("/", async (c) => {
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="blocked_senders">
|
||||
Blocked senders (optional, one email or domain per line)
|
||||
</label>
|
||||
<textarea
|
||||
id="blocked_senders"
|
||||
name="blocked_senders"
|
||||
rows={3}
|
||||
placeholder={"spam@example.com\nunwanted.com"}
|
||||
></textarea>
|
||||
<small>
|
||||
Emails from these senders/domains are always rejected, even if
|
||||
they match the allowlist.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="language" name="language" value="en" />
|
||||
<input type="hidden" name="view" value={view} />
|
||||
|
||||
|
||||
+122
-1
@@ -72,6 +72,82 @@ const CopyField = ({ label, value, display }: CopyFieldProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div class="copyable">
|
||||
<span class="copyable-label">From:</span>
|
||||
<div class="copyable-content">
|
||||
<span class="copyable-value" data-copy={from}>
|
||||
{from}
|
||||
</span>
|
||||
<div class="copy-icon-container">
|
||||
<CopyIcon />
|
||||
<CheckIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sender-filter-dropdown"
|
||||
data-feed-id={feedId}
|
||||
data-email={senderEmail}
|
||||
data-domain={senderDomain}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="sender-filter-btn"
|
||||
aria-label="Sender filter options"
|
||||
onclick="toggleSenderFilter(this)"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div class="sender-filter-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
class="sender-filter-item sender-filter-allow"
|
||||
data-action="allow_sender"
|
||||
>
|
||||
Allow {senderEmail}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sender-filter-item sender-filter-allow"
|
||||
data-action="allow_domain"
|
||||
>
|
||||
Allow @{senderDomain}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sender-filter-item sender-filter-block"
|
||||
data-action="block_sender"
|
||||
>
|
||||
Block {senderEmail}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sender-filter-item sender-filter-block"
|
||||
data-action="block_domain"
|
||||
>
|
||||
Block @{senderDomain}
|
||||
</button>
|
||||
</div>
|
||||
<span class="sender-filter-feedback" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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()}
|
||||
/>
|
||||
<CopyField label="From:" value={emailData.from} />
|
||||
<SenderField from={emailData.from} feedId={feedId} />
|
||||
<CopyField
|
||||
label="To:"
|
||||
value={feedEmailAddress(feedId, env)}
|
||||
|
||||
@@ -31,6 +31,7 @@ const createFeedSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
language: z.string().optional().default("en"),
|
||||
allowedSenders: z.array(z.string()).optional().default([]),
|
||||
blockedSenders: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
const updateFeedSchema = z.object({
|
||||
@@ -38,6 +39,12 @@ const updateFeedSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
language: z.string().optional().default("en"),
|
||||
allowedSenders: z.array(z.string()).optional().default([]),
|
||||
blockedSenders: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
const senderFilterSchema = z.object({
|
||||
action: z.enum(["allow_sender", "allow_domain", "block_sender", "block_domain"]),
|
||||
value: z.string().min(1),
|
||||
});
|
||||
|
||||
// ── Delete helpers ────────────────────────────────────────────────────────────
|
||||
@@ -156,6 +163,7 @@ feedsRouter.post("/create", async (c) => {
|
||||
let language: string;
|
||||
let view: string;
|
||||
let allowedSenders: string[];
|
||||
let blockedSenders: string[];
|
||||
|
||||
if (isJson) {
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
@@ -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) => {
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="blocked_senders">
|
||||
Blocked senders (optional, one email or domain per line)
|
||||
</label>
|
||||
<textarea
|
||||
id="blocked_senders"
|
||||
name="blocked_senders"
|
||||
rows={3}
|
||||
placeholder={"spam@example.com\nunwanted.com"}
|
||||
>
|
||||
{(feedConfig.blocked_senders || []).join("\n")}
|
||||
</textarea>
|
||||
<small>
|
||||
Emails from these senders/domains are always rejected, even if
|
||||
they match the allowlist.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="language" name="language" value="en" />
|
||||
|
||||
<button type="submit" class="button">
|
||||
@@ -320,12 +356,16 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
const allowedSenders = parseAllowedSenders(
|
||||
formData.get("allowed_senders")?.toString() || "",
|
||||
);
|
||||
const blockedSenders = parseAllowedSenders(
|
||||
formData.get("blocked_senders")?.toString() || "",
|
||||
);
|
||||
|
||||
const parsedData = updateFeedSchema.parse({
|
||||
title,
|
||||
description,
|
||||
language,
|
||||
allowedSenders,
|
||||
blockedSenders,
|
||||
});
|
||||
|
||||
const feedConfigKey = `feed:${feedId}:config`;
|
||||
@@ -345,6 +385,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
description: parsedData.description,
|
||||
language: parsedData.language,
|
||||
allowed_senders: parsedData.allowedSenders,
|
||||
blocked_senders: parsedData.blockedSenders,
|
||||
updated_at: Date.now(),
|
||||
}),
|
||||
);
|
||||
@@ -363,6 +404,65 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Sender filter quick-add ───────────────────────────────────────────────────
|
||||
|
||||
feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
||||
const env = c.env;
|
||||
const feedId = c.req.param("feedId");
|
||||
const feedConfigKey = `feed:${feedId}:config`;
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = senderFilterSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ ok: false, error: "Invalid request" }, 400);
|
||||
}
|
||||
|
||||
const { action, value } = parsed.data;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
const feedConfig = (await env.EMAIL_STORAGE.get(feedConfigKey, {
|
||||
type: "json",
|
||||
})) as FeedConfig | null;
|
||||
if (!feedConfig) return c.json({ ok: false, error: "Feed not found" }, 404);
|
||||
|
||||
const allowedSenders = (feedConfig.allowed_senders || []).map((s) =>
|
||||
s.trim().toLowerCase(),
|
||||
);
|
||||
const blockedSenders = (feedConfig.blocked_senders || []).map((s) =>
|
||||
s.trim().toLowerCase(),
|
||||
);
|
||||
|
||||
const isAllowAction = action === "allow_sender" || action === "allow_domain";
|
||||
const targetList = isAllowAction ? allowedSenders : blockedSenders;
|
||||
const oppositeList = isAllowAction ? blockedSenders : allowedSenders;
|
||||
const oppositeLabel = isAllowAction ? "blocklist" : "allowlist";
|
||||
|
||||
if (oppositeList.includes(normalized)) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: `"${normalized}" is already in the ${oppositeLabel}`,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetList.includes(normalized)) {
|
||||
targetList.push(normalized);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
feedConfigKey,
|
||||
JSON.stringify({
|
||||
...feedConfig,
|
||||
allowed_senders: allowedSenders,
|
||||
blocked_senders: blockedSenders,
|
||||
updated_at: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
feedsRouter.post("/:feedId/delete", async (c) => {
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
|
||||
@@ -124,3 +124,106 @@
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
/* ── Sender filter dropdown ── */
|
||||
|
||||
.sender-filter-dropdown {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--spacing-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.sender-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sender-filter-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sender-filter-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 220px;
|
||||
background-color: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sender-filter-dropdown[data-open] .sender-filter-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sender-filter-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sender-filter-allow {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.sender-filter-allow:hover {
|
||||
background-color: rgba(48, 209, 88, 0.12);
|
||||
}
|
||||
|
||||
.sender-filter-block {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.sender-filter-block:hover {
|
||||
background-color: rgba(255, 69, 58, 0.12);
|
||||
}
|
||||
|
||||
/* Divider between allow and block groups */
|
||||
.sender-filter-block:first-of-type {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.sender-filter-feedback {
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.sender-filter-feedback-ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.sender-filter-feedback-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface FeedConfig {
|
||||
title: string;
|
||||
description?: string;
|
||||
allowed_senders?: string[];
|
||||
blocked_senders?: string[];
|
||||
language: string;
|
||||
author?: string;
|
||||
created_at: number;
|
||||
|
||||
Reference in New Issue
Block a user