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
+81 -1
View File
@@ -157,12 +157,92 @@ afterEach(() => {
server.resetHandlers();
});
/**
* Mock R2 bucket implementation
* Simulates Cloudflare Workers R2 storage using an in-memory Map
*/
export class MockR2 {
private store: Map<
string,
{
body: ArrayBuffer;
httpMetadata?: { contentType?: string; contentDisposition?: string };
httpEtag: string;
}
> = new Map();
async put(
key: string,
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob,
options?: {
httpMetadata?: { contentType?: string; contentDisposition?: string };
},
) {
let buffer: ArrayBuffer;
if (value instanceof ArrayBuffer) {
buffer = value;
} else if (typeof value === "string") {
buffer = new TextEncoder().encode(value).buffer as ArrayBuffer;
} else if (ArrayBuffer.isView(value)) {
buffer = value.buffer as ArrayBuffer;
} else {
buffer = new ArrayBuffer(0);
}
this.store.set(key, {
body: buffer,
httpMetadata: options?.httpMetadata,
httpEtag: `"mock-etag-${key}"`,
});
}
async get(key: string) {
const entry = this.store.get(key);
if (!entry) return null;
return {
key,
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(entry.body));
controller.close();
},
}),
httpEtag: entry.httpEtag,
httpMetadata: entry.httpMetadata,
size: entry.body.byteLength,
arrayBuffer: async () => entry.body,
text: async () => new TextDecoder().decode(entry.body),
writeHttpMetadata: (headers: Headers) => {
if (entry.httpMetadata?.contentType) {
headers.set("Content-Type", entry.httpMetadata.contentType);
}
if (entry.httpMetadata?.contentDisposition) {
headers.set("Content-Disposition", entry.httpMetadata.contentDisposition);
}
},
};
}
async delete(keys: string | string[]) {
const arr = Array.isArray(keys) ? keys : [keys];
for (const k of arr) {
this.store.delete(k);
}
}
_has(key: string) {
return this.store.has(key);
}
}
/**
* Create a mock environment for testing
* @returns Mock environment with KV storage and configuration
*/
export const createMockEnv = () => ({
export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
EMAIL_STORAGE: new MockKV(),
DOMAIN: "test.getmynews.app",
ADMIN_PASSWORD: "test-password",
...(options.withR2
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
: {}),
});