mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
6b51173722
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 <noreply@anthropic.com>
120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
import { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types";
|
|
|
|
const HOUR_MS = 3_600_000;
|
|
|
|
/**
|
|
* The Feed aggregate's invariants, in one framework-agnostic place: expiry,
|
|
* sender allow/block policy, and the email-size budget. No I/O — callers load
|
|
* and persist state through the FeedRepository.
|
|
*/
|
|
|
|
/**
|
|
* Resolve a feed's `expires_at` from a requested lifetime (hours). A server-side
|
|
* `FEED_TTL_HOURS` always overrides the client-supplied value. Returns undefined
|
|
* when no positive lifetime applies (i.e. the feed never expires).
|
|
*/
|
|
export function resolveExpiresAt(
|
|
env: Env,
|
|
lifetimeHours?: number,
|
|
): number | undefined {
|
|
const hours = env.FEED_TTL_HOURS
|
|
? parseInt(env.FEED_TTL_HOURS, 10)
|
|
: (lifetimeHours ?? NaN);
|
|
return Number.isFinite(hours) && hours > 0
|
|
? Date.now() + hours * HOUR_MS
|
|
: undefined;
|
|
}
|
|
|
|
/** Whether a feed has reached its expiry instant. */
|
|
export function isExpired(
|
|
config: Pick<FeedConfig, "expires_at">,
|
|
now: number = Date.now(),
|
|
): boolean {
|
|
return config.expires_at !== undefined && config.expires_at <= now;
|
|
}
|
|
|
|
export type SenderDecision = "accepted" | "blocked";
|
|
|
|
function normalizeEmail(value: string): string {
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
type SenderMatch = "blocked" | "allowed" | "neutral";
|
|
|
|
function evaluateSender(
|
|
sender: string,
|
|
allowedSenders: string[],
|
|
blockedSenders: string[],
|
|
): SenderMatch {
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* Decide whether an inbound email is accepted, given the feed's sender lists and
|
|
* the message's candidate sender addresses. With no lists configured everything
|
|
* is accepted; a blocklist hit always rejects; an allowlist (when present) must
|
|
* be matched by at least one sender.
|
|
*/
|
|
export function applySenderPolicy(
|
|
config: Pick<FeedConfig, "allowed_senders" | "blocked_senders">,
|
|
senders: string[],
|
|
): SenderDecision {
|
|
const allowedSenders = (config.allowed_senders || [])
|
|
.map(normalizeEmail)
|
|
.filter(Boolean);
|
|
const blockedSenders = (config.blocked_senders || [])
|
|
.map(normalizeEmail)
|
|
.filter(Boolean);
|
|
|
|
if (allowedSenders.length === 0 && blockedSenders.length === 0) {
|
|
return "accepted";
|
|
}
|
|
|
|
const hasAllowlist = allowedSenders.length > 0;
|
|
const accepted = senders.some((sender) => {
|
|
const decision = evaluateSender(sender, allowedSenders, blockedSenders);
|
|
if (decision === "allowed") return true;
|
|
if (decision === "blocked") return false;
|
|
return !hasAllowlist;
|
|
});
|
|
|
|
return accepted ? "accepted" : "blocked";
|
|
}
|
|
|
|
/**
|
|
* Enforce the per-feed byte budget by dropping the oldest emails (mutating
|
|
* `metadata.emails`) until the total fits, always keeping at least one entry.
|
|
* Returns the dropped entries so the caller can purge their KV/R2 storage.
|
|
*/
|
|
export function trimToByteBudget(
|
|
metadata: FeedMetadata,
|
|
maxBytes: number,
|
|
): { dropped: EmailMetadata[] } {
|
|
let totalSize = metadata.emails.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
|
const dropped: EmailMetadata[] = [];
|
|
while (totalSize > maxBytes && metadata.emails.length > 1) {
|
|
const entry = metadata.emails.pop()!;
|
|
totalSize -= entry.size ?? 0;
|
|
dropped.push(entry);
|
|
}
|
|
return { dropped };
|
|
}
|