refactor(domain): consolidate Feed aggregate invariants in domain/feed.ts

Gather the feed's scattered business rules — expiry, sender allow/block policy,
and the email byte-size budget — into one framework-agnostic module. Expiry was
duplicated across feed-service, email-processor and the rss/atom/entries routes;
the sender policy and trim loop lived inline in email-processor. Each now calls
a single function (isExpired, applySenderPolicy, trimToByteBudget,
resolveExpiresAt). Drops the now-unused MAX_METADATA_EMAILS constant.

Behaviour-preserving; adds feed.test.ts covering every invariant.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-23 23:59:15 +02:00
1 parent 2b3f00f7e3
commit 6b51173722
8 files changed
+250 -114

No files matched your search

+15 -75
View File
@@ -9,6 +9,7 @@ import {
import { parseOneClickUnsubscribe } from "../utils/unsubscribe";
import { getAttachmentBucket } from "../utils/attachments";
import { FeedRepository } from "../domain/feed-repository";
import { isExpired, applySenderPolicy, trimToByteBudget } from "../domain/feed";
import { logger } from "./logger";
import { FEED_MAX_BYTES } from "../config/constants";
@@ -34,38 +35,6 @@ type ValidationSuccess = { ok: true; feedId: string; feedConfig: FeedConfig };
type ValidationFailure = { ok: false; response: Response };
type ValidationResult = ValidationSuccess | ValidationFailure;
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
type SenderDecision = "blocked" | "allowed" | "neutral";
function evaluateSender(
sender: string,
allowedSenders: string[],
blockedSenders: string[],
): SenderDecision {
const normalized = normalizeEmail(sender);
const domain = normalized.split("@")[1] || "";
const normalizeDomain = (e: string) => (e.startsWith("@") ? e.slice(1) : e);
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);
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(
attachments: RawAttachment[],
bucket: R2Bucket,
@@ -113,10 +82,7 @@ export async function validateEmail(
response: new Response("Feed does not exist", { status: 404 }),
};
}
if (
feedConfig.expires_at !== undefined &&
feedConfig.expires_at <= Date.now()
) {
if (isExpired(feedConfig)) {
logger.warn("Rejected email: feed expired", { feedId });
return {
ok: false,
@@ -124,36 +90,19 @@ export async function validateEmail(
};
}
const allowedSenders = (feedConfig.allowed_senders || [])
.map(normalizeEmail)
.filter(Boolean);
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 (applySenderPolicy(feedConfig, input.senders) === "blocked") {
logger.warn("Rejected email: sender filter", {
feedId,
senders: input.senders,
allowedSenders: feedConfig.allowed_senders,
blockedSenders: feedConfig.blocked_senders,
});
if (!accepted) {
logger.warn("Rejected email: sender filter", {
feedId,
senders: input.senders,
allowedSenders,
blockedSenders,
});
return {
ok: false,
response: new Response("Sender not allowed for this feed", {
status: 403,
}),
};
}
return {
ok: false,
response: new Response("Sender not allowed for this feed", {
status: 403,
}),
};
}
return { ok: true, feedId, feedConfig };
@@ -231,16 +180,7 @@ export async function storeEmail(
};
}
let totalSize = feedMetadata.emails.reduce(
(sum, e) => sum + (e.size ?? 0),
0,
);
const toDelete: EmailMetadata[] = [];
while (totalSize > maxBytes && feedMetadata.emails.length > 1) {
const dropped = feedMetadata.emails.pop()!;
totalSize -= dropped.size ?? 0;
toDelete.push(dropped);
}
const { dropped: toDelete } = trimToByteBudget(feedMetadata, maxBytes);
const r2Deletions =
attachmentBucket && toDelete.length > 0