import { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { html, raw } from "hono/html"; 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"; /** * 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 parseAllowedSenders(rawAllowedSenders: string): string[] { return rawAllowedSenders .split(/[\n,]+/) .map((value) => value.trim().toLowerCase()) .filter(Boolean); } 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(); }); // Authentication middleware for admin routes async function authMiddleware(c: Context, next: () => Promise) { const env = c.env as unknown as 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(); } 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); // 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 const layout = (title: string, content: any) => { 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 as unknown as 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 (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 as unknown as 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}
  • `; })}
`}
`, ), ); }); // Create a new feed app.post("/feeds/create", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; 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 view = formData.get("view")?.toString() === "table" ? "table" : "list"; const 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: [], }; // Store feed configuration and metadata await emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)); await emailStorage.put( `feed:${feedId}:metadata`, JSON.stringify(feedMetadata), ); // Add feed to the list of all feeds await addFeedToList( emailStorage, feedId, parsedData.title, parsedData.description, ); // Redirect back to admin page return c.redirect(`/admin?view=${view}`); } catch (error) { console.error("Error creating feed:", error); 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 as unknown as 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.
`, ), ); }); // Update feed app.post("/feeds/:feedId/edit", async (c) => { // Type assertion for environment variables const env = c.env as unknown as 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 feedConfigKey = `feed:${feedId}:config`; const feedMetadataKey = `feed:${feedId}:metadata`; const results = await Promise.allSettled([ emailStorage.delete(feedConfigKey), emailStorage.delete(feedMetadataKey), ]); return results.every((r) => r.status === "fulfilled"); } async function purgeFeedKeysStep( emailStorage: KVNamespace, feedId: string, options: { cursor?: string; limit?: number } = {}, ): 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 || 250)), ); const cursor = options.cursor || undefined; const listed = await emailStorage.list({ prefix, cursor, limit }); const keys = (listed.keys || []).map((k) => k.name); 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 as unknown as 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)); 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 as unknown as 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; const limit = Number.isFinite(Number(body?.limit)) ? Number(body?.limit) : 250; const step = await purgeFeedKeysStep(emailStorage, feedId, { cursor, limit, }); 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 as unknown as Env; const emailStorage = env.EMAIL_STORAGE; try { const contentType = c.req.header("Content-Type") || ""; const wantsJson = contentType.includes("application/json") || (c.req.header("Accept") || "").includes("application/json"); 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 results: Array<{ feedId: string; ok: boolean }> = []; const concurrency = 10; for (let i = 0; i < parsedFeedIds.length; i += concurrency) { const batch = parsedFeedIds.slice(i, i + concurrency); const batchResults = await Promise.all( batch.map(async (feedId) => { try { const ok = await deleteFeedFast(emailStorage, feedId); return { feedId, ok }; } catch (error) { console.error("Error bulk deleting feed:", feedId, error); return { feedId, ok: false }; } }), ); results.push(...batchResults); } const okIds = results.filter((r) => r.ok).map((r) => r.feedId); const failedFeedIds = results.filter((r) => !r.ok).map((r) => r.feedId); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); // Best-effort: kick off small purge steps in the background so storage starts clearing. // The UI also runs purge steps for full cleanup + progress. deletedFeedIds.forEach((feedId) => { waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId)); }); return c.json({ ok: failedFeedIds.length === 0, deletedFeedIds, failedFeedIds, }); } 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 results: Array<{ feedId: string; ok: boolean }> = []; const concurrency = 10; for (let i = 0; i < parsedFeedIds.length; i += concurrency) { const batch = parsedFeedIds.slice(i, i + concurrency); const batchResults = await Promise.all( batch.map(async (feedId) => { try { const ok = await deleteFeedFast(emailStorage, feedId); return { feedId, ok }; } catch (error) { console.error("Error bulk deleting feed:", feedId, error); return { feedId, ok: false }; } }), ); results.push(...batchResults); } const okIds = results.filter((r) => r.ok).map((r) => r.feedId); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); deletedFeedIds.forEach((feedId) => { waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId)); }); return c.redirect( `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, ); } catch (error) { console.error("Error bulk deleting feeds:", error); return c.text("Error bulk deleting feeds. Please try again.", 400); } }); // View all emails for a feed app.get("/feeds/:feedId/emails", async (c) => { // Type assertion for environment variables const env = c.env as unknown as 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.

`}
`, ), ); }); // View email content app.get("/emails/:emailKey", async (c) => { // Type assertion for environment variables const env = c.env as unknown as 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

`, ), ); }); // Delete email app.post("/emails/:emailKey/delete", async (c) => { // Type assertion for environment variables const env = c.env as unknown as 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); } // Delete the email await emailStorage.delete(emailKey); // Remove the email from the feed metadata const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", })) as FeedMetadata | null; 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)); } 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 as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const contentType = c.req.header("Content-Type") || ""; const wantsJson = contentType.includes("application/json") || (c.req.header("Accept") || "").includes("application/json"); 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)); 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)); 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 c.text("Error bulk deleting emails. Please try again.", 400); } }); // 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", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { // Parse JSON data from request const data = await c.req.json(); const { title, description } = data; // Validate inputs const parsedData = updateFeedSchema.parse({ title, description, language: "en", // We're defaulting to English }); // 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;