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 [email protected] 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 <[email protected]>
This commit is contained in:
Julien HerrandClaude Sonnet 4.5 committed 2026-05-22 23:09:53 +02:00
1 parent 7b2b98d693
commit 4a4c276859
8 files changed
+568 -22

No files matched your search

+38 -21
View File
@@ -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,