import { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { html, raw } from "hono/html"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem, } from "../types"; import { generateFeedId } from "../utils/id-generator"; import { designSystem } from "../styles/index"; import { interactiveScripts } from "../scripts/index"; import { generateCsrfToken, verifyCsrfToken } from "../utils/csrf"; type AppEnv = { Bindings: Env }; /** * Admin routes handler for Email-to-RSS * Provides a secure interface for managing RSS feeds and viewing emails * * Security: * - All routes except /login are protected by server-side cookie authentication * - Uses HttpOnly cookies to prevent XSS attacks * - Implements SameSite=Strict to prevent CSRF attacks */ const app = new Hono(); // Export for testing export default app; const ADMIN_COOKIE_NAME = "admin_auth"; const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week function waitUntilSafe(c: Context, promise: Promise) { // Hono throws when ExecutionContext isn't present (ex: Node unit tests). try { c.executionCtx.waitUntil(promise); } catch { // ignore } } function normalizeAllowedSenders(senders: string[]): string[] { return senders.map((s) => s.trim().toLowerCase()).filter(Boolean); } function parseAllowedSenders(rawAllowedSenders: string): string[] { return normalizeAllowedSenders(rawAllowedSenders.split(/[\n,]+/)); } function clampText(value: string, maxLen: number): string { const raw = `${value || ""}`; if (raw.length <= maxLen) { return raw.trim(); } if (maxLen <= 3) { return raw.slice(0, maxLen).trim(); } return `${raw.slice(0, maxLen - 3).trimEnd()}...`; } // Prevent accidental caching of admin pages and redirects. app.use("*", async (c, next) => { c.header("Cache-Control", "no-store, max-age=0"); await next(); }); function timingSafeEqual(a: string, b: string): boolean { const enc = new TextEncoder(); const aBytes = enc.encode(a); const bBytes = enc.encode(b); // Try native timing-safe implementation first (Cloudflare Workers runtime) // eslint-disable-next-line @typescript-eslint/no-explicit-any const subtle = crypto.subtle as any; if (typeof subtle.timingSafeEqual === "function") { if (aBytes.length !== bBytes.length) return false; return subtle.timingSafeEqual(aBytes, bBytes); } // Constant-time fallback for Node (test environment): encode length // mismatch into `diff` so the loop always runs over the full length. const len = Math.max(aBytes.length, bBytes.length); let diff = aBytes.length ^ bBytes.length; for (let i = 0; i < len; i++) { diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0); } return diff === 0; } // Authentication middleware for admin routes async function authMiddleware(c: Context, next: () => Promise) { const env = c.env; const path = new URL(c.req.url).pathname; // Skip auth check for login page - note that path includes /admin prefix if (path === "/admin/login") { return next(); } // Proxy auth: only active when both env vars are present if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) { const trustedIps = env.PROXY_TRUSTED_IPS.split(",") .map((s: string) => s.trim()) .filter(Boolean); const clientIp = c.req.header("CF-Connecting-IP") ?? ""; const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? ""; const remoteUser = c.req.header("Remote-User") || c.req.header("X-Forwarded-User") || ""; if ( trustedIps.includes(clientIp) && timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) && remoteUser.length > 0 ) { return next(); } } // Fallback: signed cookie const authCookie = await getSignedCookie( c, env.ADMIN_PASSWORD, ADMIN_COOKIE_NAME, ); if (authCookie !== "1") { return c.redirect("/admin/login"); } await next(); } // Apply auth middleware to all admin routes app.use("*", authMiddleware); // CSRF middleware: generates token for GET requests and validates on mutating requests app.use("*", async (c, next) => { const path = new URL(c.req.url).pathname; // Login route is pre-auth, so CSRF doesn't apply there if (path === "/admin/login") { return next(); } const token = await generateCsrfToken(c.env.ADMIN_PASSWORD); c.set("csrfToken", token); if ( c.req.method === "POST" || c.req.method === "PUT" || c.req.method === "DELETE" ) { // Accept token from X-CSRF-Token header (JS fetch calls) const headerToken = c.req.header("X-CSRF-Token") ?? ""; if (headerToken) { if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, headerToken))) { return c.text("Invalid CSRF token", 403); } return next(); } // For HTML form submissions: clone the request body to read _csrf without consuming the stream const contentType = c.req.header("Content-Type") ?? ""; if ( contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data") ) { const form = await c.req.raw.clone().formData(); const formToken = form.get("_csrf")?.toString() ?? ""; if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, formToken))) { return c.text("Invalid CSRF token", 403); } } } return next(); }); // Schema for feed creation const createFeedSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().optional(), language: z.string().optional().default("en"), allowedSenders: z.array(z.string()).optional().default([]), }); // Schema for feed updates const updateFeedSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().optional(), language: z.string().optional().default("en"), allowedSenders: z.array(z.string()).optional().default([]), }); // Authentication schema const authSchema = z.object({ password: z.string().min(1, "Password is required"), }); // Base HTML layout with design system // eslint-disable-next-line @typescript-eslint/no-explicit-any const layout = (title: string, content: any, csrfToken = "") => { return html` ${title} - Email to RSS Admin ${content} `; }; // Login page app.get("/login", (c) => { const error = c.req.query("error"); const errorMessage = error === "invalid" ? "Invalid password. Please try again." : ""; return c.html( layout( "Login", html`

Email to RSS Admin

${errorMessage ? html`
${errorMessage}
` : ""}
`, ), ); }); // Handle login app.post("/login", async (c) => { const env = c.env; try { const formData = await c.req.formData(); const password = formData.get("password")?.toString() || ""; // Validate password authSchema.parse({ password }); // Check password against environment variable if (timingSafeEqual(password, env.ADMIN_PASSWORD)) { await setSignedCookie(c, ADMIN_COOKIE_NAME, "1", env.ADMIN_PASSWORD, { path: "/", httpOnly: true, sameSite: "Strict", secure: true, maxAge: ADMIN_COOKIE_MAX_AGE, }); return c.redirect("/admin"); } // Incorrect password - redirect back to login with an error message return c.redirect("/admin/login?error=invalid"); } catch (error) { console.error("Login error:", error); return c.redirect("/admin/login?error=invalid"); } }); // Logout route app.get("/logout", (c) => { deleteCookie(c, ADMIN_COOKIE_NAME, { path: "/" }); return c.redirect("/admin/login"); }); // Admin dashboard route app.get("/", async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const url = new URL(c.req.url); const view = url.searchParams.get("view") === "table" ? "table" : "list"; const message = url.searchParams.get("message"); const count = Number(url.searchParams.get("count") || "0"); // List all feeds const feedList = await listAllFeeds(emailStorage); // Keep the dashboard fast: avoid N KV reads for N feeds. // We store title/description in `feeds:list` (description is optional for older data). const feedsWithConfig = feedList.map((feed) => ({ ...feed, description: feed.description || "", })); const viewHref = (nextView: "list" | "table") => { const nextUrl = new URL(url); nextUrl.pathname = "/admin"; nextUrl.searchParams.set("view", nextView); const qs = nextUrl.searchParams.toString(); return `${nextUrl.pathname}${qs ? `?${qs}` : ""}`; }; const viewToggle = html` `; return c.html( layout( "Dashboard", html`

Email to RSS Admin

Manage your email newsletter feeds

Create New Feed

When set, inbound emails are only accepted from these senders/domains.
${message === "bulkDeleted" ? html`

Deleted ${Number.isFinite(count) ? count : 0} feed(s).

` : ""} ${message === "bulkDeleteNoop" ? html`

No feeds were selected.

` : ""}

Your Feeds

${feedsWithConfig.length}
${viewToggle}
${feedsWithConfig.length === 0 ? html`

You don't have any feeds yet. Create one above.

` : view === "table" ? html`
Showing ${feedsWithConfig.length} 0 selected
${feedsWithConfig.map((feed) => { const emailAddress = `${feed.id}@${env.DOMAIN}`; const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const titleDisplay = clampText(feed.title, 160); const titleHover = clampText(feed.title, 1000); const sortTitle = titleHover.toLowerCase(); const sortFeedId = feed.id.toLowerCase(); const sortEmail = emailAddress.toLowerCase(); const sortRss = rssUrl.toLowerCase(); const descDisplay = clampText( feed.description || "", 220, ); const descHover = clampText( feed.description || "", 1000, ); const searchHaystack = `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); return html` `; })}
Actions
${titleDisplay} ${feed.description ? html`
${descDisplay}
` : ""}
${feed.id}
${emailAddress}
${rssUrl}
Edit Emails
` : html`
Tip: use Table view for bulk deletion.
    ${feedsWithConfig.map((feed) => { const emailAddress = `${feed.id}@${env.DOMAIN}`; const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const titleDisplay = clampText(feed.title, 140); const titleHover = clampText(feed.title, 1000); const descDisplay = clampText( feed.description || "", 240, ); const descHover = clampText(feed.description || "", 1000); const searchHaystack = `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); return html`
  • ${titleDisplay}

    ${feed.description ? html`

    ${descDisplay}

    ` : ""}
    Email:
    ${emailAddress}
    RSS Feed:
    ${rssUrl}
  • `; })}
`}
`, c.var.csrfToken ?? "", ), ); }); // Create a new feed app.post("/feeds/create", async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const isJson = c.req.header("Content-Type")?.includes("application/json") ?? false; try { let title: string; let description: string | undefined; let language: string; let view: string; let allowedSenders: string[]; if (isJson) { const body = await c.req.json>(); title = String(body.title ?? ""); description = body.description != null ? String(body.description) : undefined; language = String(body.language ?? "en"); view = "list"; allowedSenders = Array.isArray(body.allowedSenders) ? normalizeAllowedSenders( (body.allowedSenders as unknown[]).map(String), ) : []; } else { const formData = await c.req.formData(); title = formData.get("title")?.toString() || ""; description = formData.get("description")?.toString(); language = formData.get("language")?.toString() || "en"; view = formData.get("view")?.toString() === "table" ? "table" : "list"; allowedSenders = parseAllowedSenders( formData.get("allowed_senders")?.toString() || "", ); } // Validate inputs const parsedData = createFeedSchema.parse({ title, description, language, allowedSenders, }); // Generate a feed ID const feedId = generateFeedId(); // Create feed configuration const feedConfig: FeedConfig = { title: parsedData.title, description: parsedData.description, language: parsedData.language, site_url: `https://${env.DOMAIN}/rss/${feedId}`, feed_url: `https://${env.DOMAIN}/rss/${feedId}`, allowed_senders: parsedData.allowedSenders, created_at: Date.now(), updated_at: Date.now(), }; // Create feed metadata const feedMetadata: FeedMetadata = { emails: [], }; await Promise.all([ emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)), emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)), ]); await addFeedToList( emailStorage, feedId, parsedData.title, parsedData.description, ); if (isJson) { return c.json({ feedId, email: `${feedId}@${env.DOMAIN}`, feedUrl: feedConfig.feed_url, }); } // Redirect back to admin page return c.redirect(`/admin?view=${view}`); } catch (error) { console.error("Error creating feed:", error); if (isJson) { return c.json({ error: "Error creating feed." }, 400); } return c.text("Error creating feed. Please try again.", 400); } }); // Edit feed page app.get("/feeds/:feedId/edit", async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); // Get feed configuration const feedConfigKey = `feed:${feedId}:config`; const feedConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; if (!feedConfig) { return c.text("Feed not found", 404); } return c.html( layout( "Edit Feed", html`

${feedConfig.title} - Edit Feed

When set, inbound emails are only accepted from these senders/domains.
`, c.var.csrfToken ?? "", ), ); }); // Update feed app.post("/feeds/:feedId/edit", async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const formData = await c.req.formData(); const title = formData.get("title")?.toString() || ""; const description = formData.get("description")?.toString(); const language = formData.get("language")?.toString() || "en"; const allowedSenders = parseAllowedSenders( formData.get("allowed_senders")?.toString() || "", ); // Validate inputs const parsedData = updateFeedSchema.parse({ title, description, language, allowedSenders, }); // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; const existingConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; if (!existingConfig) { return c.text("Feed not found", 404); } // Update feed configuration await emailStorage.put( feedConfigKey, JSON.stringify({ ...existingConfig, title: parsedData.title, description: parsedData.description, language: parsedData.language, allowed_senders: parsedData.allowedSenders, updated_at: Date.now(), }), ); // Update feed in the list of all feeds await updateFeedInList( emailStorage, feedId, parsedData.title, parsedData.description, ); // Redirect back to admin page return c.redirect("/admin"); } catch (error) { console.error("Error updating feed:", error); return c.text("Error updating feed. Please try again.", 400); } }); async function deleteKeysWithConcurrency( emailStorage: KVNamespace, keys: string[], concurrency: number, ): Promise<{ ok: string[]; failed: string[] }> { const uniqueKeys = Array.from(new Set(keys.filter(Boolean))); const ok: string[] = []; const failed: string[] = []; const limit = Math.max(1, Math.floor(concurrency) || 1); for (let i = 0; i < uniqueKeys.length; i += limit) { const batch = uniqueKeys.slice(i, i + limit); const results = await Promise.allSettled( batch.map((key) => emailStorage.delete(key)), ); results.forEach((result, idx) => { const key = batch[idx]; if (result.status === "fulfilled") { ok.push(key); } else { failed.push(key); } }); } return { ok, failed }; } async function deleteFeedFast( emailStorage: KVNamespace, feedId: string, ): Promise { const result = await deleteFeedFastDetailed(emailStorage, feedId); return result.ok; } type DeleteFeedFastResult = { // "ok" means the feed is deactivated (config deleted). Metadata is best-effort. ok: boolean; configDeleted: boolean; metadataDeleted: boolean; errors: string[]; }; async function deleteFeedFastDetailed( emailStorage: KVNamespace, feedId: string, ): Promise { const feedConfigKey = `feed:${feedId}:config`; const feedMetadataKey = `feed:${feedId}:metadata`; const errors: string[] = []; let configDeleted = false; let metadataDeleted = false; try { await emailStorage.delete(feedConfigKey); configDeleted = true; } catch (error) { errors.push(`config delete failed: ${String(error)}`); } // Best-effort: if config is gone the feed is effectively disabled. try { await emailStorage.delete(feedMetadataKey); metadataDeleted = true; } catch (error) { errors.push(`metadata delete failed: ${String(error)}`); } return { ok: configDeleted, configDeleted, metadataDeleted, errors }; } async function purgeFeedKeysStep( emailStorage: KVNamespace, feedId: string, options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {}, ): Promise<{ deletedKeys: string[]; failedKeys: string[]; cursor: string; listComplete: boolean; }> { const prefix = `feed:${feedId}:`; const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100))); const cursor = options.cursor || undefined; 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, ), ); const attachmentIds = emailDataResults .filter( (r): r is PromiseFulfilledResult => 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, 35, ); return { deletedKeys: ok, failedKeys: failed, cursor: listed.cursor || "", listComplete: !!listed.list_complete, }; } // Delete feed app.post("/feeds/:feedId/delete", async (c) => { const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); const view = c.req.query("view") === "table" ? "table" : "list"; const wantsJson = (c.req.header("Accept") || "").includes("application/json"); try { await deleteFeedFast(emailStorage, feedId); await removeFeedFromList(emailStorage, feedId); // 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, { bucket: env.ATTACHMENT_BUCKET, }), ); if (wantsJson) { return c.json({ ok: true, feedId }); } return c.redirect(`/admin?view=${view}`); } catch (error) { console.error("Error deleting feed:", error); if (wantsJson) { return c.json( { ok: false, error: "Error deleting feed. Please try again." }, 400, ); } return c.text("Error deleting feed. Please try again.", 400); } }); // Purge all keys for a feed in small steps (used by the admin UI after deleting feeds). app.post("/feeds/:feedId/purge", async (c) => { const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const body = (await c.req.json().catch(() => null)) as { cursor?: unknown; limit?: unknown; } | null; const cursor = body?.cursor ? String(body.cursor) : undefined; // Keep purge requests small to avoid Cloudflare per-request limits. const limit = Number.isFinite(Number(body?.limit)) ? Number(body?.limit) : 100; const step = await purgeFeedKeysStep(emailStorage, feedId, { cursor, limit, bucket: env.ATTACHMENT_BUCKET, }); return c.json({ ok: step.failedKeys.length === 0, deletedCount: step.deletedKeys.length, failedCount: step.failedKeys.length, cursor: step.cursor, listComplete: step.listComplete, }); } catch (error) { console.error("Error purging feed keys:", error); return c.json({ ok: false, error: "Error purging feed keys" }, 500); } }); // Bulk delete feeds selected in the dashboard app.post("/feeds/bulk-delete", async (c) => { const env = c.env; const emailStorage = env.EMAIL_STORAGE; const contentType = c.req.header("Content-Type") || ""; const wantsJson = contentType.includes("application/json") || (c.req.header("Accept") || "").includes("application/json"); try { if (wantsJson) { const body = (await c.req.json().catch(() => null)) as { feedIds?: unknown; } | null; const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : []; const parsedFeedIds = Array.from( new Set(rawIds.map((value) => String(value)).filter(Boolean)), ); if (parsedFeedIds.length === 0) { return c.json({ ok: false, error: "No feeds were selected." }, 400); } // The UI batches requests; cap to avoid accidental huge deletes in one request. if (parsedFeedIds.length > 50) { return c.json( { ok: false, error: "Too many feedIds for a single request. Please delete in smaller batches.", }, 413, ); } const okIds: string[] = []; const failures: Array<{ feedId: string; error: string }> = []; const warnings: Array<{ feedId: string; warning: string }> = []; // Keep this request intentionally small/cheap (the UI already batches calls). for (const feedId of parsedFeedIds) { try { const result = await deleteFeedFastDetailed(emailStorage, feedId); if (!result.ok) { failures.push({ feedId, error: result.errors.join("; ") || "Failed to delete feed config (feed may still be active).", }); continue; } if (!result.metadataDeleted) { warnings.push({ feedId, warning: "Feed config deleted, but metadata cleanup failed. This is usually safe, but storage cleanup may be incomplete.", }); } okIds.push(feedId); } catch (error) { console.error("Error bulk deleting feed:", feedId, error); failures.push({ feedId, error: String(error) }); } } const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); // If config deletion succeeded but list removal didn't, surface it explicitly. const removed = new Set(deletedFeedIds); okIds.forEach((feedId) => { if (!removed.has(feedId)) { failures.push({ feedId, error: "Feed config deleted, but failed to remove it from feeds:list. Refresh and try again.", }); } }); const failedFeedIds = Array.from(new Set(failures.map((f) => f.feedId))); return c.json({ ok: failedFeedIds.length === 0, deletedFeedIds, failedFeedIds, failures, warnings, }); } const formData = await c.req.formData(); const view = formData.get("view")?.toString() === "table" ? "table" : "list"; const redirectBase = `/admin?view=${view}`; const rawIds = formData.getAll("feedIds").map((value) => value.toString()); const parsedFeedIds = Array.from(new Set(rawIds.filter(Boolean))); if (parsedFeedIds.length === 0) { return c.redirect(`${redirectBase}&message=bulkDeleteNoop`); } const okIds: string[] = []; for (const feedId of parsedFeedIds) { try { const result = await deleteFeedFastDetailed(emailStorage, feedId); if (result.ok) okIds.push(feedId); } catch (error) { console.error("Error bulk deleting feed:", feedId, error); } } const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); return c.redirect( `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, ); } catch (error) { console.error("Error bulk deleting feeds:", error); return wantsJson ? c.json( { ok: false, error: "Server error while deleting feeds. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", }, 500, ) : c.text("Error bulk deleting feeds. Please try again.", 500); } }); // View all emails for a feed app.get("/feeds/:feedId/emails", async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); const message = c.req.query("message"); const count = Number(c.req.query("count") || "0"); // Get feed configuration and metadata const feedConfigKey = `feed:${feedId}:config`; const feedMetadataKey = `feed:${feedId}:metadata`; const feedConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", })) as FeedMetadata | null; if (!feedConfig || !feedMetadata) { return c.text("Feed not found", 404); } return c.html( layout( `${feedConfig.title} - Emails`, html`

${feedConfig.title} - Emails

Feed Details

Email Address:
${feedId}@${env.DOMAIN}
RSS Feed:
https://${env.DOMAIN}/rss/${feedId}

Emails (${feedMetadata.emails.length})

${message === "bulkDeleted" ? html`

Deleted ${Number.isFinite(count) ? count : 0} email(s).

` : ""} ${message === "bulkDeleteNoop" ? html`

No emails were selected.

` : ""} ${feedMetadata.emails.length > 0 ? html`
Showing ${feedMetadata.emails.length} 0 selected
${feedMetadata.emails.map((email: EmailMetadata) => { const subjectDisplay = clampText(email.subject, 180); const subjectHover = clampText(email.subject, 1000); const sortSubject = subjectHover.toLowerCase(); const sortReceivedAt = String(email.receivedAt); const searchHaystack = clampText( email.subject, 320, ).toLowerCase(); return html` `; })}
Actions
` : html`

No emails received yet. Subscribe to newsletters using the email address above.

`}
`, c.var.csrfToken ?? "", ), ); }); // View email content app.get("/emails/:emailKey", async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param("emailKey"); // Get email data const emailData = (await emailStorage.get(emailKey, { type: "json", })) as EmailData | null; if (!emailData) { return c.text("Email not found", 404); } // Extract feed ID from the key format (feed:ID:emails:timestamp) const keyParts = emailKey.split(":"); const feedId = keyParts[1]; // Create a sanitized HTML content with CSS for the iframe const htmlContent = ` ${emailData.content} `; // Properly encode the HTML content to handle Unicode characters const encodedHtmlContent = (() => { // Convert the string to UTF-8 const encoder = new TextEncoder(); const bytes = encoder.encode(htmlContent); // Convert bytes to base64 return btoa(String.fromCharCode(...new Uint8Array(bytes))); })(); return c.html( layout( `Email: ${emailData.subject}`, html`

Email Content

`, c.var.csrfToken ?? "", ), ); }); // Delete email app.post("/emails/:emailKey/delete", async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param("emailKey"); const wantsJson = (c.req.header("Accept") || "").includes("application/json"); try { // Get feedId from query parameters instead of form data const feedId = c.req.query("feedId"); if (!feedId) { if (wantsJson) { return c.json({ ok: false, error: "Feed ID is required" }, 400); } return c.text("Feed ID is required", 400); } // 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( (email) => email.key !== emailKey, ); // Update feed metadata 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 }); } // Redirect back to the feed emails page return c.redirect(`/admin/feeds/${feedId}/emails`); } catch (error) { console.error("Error deleting email:", error); if (wantsJson) { return c.json( { ok: false, error: "Error deleting email. Please try again." }, 400, ); } return c.text("Error deleting email. Please try again.", 400); } }); // Bulk delete selected emails from a feed app.post("/feeds/:feedId/emails/bulk-delete", async (c) => { const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); const contentType = c.req.header("Content-Type") || ""; const wantsJson = contentType.includes("application/json") || (c.req.header("Accept") || "").includes("application/json"); try { const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", })) as FeedMetadata | null; if (!feedMetadata) { return wantsJson ? c.json({ ok: false, error: "Feed not found" }, 404) : c.text("Feed not found", 404); } const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key)); if (wantsJson) { const body = (await c.req.json().catch(() => null)) as { emailKeys?: unknown; } | null; const rawEmailKeys = Array.isArray(body?.emailKeys) ? body?.emailKeys : []; const emailKeys = Array.from( new Set(rawEmailKeys.map((value) => String(value)).filter(Boolean)), ); if (emailKeys.length === 0) { return c.json({ ok: false, error: "No emails were selected." }, 400); } // The UI batches requests; cap to avoid accidental huge deletes in one request. if (emailKeys.length > 250) { return c.json( { ok: false, error: "Too many emailKeys for a single request. Please delete in smaller batches.", }, 413, ); } 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); const deletedSet = new Set(deletedOk); feedMetadata.emails = feedMetadata.emails.filter( (email) => !deletedSet.has(email.key), ); 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, failedEmailKeys, }); } const formData = await c.req.formData(); const rawEmailKeys = formData .getAll("emailKeys") .map((value) => value.toString()); const emailKeys = Array.from(new Set(rawEmailKeys.filter(Boolean))); if (emailKeys.length === 0) { return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`); } const candidates = emailKeys.filter((key) => allowedKeys.has(key)); const { ok: deletedOk } = await deleteKeysWithConcurrency( emailStorage, candidates, 35, ); const deletedSet = new Set(deletedOk); feedMetadata.emails = feedMetadata.emails.filter( (email) => !deletedSet.has(email.key), ); await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); return c.redirect( `/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`, ); } catch (error) { console.error("Error bulk deleting emails:", error); return wantsJson ? c.json( { ok: false, error: "Server error while deleting emails. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", }, 500, ) : c.text("Error bulk deleting emails. Please try again.", 500); } }); // Helper function to list all feeds async function listAllFeeds( emailStorage: KVNamespace, ): Promise { try { const feedListKey = "feeds:list"; const feedList = (await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null; return feedList?.feeds || []; } catch (error) { console.error("Error listing feeds:", error); return []; } } // Helper function to add a feed to the list of all feeds async function addFeedToList( emailStorage: KVNamespace, feedId: string, title: string, description?: string, ): Promise { try { const feedListKey = "feeds:list"; const feedList = ((await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null) || { feeds: [] }; // Add new feed to the list feedList.feeds.push({ id: feedId, title, description, }); // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } catch (error) { console.error("Error adding feed to list:", error); } } // Helper function to update a feed in the list of all feeds async function updateFeedInList( emailStorage: KVNamespace, feedId: string, title: string, description?: string, ): Promise { try { const feedListKey = "feeds:list"; const feedList = ((await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null) || { feeds: [] }; // Find and update the feed in the list const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId); if (feedIndex !== -1) { feedList.feeds[feedIndex].title = title; feedList.feeds[feedIndex].description = description; // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } } catch (error) { console.error("Error updating feed in list:", error); } } async function removeFeedsFromListBulk( emailStorage: KVNamespace, feedIds: string[], ): Promise { try { const feedListKey = "feeds:list"; const feedList = ((await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null) || { feeds: [] }; const toRemove = new Set(feedIds.filter(Boolean)); if (toRemove.size === 0) { return []; } const removed: string[] = []; const nextFeeds: FeedListItem[] = []; for (const feed of feedList.feeds) { if (toRemove.has(feed.id)) { removed.push(feed.id); continue; } nextFeeds.push(feed); } if (removed.length === 0) { return []; } feedList.feeds = nextFeeds; // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); return removed; } catch (error) { console.error("Error removing feed from list:", error); return []; } } // Helper function to remove a feed from the list of all feeds async function removeFeedFromList( emailStorage: KVNamespace, feedId: string, ): Promise { const removed = await removeFeedsFromListBulk(emailStorage, [feedId]); return removed.includes(feedId); } // Update feed via API (for in-place editing) app.post( "/api/feeds/:feedId/update", zValidator( "json", updateFeedSchema.pick({ title: true, description: true }), (result, c) => { if (!result.success) return c.json({ success: false, error: result.error.issues }, 400); }, ), async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const { title, description } = c.req.valid("json"); const parsedData = { title, description, language: "en" as const }; // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; const existingConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; if (!existingConfig) { return c.json({ error: "Feed not found" }, 404); } // Update feed configuration await emailStorage.put( feedConfigKey, JSON.stringify({ ...existingConfig, title: parsedData.title, description: parsedData.description, updated_at: Date.now(), }), ); // Update feed in the list of all feeds await updateFeedInList( emailStorage, feedId, parsedData.title, parsedData.description, ); // Return success response return c.json({ success: true }); } catch (error) { console.error("Error updating feed via API:", error); return c.json({ error: "Error updating feed" }, 400); } }, ); // Export the Hono app export const handle = app;