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 <[email protected]>
This commit is contained in:
1 parent
3e28246c61
commit
e93bbb8d3e
15 files changed
+615
-19
No files matched your search
@@ -1,5 +1,11 @@
|
||||
import { EmailParser } from "../utils/email-parser";
|
||||
import { Env, FeedConfig, FeedMetadata } from "../types";
|
||||
import { AttachmentData, EmailMetadata, Env, FeedConfig, FeedMetadata } from "../types";
|
||||
|
||||
export interface RawAttachment {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
content: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface ProcessEmailInput {
|
||||
toAddress: string;
|
||||
@@ -9,6 +15,7 @@ export interface ProcessEmailInput {
|
||||
content: string;
|
||||
receivedAt: number;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: RawAttachment[];
|
||||
}
|
||||
|
||||
function normalizeEmail(value: string): string {
|
||||
@@ -34,6 +41,29 @@ function senderMatchesAllowlist(
|
||||
return senderDomain === normalizedDomain;
|
||||
}
|
||||
|
||||
async function uploadAttachments(
|
||||
attachments: RawAttachment[],
|
||||
bucket: R2Bucket,
|
||||
): Promise<AttachmentData[]> {
|
||||
return Promise.all(
|
||||
attachments.map(async (att) => {
|
||||
const id = crypto.randomUUID();
|
||||
await bucket.put(id, att.content, {
|
||||
httpMetadata: {
|
||||
contentType: att.contentType,
|
||||
contentDisposition: `attachment; filename="${att.filename}"`,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id,
|
||||
filename: att.filename,
|
||||
contentType: att.contentType,
|
||||
size: att.content.byteLength,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function processEmail(
|
||||
input: ProcessEmailInput,
|
||||
env: Env,
|
||||
@@ -74,12 +104,18 @@ export async function processEmail(
|
||||
}
|
||||
}
|
||||
|
||||
const storedAttachments: AttachmentData[] =
|
||||
env.ATTACHMENT_BUCKET && input.attachments?.length
|
||||
? await uploadAttachments(input.attachments, env.ATTACHMENT_BUCKET)
|
||||
: [];
|
||||
|
||||
const emailData = {
|
||||
subject: input.subject,
|
||||
from: input.from,
|
||||
content: input.content,
|
||||
receivedAt: input.receivedAt,
|
||||
headers: input.headers ?? {},
|
||||
...(storedAttachments.length > 0 ? { attachments: storedAttachments } : {}),
|
||||
};
|
||||
|
||||
const emailKey = `feed:${feedId}:${Date.now()}`;
|
||||
@@ -104,24 +140,36 @@ export async function processEmail(
|
||||
|
||||
const serialised = JSON.stringify(emailData);
|
||||
const serialisedSize = new TextEncoder().encode(serialised).byteLength;
|
||||
feedMetadata.emails.unshift({
|
||||
const newEntry: EmailMetadata = {
|
||||
key: emailKey,
|
||||
subject: emailData.subject,
|
||||
receivedAt: emailData.receivedAt,
|
||||
size: serialisedSize,
|
||||
});
|
||||
...(storedAttachments.length > 0
|
||||
? { attachmentIds: storedAttachments.map((a) => a.id) }
|
||||
: {}),
|
||||
};
|
||||
feedMetadata.emails.unshift(newEntry);
|
||||
|
||||
let totalSize = feedMetadata.emails.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
const toDelete: string[] = [];
|
||||
const toDelete: EmailMetadata[] = [];
|
||||
while (totalSize > maxBytes && feedMetadata.emails.length > 1) {
|
||||
const dropped = feedMetadata.emails.pop()!;
|
||||
totalSize -= dropped.size ?? 0;
|
||||
toDelete.push(dropped.key);
|
||||
toDelete.push(dropped);
|
||||
}
|
||||
|
||||
const r2Deletions =
|
||||
env.ATTACHMENT_BUCKET && toDelete.length > 0
|
||||
? toDelete
|
||||
.flatMap((e) => e.attachmentIds ?? [])
|
||||
.map((id) => env.ATTACHMENT_BUCKET!.delete(id))
|
||||
: [];
|
||||
|
||||
await Promise.all([
|
||||
env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata)),
|
||||
...toDelete.map((k) => env.EMAIL_STORAGE.delete(k)),
|
||||
...toDelete.map((e) => env.EMAIL_STORAGE.delete(e.key)),
|
||||
...r2Deletions,
|
||||
]);
|
||||
|
||||
console.log(`Successfully processed email for feed ${feedId}`);
|
||||
|
||||
Reference in new issue
Block a user