Files
kill-the-news/src/lib/email-processor.ts
T
Julien HerrandClaude Opus 4.7 debbfc623e fix(attachments): render inline cid: images in emails and feeds
Capture each attachment's Content-ID at ingestion (postal-mime and
mailparser paths) and rewrite cid: image refs to the stored /files URL
in processEmailContent, shared by the entry view and RSS/Atom feeds.
Bodyless HTML fragments are now serialized so sanitization and the cid
rewrite apply to them too.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 18:42:04 +02:00

295 lines
8.7 KiB
TypeScript

import { EmailParser } from "../utils/email-parser";
import {
AttachmentData,
EmailMetadata,
Env,
FeedConfig,
FeedMetadata,
} from "../types";
import { notifySubscribers } from "../utils/websub";
import { bumpCounters } from "../utils/stats";
import {
cacheFaviconForDomain,
extractEmailDomain,
} from "../utils/favicon-fetcher";
import { parseOneClickUnsubscribe } from "../utils/unsubscribe";
import { getAttachmentBucket } from "../utils/attachments";
import { logger } from "./logger";
import { FEED_MAX_BYTES } from "../config/constants";
export interface RawAttachment {
filename: string;
contentType: string;
content: ArrayBuffer;
contentId?: string;
}
export interface ProcessEmailInput {
toAddress: string;
from: string;
senders: string[];
subject: string;
content: string;
receivedAt: number;
headers?: Record<string, string>;
attachments?: RawAttachment[];
}
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,
): Promise<AttachmentData[]> {
return Promise.all(
attachments.map(async (att) => {
const id = crypto.randomUUID();
await bucket.put(id, att.content, {
httpMetadata: {
contentType: att.contentType,
contentDisposition: `attachment; filename="${att.filename}"`,
},
});
return {
id,
filename: att.filename,
contentType: att.contentType,
size: att.content.byteLength,
...(att.contentId ? { contentId: att.contentId } : {}),
};
}),
);
}
export async function validateEmail(
input: ProcessEmailInput,
env: Env,
): Promise<ValidationResult> {
const feedId = EmailParser.extractFeedId(input.toAddress);
if (!feedId) {
logger.error("Invalid email address format", {
toAddress: input.toAddress,
});
return {
ok: false,
response: new Response("Invalid email address format", { status: 400 }),
};
}
const feedConfig = (await env.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as FeedConfig | null;
if (!feedConfig) {
logger.error("Feed not found", { feedId });
return {
ok: false,
response: new Response("Feed does not exist", { status: 404 }),
};
}
if (
feedConfig.expires_at !== undefined &&
feedConfig.expires_at <= Date.now()
) {
logger.warn("Rejected email: feed expired", { feedId });
return {
ok: false,
response: new Response("Feed has expired", { status: 410 }),
};
}
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 (!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: true, feedId, feedConfig };
}
export async function storeEmail(
feedId: string,
input: ProcessEmailInput,
env: Env,
ctx?: ExecutionContext,
): Promise<void> {
const attachmentBucket = getAttachmentBucket(env);
const storedAttachments: AttachmentData[] =
attachmentBucket && input.attachments?.length
? await uploadAttachments(input.attachments, attachmentBucket)
: [];
const emailData = {
subject: input.subject,
from: input.from,
content: input.content,
receivedAt: input.receivedAt,
headers: input.headers ?? {},
...(storedAttachments.length > 0 ? { attachments: storedAttachments } : {}),
};
const emailKey = `feed:${feedId}:${Date.now()}`;
const feedMetadataKey = `feed:${feedId}:metadata`;
const [, rawMetadata] = await Promise.all([
env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData)),
env.EMAIL_STORAGE.get(feedMetadataKey, "json"),
]);
// Note: KV has no atomic compare-and-swap. Concurrent invocations for the
// same feed can read stale metadata and produce orphaned KV entries or
// duplicate trim deletions. This is an accepted limitation given Cloudflare
// KV's eventual-consistency model.
// TODO: Migrate feed metadata writes to Cloudflare Durable Objects to serialise
// concurrent writes and eliminate this race condition.
const feedMetadata = ((rawMetadata as FeedMetadata | null) || {
emails: [],
}) as FeedMetadata;
const maxBytes =
parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || FEED_MAX_BYTES;
const serialised = JSON.stringify(emailData);
const serialisedSize = new TextEncoder().encode(serialised).byteLength;
const newEntry: EmailMetadata = {
key: emailKey,
subject: emailData.subject,
receivedAt: emailData.receivedAt,
size: serialisedSize,
...(storedAttachments.length > 0
? { attachmentIds: storedAttachments.map((a) => a.id) }
: {}),
};
feedMetadata.emails.unshift(newEntry);
// Track the latest sender's domain so the feed icon follows the source.
const iconDomain = extractEmailDomain(input.from);
if (iconDomain) {
feedMetadata.iconDomain = iconDomain;
}
// Capture the sender's RFC 8058 one-click unsubscribe link so we can stop the
// newsletter when the feed is deleted. Keyed by sender: each newsletter on the
// feed keeps its own entry, and a repeat send overwrites with the latest URL.
const unsubUrl = parseOneClickUnsubscribe(input.headers ?? {});
if (unsubUrl) {
const senderKey =
input.senders[0] || extractEmailDomain(input.from) || input.from;
feedMetadata.unsubscribe = {
...(feedMetadata.unsubscribe ?? {}),
[senderKey]: unsubUrl,
};
}
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 r2Deletions =
attachmentBucket && toDelete.length > 0
? toDelete
.flatMap((e) => e.attachmentIds ?? [])
.map((id) => attachmentBucket.delete(id))
: [];
await Promise.all([
env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata)),
...toDelete.map((e) => env.EMAIL_STORAGE.delete(e.key)),
...r2Deletions,
]);
logger.info("Email processed", { feedId });
if (ctx) {
ctx.waitUntil(notifySubscribers(feedId, env));
if (iconDomain) {
ctx.waitUntil(cacheFaviconForDomain(iconDomain, env));
}
}
}
export async function processEmail(
input: ProcessEmailInput,
env: Env,
ctx?: ExecutionContext,
): Promise<Response> {
const validation = await validateEmail(input, env);
if (!validation.ok) {
await bumpCounters(env.EMAIL_STORAGE, { emails_rejected: 1 });
return validation.response;
}
await storeEmail(validation.feedId, input, env, ctx);
await bumpCounters(env.EMAIL_STORAGE, {
emails_received: 1,
last_email_at: new Date().toISOString(),
});
return new Response("Email processed successfully", { status: 200 });
}