diff --git a/AGENTS.md b/AGENTS.md index 90eba4e..82a623b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Current keys used by routes: - `feeds:list` -> `{ feeds: Array<{ id, title }> }` - `feed::config` -> feed config object +- `feed::config.allowed_senders` -> optional sender allowlist (email or domain) - `feed::metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }` - `feed::` -> stored email body/metadata @@ -68,9 +69,17 @@ Notes: ## Security assumptions - Inbound endpoint only accepts requests from ForwardEmail source IPs. -- Admin access uses cookie gate and password stored in Worker secret (`ADMIN_PASSWORD`). +- Admin access uses a signed cookie gate and password stored in Worker secret (`ADMIN_PASSWORD`). +- Admin pages set `Cache-Control: no-store`. +- Prefer setting `allowed_senders` on legitimate feeds to reduce inbound spam. - Do not hardcode credentials or domain-specific secrets into tracked files. +## Spam cleanup workflow + +- First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection. +- Use **Table** view for bulk delete. +- Avoid wildcard deletion; prefer search + small batches to reduce risk of deleting legitimate feeds. + ## Cloudflare/Wrangler conventions - `wrangler.toml` is generated locally from `wrangler-example.toml`. diff --git a/README.md b/README.md index dc4cad6..358a0ee 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da ## Features - One-click feed creation from an admin dashboard +- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow) - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`) - ForwardEmail webhook ingestion with source-IP verification +- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`) - RSS generation on demand (`/rss/:feedId`) - Cloudflare KV storage for feed config + email metadata/content - Password-protected admin UI @@ -97,9 +99,21 @@ npm run build ## Security notes - Inbound webhook access is IP-restricted to ForwardEmail MX sources. -- Admin auth is cookie-based (`HttpOnly`, `SameSite=Strict`). +- Admin auth uses a signed, `HttpOnly`, `Secure`, `SameSite=Strict` cookie. +- Admin responses are `no-store` to avoid cache leakage. +- For high-value feeds, set `Allowed senders` so only known sender addresses/domains are accepted. - You should use a strong admin password and rotate periodically. +## Spam cleanup runbook + +### UI-first cleanup + +1. Open `/admin`. +2. Switch to **Table** view. +3. Use the search box to filter obvious spam feeds. +4. Select rows and use **Delete Selected Feeds**. +5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Delete Selected Emails**. + ## Upgrading dependencies To refresh dependencies to latest: diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index e943af0..65c1f2c 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -8,12 +8,25 @@ describe("Admin Routes", () => { let testApp: Hono; let mockEnv: Env; let request: (path: string, init?: RequestInit) => Promise; + let loginAndGetCookie: () => Promise; beforeEach(() => { mockEnv = createMockEnv(); testApp = new Hono(); testApp.route("/admin", app); request = (path, init = {}) => testApp.request(path, init, mockEnv); + loginAndGetCookie = async () => { + const formData = new FormData(); + formData.append("password", "test-password"); + const response = await request("/admin/login", { + method: "POST", + body: formData, + }); + expect(response.status).toBe(302); + const setCookie = response.headers.get("Set-Cookie"); + expect(setCookie).toBeTruthy(); + return (setCookie as string).split(";")[0]; + }; }); describe("Authentication", () => { @@ -38,11 +51,13 @@ describe("Admin Routes", () => { body: formData, }); - expect(res.status).toBe(200); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/admin"); const cookie = res.headers.get("Set-Cookie"); - expect(cookie).toContain("admin_auth=true"); + expect(cookie).toContain("admin_auth="); expect(cookie).toContain("HttpOnly"); expect(cookie).toContain("SameSite=Strict"); + expect(cookie).toContain("Secure"); expect(cookie).toContain("Path=/"); }); @@ -73,9 +88,8 @@ describe("Admin Routes", () => { }); describe("Protected Routes", () => { - const authCookie = "admin_auth=true"; - it("should allow access to dashboard with valid auth cookie", async () => { + const authCookie = await loginAndGetCookie(); const res = await request("/admin", { headers: { Cookie: authCookie, @@ -85,6 +99,16 @@ describe("Admin Routes", () => { expect(res.headers.get("Content-Type")).toContain("text/html"); }); + it("should reject access with forged auth cookie", async () => { + const res = await request("/admin", { + headers: { + Cookie: "admin_auth=true", + }, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/admin/login"); + }); + describe("Feed Creation", () => { it("should prevent feed creation without authentication", async () => { const formData = new FormData(); @@ -105,6 +129,7 @@ describe("Admin Routes", () => { }); it("should allow feed creation with valid authentication", async () => { + const authCookie = await loginAndGetCookie(); const formData = new FormData(); formData.append("title", "Test Feed"); formData.append("description", "Test Description"); @@ -118,7 +143,7 @@ describe("Admin Routes", () => { }); expect(res.status).toBe(302); // Redirects back to dashboard - expect(res.headers.get("Location")).toBe("/admin"); + expect(res.headers.get("Location")).toBe("/admin?view=list"); // Verify feed was created in KV const feedList = (await mockEnv.EMAIL_STORAGE.get( @@ -141,6 +166,7 @@ describe("Admin Routes", () => { }); it("should reject feed creation with missing title", async () => { + const authCookie = await loginAndGetCookie(); const formData = new FormData(); formData.append("description", "Test Description"); @@ -187,6 +213,7 @@ describe("Admin Routes", () => { }); it("should allow feed deletion with valid authentication", async () => { + const authCookie = await loginAndGetCookie(); // First create a feed const formData = new FormData(); formData.append("title", "Test Feed"); @@ -218,7 +245,7 @@ describe("Admin Routes", () => { }); expect(deleteRes.status).toBe(302); - expect(deleteRes.headers.get("Location")).toBe("/admin"); + expect(deleteRes.headers.get("Location")).toBe("/admin?view=list"); // Verify feed was deleted const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get( @@ -235,6 +262,53 @@ describe("Admin Routes", () => { ); expect(feedConfig).toBeNull(); }); + + it("should allow bulk feed deletion with valid authentication", async () => { + const authCookie = await loginAndGetCookie(); + + for (const title of ["Feed A", "Feed B"]) { + const formData = new FormData(); + formData.append("title", title); + formData.append("description", "Test"); + const createRes = await request("/admin/feeds/create", { + method: "POST", + headers: { Cookie: authCookie }, + body: formData, + }); + expect(createRes.status).toBe(302); + } + + const feedListBefore = (await mockEnv.EMAIL_STORAGE.get( + "feeds:list", + "json", + )) as { + feeds: Array<{ id: string; title: string }>; + } | null; + expect(feedListBefore?.feeds.length).toBe(2); + + const bulkForm = new FormData(); + for (const feed of feedListBefore?.feeds || []) { + bulkForm.append("feedIds", feed.id); + } + + const bulkDeleteRes = await request("/admin/feeds/bulk-delete", { + method: "POST", + headers: { Cookie: authCookie }, + body: bulkForm, + }); + + expect(bulkDeleteRes.status).toBe(302); + expect(bulkDeleteRes.headers.get("Location")).toContain("/admin?view=list"); + expect(bulkDeleteRes.headers.get("Location")).toContain("message=bulkDeleted"); + + const feedListAfter = (await mockEnv.EMAIL_STORAGE.get( + "feeds:list", + "json", + )) as { + feeds: Array<{ id: string; title: string }>; + } | null; + expect(feedListAfter?.feeds.length).toBe(0); + }); }); }); }); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 4f5137b..d11e4f8 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,16 +1,24 @@ -import { Context, Hono } from 'hono'; -import { getCookie } 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, authHelpers } from '../scripts/index'; +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 @@ -21,414 +29,821 @@ 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 parseAllowedSenders(rawAllowedSenders: string): string[] { + return rawAllowedSenders + .split(/[\n,]+/) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); +} + +// 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') { + if (path === "/admin/login") { return next(); } - const authCookie = getCookie(c, 'admin_auth'); - if (!authCookie || authCookie !== 'true') { - return c.redirect('/admin/login'); + 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); +app.use("*", authMiddleware); // Schema for feed creation const createFeedSchema = z.object({ - title: z.string().min(1, 'Title is required'), + title: z.string().min(1, "Title is required"), description: z.string().optional(), - language: z.string().optional().default('en') + 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'), + title: z.string().min(1, "Title is required"), description: z.string().optional(), - language: z.string().optional().default('en') + 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') + 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} - - `; + + + ${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}
` : ''} -
-
- - +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) => { +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() || ''; - + const password = formData.get("password")?.toString() || ""; + // Validate password authSchema.parse({ password }); - + // Check password against environment variable if (password === env.ADMIN_PASSWORD) { - // Set a cookie for server-side authentication - c.header('Set-Cookie', `admin_auth=true; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24 * 7}`); // 1 week - - // Also use localStorage for client-side checks - return c.html(html` - - `); - } else { - // Incorrect password - redirect back to login with an error message - return c.redirect('/admin/login?error=invalid'); + 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'); + console.error("Login error:", error); + return c.redirect("/admin/login?error=invalid"); } }); // Logout route -app.get('/logout', (c) => { - return c.html(html` - - `); +app.get("/logout", (c) => { + deleteCookie(c, ADMIN_COOKIE_NAME, { path: "/" }); + return c.redirect("/admin/login"); }); // Admin dashboard route -app.get('/', async (c) => { +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); - + // Fetch full feed configs to get descriptions const feedsWithConfig = await Promise.all( feedList.map(async (feed) => { const configKey = `feed:${feed.id}:config`; - const config = await emailStorage.get(configKey, { type: 'json' }) as FeedConfig | null; + const config = (await emailStorage.get(configKey, { + type: "json", + })) as FeedConfig | null; return { ...feed, - description: config?.description || '' + description: config?.description || "", }; - }) + }), ); - - return c.html(layout('Dashboard', html` -
-
-
-

Email to RSS Admin

-

Manage your email newsletter feeds

-
-
- Logout -
-
- -
-

Create New Feed

-
-
- - -
- -
- - -
- - - - -
-
- -

Your Feeds

- - ${feedsWithConfig.length > 0 ? - html`
    - ${feedsWithConfig.map((feed, index: number) => html` -
  • -
    -

    ${feed.title}

    - - ${feed.description ? - html`

    ${feed.description}

    ` : - html`

    No description

    ` - } - -
    -
    -
    - Email: -
    - ${feed.id}@${env.DOMAIN} -
    - - - - - - - -
    -
    -
    -
    - RSS Feed: -
    - https://${env.DOMAIN}/rss/${feed.id} -
    - - - - - - - -
    -
    -
    -
    -
    -
    - - View Emails -
    -
    - -
    -
    -
  • - `)} -
` : - html`

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

` - } + + 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` +
+ List + Table
- - - `)); + + function setVisibleFeedSelection(checked) { + const checkboxes = document.querySelectorAll('.feed-select'); + checkboxes.forEach((checkbox) => { + if (checkbox.closest('tr')?.style.display !== 'none') { + checkbox.checked = checked; + } + }); + updateFeedSelectionState(); + } + + function filterFeedRows() { + const query = (document.getElementById('feed-search')?.value || '').toLowerCase().trim(); + const rows = document.querySelectorAll('.feed-row'); + rows.forEach((row) => { + const haystack = row.getAttribute('data-search') || ''; + row.style.display = !query || haystack.includes(query) ? '' : 'none'; + }); + updateFeedSelectionState(); + } + + function confirmBulkFeedDelete() { + const selected = document.querySelectorAll('.feed-select:checked').length; + if (selected === 0) { + return false; + } + return confirm('Delete ' + selected + ' selected feed(s)? This will also delete all emails inside those feeds.'); + } + + document.addEventListener('DOMContentLoaded', () => { + updateFeedSelectionState(); + }); + `)}; + + `, + ), + ); }); // Create a new feed -app.post('/feeds/create', async (c) => { +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 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 + language, + allowedSenders, }); - + // Generate a feed ID const feedId = generateFeedId(); - + // Create feed configuration const feedConfig: FeedConfig = { title: parsedData.title, @@ -436,249 +851,515 @@ app.post('/feeds/create', async (c) => { 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() + updated_at: Date.now(), }; - + // Create feed metadata const feedMetadata: FeedMetadata = { - emails: [] + emails: [], }; - + // Store feed configuration and metadata await emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)); - await emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)); - + await emailStorage.put( + `feed:${feedId}:metadata`, + JSON.stringify(feedMetadata), + ); + // Add feed to the list of all feeds await addFeedToList(emailStorage, feedId, parsedData.title); - + // Redirect back to admin page - return c.redirect('/admin'); + return c.redirect(`/admin?view=${view}`); } catch (error) { - console.error('Error creating feed:', error); - return c.text('Error creating feed. Please try again.', 400); + 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) => { +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'); - + 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; - + const feedConfig = (await emailStorage.get(feedConfigKey, { + type: "json", + })) as FeedConfig | null; + if (!feedConfig) { - return c.text('Feed not found', 404); + return c.text("Feed not found", 404); } - - return c.html(layout('Edit Feed', html` -
-
-
-

${feedConfig.title} - Edit Feed

-
- -
- -
-
-
- - + + 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) => { +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'); - + 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 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 + language, + allowedSenders, }); - + // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; - const existingConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; - + const existingConfig = (await emailStorage.get(feedConfigKey, { + type: "json", + })) as FeedConfig | null; + if (!existingConfig) { - return c.text('Feed not found', 404); + 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, - updated_at: Date.now() - })); - + 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); - + // Redirect back to admin page - return c.redirect('/admin'); + return c.redirect("/admin"); } catch (error) { - console.error('Error updating feed:', error); - return c.text('Error updating feed. Please try again.', 400); + console.error("Error updating feed:", error); + return c.text("Error updating feed. Please try again.", 400); } }); +async function deleteFeedAndEmails( + emailStorage: KVNamespace, + feedId: string, +): Promise { + const feedMetadataKey = `feed:${feedId}:metadata`; + const feedMetadata = (await emailStorage.get(feedMetadataKey, { + type: "json", + })) as FeedMetadata | null; + + if (!feedMetadata) { + return false; + } + + for (const email of feedMetadata.emails) { + await emailStorage.delete(email.key); + } + + await emailStorage.delete(`feed:${feedId}:config`); + await emailStorage.delete(feedMetadataKey); + await removeFeedFromList(emailStorage, feedId); + + return true; +} + // Delete feed -app.post('/feeds/:feedId/delete', async (c) => { - // Type assertion for environment variables +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 feedId = c.req.param("feedId"); + const view = c.req.query("view") === "table" ? "table" : "list"; + try { - // Get feed metadata to find all email keys - const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = await emailStorage.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; - - if (!feedMetadata) { - return c.text('Feed not found', 404); + const deleted = await deleteFeedAndEmails(emailStorage, feedId); + if (!deleted) { + return c.text("Feed not found", 404); } - - // Delete all emails for this feed - for (const email of feedMetadata.emails) { - await emailStorage.delete(email.key); - } - - // Delete feed configuration and metadata - await emailStorage.delete(`feed:${feedId}:config`); - await emailStorage.delete(feedMetadataKey); - - // Remove feed from the list of all feeds - await removeFeedFromList(emailStorage, feedId); - - // Redirect back to admin page - return c.redirect('/admin'); + return c.redirect(`/admin?view=${view}`); } catch (error) { - console.error('Error deleting feed:', error); - return c.text('Error deleting feed. Please try again.', 400); + console.error("Error deleting feed:", error); + return c.text("Error deleting feed. Please try again.", 400); + } +}); + +// 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 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 feedIds = Array.from(new Set(rawIds.filter(Boolean))); + + if (feedIds.length === 0) { + return c.redirect(`${redirectBase}&message=bulkDeleteNoop`); + } + + let deletedCount = 0; + for (const feedId of feedIds) { + const deleted = await deleteFeedAndEmails(emailStorage, feedId); + if (deleted) { + deletedCount += 1; + } + } + + return c.redirect(`${redirectBase}&message=bulkDeleted&count=${deletedCount}`); + } 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) => { +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 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; - + + 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.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} -
- - - - - - - + + 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` +
+
+ + + + 0 selected +
+
+ +
+
+ + + + + + + + + + + ${feedMetadata.emails.map( + (email: EmailMetadata) => html` + + + + + + + `, + )} + +
+ + SubjectReceivedActions
+
+
+ +
+
+ ` + : html`
+

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

+
`}
-
- -

Emails (${feedMetadata.emails.length})

- - ${feedMetadata.emails.length > 0 ? - html`` : - html`
-

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

-
` - } -
- - - `)); + + function updateEmailSelectionState() { + const checkboxes = Array.from(document.querySelectorAll('.email-select')); + const selected = checkboxes.filter((checkbox) => checkbox.checked); + const selectedCount = document.getElementById('selected-email-count'); + const bulkDeleteButton = document.getElementById('bulk-delete-emails-button'); + const selectAll = document.getElementById('select-all-emails'); + + if (selectedCount) { + selectedCount.textContent = selected.length + ' selected'; + } + if (bulkDeleteButton) { + bulkDeleteButton.disabled = selected.length === 0; + } + if (selectAll) { + const visibleCheckboxes = checkboxes.filter((checkbox) => checkbox.closest('tr')?.style.display !== 'none'); + selectAll.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.every((checkbox) => checkbox.checked); + } + } + + function toggleAllEmails(checked) { + const checkboxes = document.querySelectorAll('.email-select'); + checkboxes.forEach((checkbox) => { + if (checkbox.closest('tr')?.style.display !== 'none') { + checkbox.checked = checked; + } + }); + updateEmailSelectionState(); + } + + function setVisibleEmailSelection(checked) { + const checkboxes = document.querySelectorAll('.email-select'); + checkboxes.forEach((checkbox) => { + if (checkbox.closest('tr')?.style.display !== 'none') { + checkbox.checked = checked; + } + }); + updateEmailSelectionState(); + } + + function filterEmailRows() { + const query = (document.getElementById('email-search')?.value || '').toLowerCase().trim(); + const rows = document.querySelectorAll('.email-row'); + rows.forEach((row) => { + const haystack = row.getAttribute('data-search') || ''; + row.style.display = !query || haystack.includes(query) ? '' : 'none'; + }); + updateEmailSelectionState(); + } + + function confirmBulkEmailDelete() { + const selected = document.querySelectorAll('.email-select:checked').length; + if (selected === 0) { + return false; + } + return confirm('Delete ' + selected + ' selected email(s)?'); + } + + document.addEventListener('DOMContentLoaded', () => { + updateEmailSelectionState(); + }); + `)}; + + `, + ), + ); }); // View email content -app.get('/emails/:emailKey', async (c) => { +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'); - + const emailKey = c.req.param("emailKey"); + // Get email data - const emailData = await emailStorage.get(emailKey, { type: 'json' }) as EmailData | null; - + const emailData = (await emailStorage.get(emailKey, { + type: "json", + })) as EmailData | null; + if (!emailData) { - return c.text('Email not found', 404); + return c.text("Email not found", 404); } - + // Extract feed ID from the key format (feed:ID:emails:timestamp) - const keyParts = emailKey.split(':'); + const keyParts = emailKey.split(":"); const feedId = keyParts[1]; - + // Create a sanitized HTML content with CSS for the iframe const htmlContent = ` @@ -752,7 +1498,7 @@ app.get('/emails/:emailKey', async (c) => { `; - + // Properly encode the HTML content to handle Unicode characters const encodedHtmlContent = (() => { // Convert the string to UTF-8 @@ -761,102 +1507,254 @@ app.get('/emails/:emailKey', async (c) => { // Convert bytes to base64 return btoa(String.fromCharCode(...new Uint8Array(bytes))); })(); - - return c.html(layout(`Email: ${emailData.subject}`, html` -
-
-
-

Email Content

-
- -
- -
-