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
+58 -6
View File
@@ -1908,7 +1908,7 @@ async function deleteFeedFastDetailed(
async function purgeFeedKeysStep(
emailStorage: KVNamespace,
feedId: string,
options: { cursor?: string; limit?: number } = {},
options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {},
): Promise<{
deletedKeys: string[];
failedKeys: string[];
@@ -1921,6 +1921,33 @@ async function purgeFeedKeysStep(
const listed = await emailStorage.list({ prefix, cursor, limit });
const keys = (listed.keys || []).map((k) => k.name);
// Collect R2 attachment IDs from email entries before deleting
if (options.bucket && keys.length > 0) {
const emailKeys = keys.filter((k) => {
const suffix = k.slice(prefix.length);
return suffix !== "config" && suffix !== "metadata";
});
if (emailKeys.length > 0) {
const emailDataResults = await Promise.allSettled(
emailKeys.map((k) =>
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
),
);
const attachmentIds = emailDataResults
.filter(
(r): r is PromiseFulfilledResult<EmailData | null> =>
r.status === "fulfilled",
)
.flatMap((r) => r.value?.attachments?.map((a) => a.id) ?? []);
if (attachmentIds.length > 0) {
await Promise.allSettled(
attachmentIds.map((id) => options.bucket!.delete(id)),
);
}
}
}
const { ok, failed } = await deleteKeysWithConcurrency(
emailStorage,
keys,
@@ -1949,7 +1976,7 @@ app.post("/feeds/:feedId/delete", async (c) => {
// Best-effort cleanup in the background so the request stays fast.
// Use the UI purge endpoint for full, user-visible progress.
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId, { bucket: env.ATTACHMENT_BUCKET }));
if (wantsJson) {
return c.json({ ok: true, feedId });
}
@@ -1987,6 +2014,7 @@ app.post("/feeds/:feedId/purge", async (c) => {
const step = await purgeFeedKeysStep(emailStorage, feedId, {
cursor,
limit,
bucket: env.ATTACHMENT_BUCKET,
});
return c.json({
@@ -3420,15 +3448,18 @@ app.post("/emails/:emailKey/delete", async (c) => {
return c.text("Feed ID is required", 400);
}
// Delete the email
await emailStorage.delete(emailKey);
// Remove the email from the feed metadata
// Load metadata first to collect attachment IDs for R2 cleanup
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = (await emailStorage.get(feedMetadataKey, {
type: "json",
})) as FeedMetadata | null;
const attachmentIds =
feedMetadata?.emails.find((e) => e.key === emailKey)?.attachmentIds ?? [];
// Delete the email
await emailStorage.delete(emailKey);
if (feedMetadata) {
// Filter out the deleted email
feedMetadata.emails = feedMetadata.emails.filter(
@@ -3439,6 +3470,13 @@ app.post("/emails/:emailKey/delete", async (c) => {
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
}
// Best-effort R2 attachment cleanup
if (env.ATTACHMENT_BUCKET && attachmentIds.length > 0) {
await Promise.allSettled(
attachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)),
);
}
if (wantsJson) {
return c.json({ ok: true, emailKey, feedId });
}
@@ -3508,6 +3546,13 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
}
const candidates = emailKeys.filter((key) => allowedKeys.has(key));
// Collect attachment IDs from metadata before deleting (no extra KV reads needed)
const candidateSet = new Set(candidates);
const r2AttachmentIds = feedMetadata.emails
.filter((e) => candidateSet.has(e.key))
.flatMap((e) => e.attachmentIds ?? []);
const { ok: deletedOk, failed: failedEmailKeys } =
await deleteKeysWithConcurrency(emailStorage, candidates, 35);
@@ -3517,6 +3562,13 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
);
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
// Best-effort R2 attachment cleanup
if (env.ATTACHMENT_BUCKET && r2AttachmentIds.length > 0) {
await Promise.allSettled(
r2AttachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)),
);
}
return c.json({
ok: failedEmailKeys.length === 0,
deletedEmailKeys: deletedOk,