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]>
96 lines
2.9 KiB
TypeScript
96 lines
2.9 KiB
TypeScript
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),
|
||
});
|
||
}
|
||
}
|