feat: store email attachments in R2 and expose as RSS enclosures

Attachments from incoming emails are uploaded to an optional Cloudflare R2
bucket and exposed as <enclosure> elements in RSS and <link rel="enclosure">
in Atom feeds, served at /files/{id}/{filename} with immutable caching.

R2 is opt-in: if ATTACHMENT_BUCKET is not bound the feature is a no-op.
Attachments are cleaned up from R2 on email/feed deletion and during
size-based feed trimming. Adds MockR2 to the test setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-21 09:09:37 +02:00
parent 3e28246c61
commit e93bbb8d3e
15 changed files with 615 additions and 19 deletions
+37 -2
View File
@@ -1,6 +1,16 @@
import { EmailParser } from "../utils/email-parser";
import { Env } from "../types";
import { processEmail } from "./email-processor";
import { processEmail, RawAttachment } from "./email-processor";
export interface ForwardEmailAttachment {
filename?: string;
contentType?: string;
size?: number;
content?:
| { type: "Buffer"; data: number[] }
| ArrayBuffer
| ArrayBufferView;
}
export interface ForwardEmailPayload {
recipients?: string[];
@@ -17,7 +27,7 @@ export interface ForwardEmailPayload {
headerLines?: Array<{ key: string; line: string }>;
headers?: string;
raw?: string;
attachments?: Array<any>;
attachments?: ForwardEmailAttachment[];
}
function normalizeEmail(value: string): string {
@@ -41,12 +51,36 @@ function extractSenderAddresses(payload: ForwardEmailPayload): string[] {
return Array.from(new Set(matches.map(normalizeEmail)));
}
function toArrayBuffer(
content: ForwardEmailAttachment["content"],
): ArrayBuffer | null {
if (!content) return null;
if (content instanceof ArrayBuffer) return content;
if (ArrayBuffer.isView(content)) return (content as ArrayBufferView).buffer as ArrayBuffer;
if (typeof content === "object" && content.type === "Buffer" && Array.isArray(content.data)) {
return Uint8Array.from(content.data).buffer as ArrayBuffer;
}
return null;
}
export async function handleForwardEmail(
payload: ForwardEmailPayload,
env: Env,
): Promise<Response> {
const emailData = EmailParser.parseForwardEmailPayload(payload);
const rawAttachments: RawAttachment[] = (payload.attachments ?? [])
.map((a) => {
const buffer = toArrayBuffer(a.content);
if (!buffer) return null;
return {
filename: a.filename || "attachment",
contentType: a.contentType || "application/octet-stream",
content: buffer,
};
})
.filter((a): a is RawAttachment => a !== null);
return processEmail(
{
toAddress: payload.recipients?.[0] || "",
@@ -56,6 +90,7 @@ export async function handleForwardEmail(
content: emailData.content,
receivedAt: emailData.receivedAt,
headers: emailData.headers,
attachments: rawAttachments,
},
env,
);