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
+122 -1
View File
@@ -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)}
+100
View File
@@ -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;