mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import "../test/setup";
|
||||
import { createMockEnv, MockR2 } from "../test/setup";
|
||||
import { Hono } from "hono";
|
||||
import { handle as handleFiles } from "./files";
|
||||
|
||||
async function request(
|
||||
env: ReturnType<typeof createMockEnv>,
|
||||
path: string,
|
||||
): Promise<Response> {
|
||||
const app = new Hono();
|
||||
const files = new Hono();
|
||||
files.get("/:attachmentId/:filename", handleFiles);
|
||||
app.route("/files", files);
|
||||
return app.request(path, {}, env as any);
|
||||
}
|
||||
|
||||
describe("GET /files/:attachmentId/:filename", () => {
|
||||
let envNoR2: ReturnType<typeof createMockEnv>;
|
||||
let envWithR2: ReturnType<typeof createMockEnv>;
|
||||
let mockR2: MockR2;
|
||||
|
||||
beforeEach(() => {
|
||||
envNoR2 = createMockEnv();
|
||||
envWithR2 = createMockEnv({ withR2: true });
|
||||
mockR2 = (envWithR2 as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||
});
|
||||
|
||||
it("returns 404 when ATTACHMENT_BUCKET is not configured", async () => {
|
||||
const res = await request(envNoR2, "/files/some-id/file.pdf");
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.text()).toContain("not configured");
|
||||
});
|
||||
|
||||
it("returns 404 when attachment ID is not found in R2", async () => {
|
||||
const res = await request(envWithR2, "/files/unknown-id/file.pdf");
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.text()).toBe("Not found");
|
||||
});
|
||||
|
||||
it("returns 200 with stored content when attachment exists", async () => {
|
||||
const content = new TextEncoder().encode("PDF content").buffer as ArrayBuffer;
|
||||
await mockR2.put("test-uuid", content, {
|
||||
httpMetadata: { contentType: "application/pdf" },
|
||||
});
|
||||
|
||||
const res = await request(envWithR2, "/files/test-uuid/report.pdf");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns correct Content-Type from stored httpMetadata", async () => {
|
||||
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
|
||||
await mockR2.put("img-uuid", content, {
|
||||
httpMetadata: { contentType: "image/png" },
|
||||
});
|
||||
|
||||
const res = await request(envWithR2, "/files/img-uuid/photo.png");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toContain("image/png");
|
||||
});
|
||||
|
||||
it("sets Cache-Control immutable header", async () => {
|
||||
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
|
||||
await mockR2.put("cache-uuid", content, {
|
||||
httpMetadata: { contentType: "application/pdf" },
|
||||
});
|
||||
|
||||
const res = await request(envWithR2, "/files/cache-uuid/doc.pdf");
|
||||
expect(res.headers.get("Cache-Control")).toBe(
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets Content-Disposition from httpMetadata when present", async () => {
|
||||
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
|
||||
await mockR2.put("disp-uuid", content, {
|
||||
httpMetadata: {
|
||||
contentType: "application/pdf",
|
||||
contentDisposition: 'attachment; filename="stored.pdf"',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(envWithR2, "/files/disp-uuid/other.pdf");
|
||||
expect(res.headers.get("Content-Disposition")).toBe(
|
||||
'attachment; filename="stored.pdf"',
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to URL filename for Content-Disposition when not in httpMetadata", async () => {
|
||||
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
|
||||
await mockR2.put("fallback-uuid", content, {
|
||||
httpMetadata: { contentType: "text/plain" },
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
envWithR2,
|
||||
"/files/fallback-uuid/hello%20world.txt",
|
||||
);
|
||||
expect(res.headers.get("Content-Disposition")).toBe(
|
||||
'attachment; filename="hello world.txt"',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user