Files
kill-the-news/src/infrastructure/cloudflare-email.ts
T
Julien HerrandClaude Opus 4.7 1a4a479190 feat: decouple read FeedId from inbound MailboxId
Separate the two feed identities so the public read URL never reveals the
inbound address and vice-versa:

- FeedId becomes an opaque high-entropy token (read id + KV key); MailboxId
  (noun.noun.NN) owns the inbound address and the untrusted-input boundary
  via MailboxId.parse. They map only through the inbound:<mailbox> secondary
  index, resolved solely at reception.
- inbound index lifecycle is owned by FeedRepository: written by save/saveConfig,
  dropped by removeFromList(Bulk) — symmetric, never mirrored by hand (removes the
  manual delete in feed-service + the cron loop, and a silent empty-catch).
- Feed.mailboxId exposes a MailboxId VO (symmetry with Feed.id); the
  mailbox@domain shape lives on MailboxId.emailAddress(domain).
- Distinguish mailbox_unknown (no feed claims the address) from feed_not_found
  (dangling index) for observability; both forwardable, both 404.
- Drop the redundant EmailParser.extractMailbox pass-through so MailboxId.parse
  is the single parse boundary.

Docs (README/INSTALL/CLAUDE.md/landing) and tests updated; 439 tests green,
tsc clean, build dry-run OK.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 22:46:37 +02:00

96 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import PostalMime from "postal-mime";
import { Env } from "../types";
import {
processEmail,
RawAttachment,
IngestRejectionReason,
} from "../application/email-processor";
import { bumpCounters } from "../application/stats";
import { normalizeCid } from "../infrastructure/html-processor";
import { logger } from "./logger";
export async function handleCloudflareEmail(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
): Promise<void> {
try {
const email = await PostalMime.parse(message.raw);
const fromAddress = email.from?.address ?? message.from;
const from =
email.from?.name && email.from.address
? `${email.from.name} <${email.from.address}>`
: fromAddress;
const headers: Record<string, string> = {};
for (const h of email.headers) {
headers[h.key] = h.value;
}
const rawAttachments: RawAttachment[] = (email.attachments ?? [])
.filter((a) => a.content instanceof ArrayBuffer)
.map((a) => ({
filename: a.filename || "attachment",
contentType: a.mimeType || "application/octet-stream",
content: a.content as ArrayBuffer,
contentId: normalizeCid(a.contentId),
}));
const result = await processEmail(
{
toAddress: message.to,
from,
senders: [message.from],
subject: email.subject ?? "(no subject)",
content: email.html ?? email.text ?? "",
receivedAt: email.date ? new Date(email.date).getTime() : Date.now(),
headers,
attachments: rawAttachments,
},
env,
ctx,
);
if (!result.ok) {
logger.warn("Inbound email rejected", {
to: message.to,
reason: result.reason,
});
await maybeForwardFallback(message, env, result.reason);
}
} catch (error) {
console.error("Error processing Cloudflare email:", error);
}
}
// Reasons safe to forward to the catch-all fallback: the mail was never a feed's
// (wrong address shape, or no such feed). Expired feeds and blocked senders are
// dropped so a real newsletter never leaks into the fallback inbox.
const FORWARDABLE_REASONS = new Set<IngestRejectionReason>([
"invalid_address",
"mailbox_unknown",
"feed_not_found",
]);
async function maybeForwardFallback(
message: ForwardableEmailMessage,
env: Env,
reason: IngestRejectionReason,
): Promise<void> {
const fallback = env.FALLBACK_FORWARD_ADDRESS;
if (!fallback || !FORWARDABLE_REASONS.has(reason)) return;
try {
await message.forward(fallback);
// Counted as a subset of emails_rejected (already bumped in processEmail);
// the dropped count is derived as emails_rejected − emails_forwarded.
await bumpCounters(env.EMAIL_STORAGE, { emails_forwarded: 1 });
} catch (error) {
logger.warn("Fallback forward failed", {
to: message.to,
fallback,
error: error instanceof Error ? error.message : String(error),
});
}
}