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:
1 parent
7b2b98d693
commit
4a4c276859
8 files changed
+568
-22
No files matched your search
+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,
|
||||
|
||||
Reference in new issue
Block a user