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:
+
+
+
+
+
+
+
+ );
+};
+
// ── 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.
+
+
+