fix(security): lock down admin + add bulk cleanup UI

This commit is contained in:
Young Lee
2026-02-05 23:18:25 -08:00
parent 59cbbd0428
commit 223560e874
12 changed files with 2100 additions and 765 deletions
+10 -1
View File
@@ -35,6 +35,7 @@ Current keys used by routes:
- `feeds:list` -> `{ feeds: Array<{ id, title }> }` - `feeds:list` -> `{ feeds: Array<{ id, title }> }`
- `feed:<feedId>:config` -> feed config object - `feed:<feedId>:config` -> feed config object
- `feed:<feedId>:config.allowed_senders` -> optional sender allowlist (email or domain)
- `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }` - `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }`
- `feed:<feedId>:<timestamp>` -> stored email body/metadata - `feed:<feedId>:<timestamp>` -> stored email body/metadata
@@ -68,9 +69,17 @@ Notes:
## Security assumptions ## Security assumptions
- Inbound endpoint only accepts requests from ForwardEmail source IPs. - 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. - 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 ## Cloudflare/Wrangler conventions
- `wrangler.toml` is generated locally from `wrangler-example.toml`. - `wrangler.toml` is generated locally from `wrangler-example.toml`.
+15 -1
View File
@@ -13,8 +13,10 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da
## Features ## Features
- One-click feed creation from an admin dashboard - 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`) - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
- ForwardEmail webhook ingestion with source-IP verification - ForwardEmail webhook ingestion with source-IP verification
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
- RSS generation on demand (`/rss/:feedId`) - RSS generation on demand (`/rss/:feedId`)
- Cloudflare KV storage for feed config + email metadata/content - Cloudflare KV storage for feed config + email metadata/content
- Password-protected admin UI - Password-protected admin UI
@@ -97,9 +99,21 @@ npm run build
## Security notes ## Security notes
- Inbound webhook access is IP-restricted to ForwardEmail MX sources. - 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. - 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 ## Upgrading dependencies
To refresh dependencies to latest: To refresh dependencies to latest:
+80 -6
View File
@@ -8,12 +8,25 @@ describe("Admin Routes", () => {
let testApp: Hono; let testApp: Hono;
let mockEnv: Env; let mockEnv: Env;
let request: (path: string, init?: RequestInit) => Promise<Response>; let request: (path: string, init?: RequestInit) => Promise<Response>;
let loginAndGetCookie: () => Promise<string>;
beforeEach(() => { beforeEach(() => {
mockEnv = createMockEnv(); mockEnv = createMockEnv();
testApp = new Hono(); testApp = new Hono();
testApp.route("/admin", app); testApp.route("/admin", app);
request = (path, init = {}) => testApp.request(path, init, mockEnv); 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", () => { describe("Authentication", () => {
@@ -38,11 +51,13 @@ describe("Admin Routes", () => {
body: formData, 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"); 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("HttpOnly");
expect(cookie).toContain("SameSite=Strict"); expect(cookie).toContain("SameSite=Strict");
expect(cookie).toContain("Secure");
expect(cookie).toContain("Path=/"); expect(cookie).toContain("Path=/");
}); });
@@ -73,9 +88,8 @@ describe("Admin Routes", () => {
}); });
describe("Protected Routes", () => { describe("Protected Routes", () => {
const authCookie = "admin_auth=true";
it("should allow access to dashboard with valid auth cookie", async () => { it("should allow access to dashboard with valid auth cookie", async () => {
const authCookie = await loginAndGetCookie();
const res = await request("/admin", { const res = await request("/admin", {
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
@@ -85,6 +99,16 @@ describe("Admin Routes", () => {
expect(res.headers.get("Content-Type")).toContain("text/html"); 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", () => { describe("Feed Creation", () => {
it("should prevent feed creation without authentication", async () => { it("should prevent feed creation without authentication", async () => {
const formData = new FormData(); const formData = new FormData();
@@ -105,6 +129,7 @@ describe("Admin Routes", () => {
}); });
it("should allow feed creation with valid authentication", async () => { it("should allow feed creation with valid authentication", async () => {
const authCookie = await loginAndGetCookie();
const formData = new FormData(); const formData = new FormData();
formData.append("title", "Test Feed"); formData.append("title", "Test Feed");
formData.append("description", "Test Description"); formData.append("description", "Test Description");
@@ -118,7 +143,7 @@ describe("Admin Routes", () => {
}); });
expect(res.status).toBe(302); // Redirects back to dashboard 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 // Verify feed was created in KV
const feedList = (await mockEnv.EMAIL_STORAGE.get( const feedList = (await mockEnv.EMAIL_STORAGE.get(
@@ -141,6 +166,7 @@ describe("Admin Routes", () => {
}); });
it("should reject feed creation with missing title", async () => { it("should reject feed creation with missing title", async () => {
const authCookie = await loginAndGetCookie();
const formData = new FormData(); const formData = new FormData();
formData.append("description", "Test Description"); formData.append("description", "Test Description");
@@ -187,6 +213,7 @@ describe("Admin Routes", () => {
}); });
it("should allow feed deletion with valid authentication", async () => { it("should allow feed deletion with valid authentication", async () => {
const authCookie = await loginAndGetCookie();
// First create a feed // First create a feed
const formData = new FormData(); const formData = new FormData();
formData.append("title", "Test Feed"); formData.append("title", "Test Feed");
@@ -218,7 +245,7 @@ describe("Admin Routes", () => {
}); });
expect(deleteRes.status).toBe(302); 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 // Verify feed was deleted
const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get( const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get(
@@ -235,6 +262,53 @@ describe("Admin Routes", () => {
); );
expect(feedConfig).toBeNull(); 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);
});
}); });
}); });
}); });
+1569 -589
View File
File diff suppressed because it is too large Load Diff
+96 -17
View File
@@ -1,12 +1,12 @@
import { Context } from 'hono'; import { Context } from "hono";
import { EmailParser } from '../utils/email-parser'; import { EmailParser } from "../utils/email-parser";
import { Env, FeedMetadata } from '../types'; import { Env, FeedConfig, FeedMetadata } from "../types";
// Interface for ForwardEmail.net webhook payload // Interface for ForwardEmail.net webhook payload
interface ForwardEmailPayload { interface ForwardEmailPayload {
recipients?: string[]; recipients?: string[];
from?: { from?: {
value?: Array<{address?: string; name?: string}>; value?: Array<{ address?: string; name?: string }>;
text?: string; text?: string;
html?: string; html?: string;
}; };
@@ -15,12 +15,58 @@ interface ForwardEmailPayload {
html?: string; html?: string;
date?: string; date?: string;
messageId?: string; messageId?: string;
headerLines?: Array<{key: string; line: string}>; headerLines?: Array<{ key: string; line: string }>;
headers?: string; headers?: string;
raw?: string; raw?: string;
attachments?: Array<any>; attachments?: Array<any>;
} }
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
function extractIncomingSenderAddresses(
payload: ForwardEmailPayload,
): string[] {
const valueEntries = payload.from?.value || [];
const structuredAddresses = valueEntries
.map((entry) => entry.address || "")
.map(normalizeEmail)
.filter(Boolean);
if (structuredAddresses.length > 0) {
return Array.from(new Set(structuredAddresses));
}
// Fallback parser for plain text like "Name <sender@example.com>"
const fromText = payload.from?.text || "";
const matches =
fromText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [];
return Array.from(new Set(matches.map(normalizeEmail)));
}
function senderMatchesAllowlist(
sender: string,
allowedSender: string,
): boolean {
const normalizedSender = normalizeEmail(sender);
const normalizedAllowed = normalizeEmail(allowedSender);
if (!normalizedAllowed) {
return false;
}
if (normalizedAllowed.includes("@")) {
return normalizedSender === normalizedAllowed;
}
const senderDomain = normalizedSender.split("@")[1] || "";
const normalizedDomain = normalizedAllowed.startsWith("@")
? normalizedAllowed.slice(1)
: normalizedAllowed;
return senderDomain === normalizedDomain;
}
/** /**
* Handle incoming emails from ForwardEmail.net webhook * Handle incoming emails from ForwardEmail.net webhook
*/ */
@@ -35,27 +81,57 @@ export async function handle(c: Context): Promise<Response> {
// Log basic information about the incoming email // Log basic information about the incoming email
console.log("Received email:", { console.log("Received email:", {
to: payload.recipients?.[0], to: payload.recipients?.[0],
from: payload.from?.text || 'Unknown', from: payload.from?.text || "Unknown",
subject: payload.subject, subject: payload.subject,
contentType: payload.html ? 'HTML' : 'Text' contentType: payload.html ? "HTML" : "Text",
}); });
// Extract feed ID from email address (e.g., apple.mountain.42@domain.com -> apple.mountain.42) // Extract feed ID from email address (e.g., apple.mountain.42@domain.com -> apple.mountain.42)
const toAddress = payload.recipients?.[0] || ''; const toAddress = payload.recipients?.[0] || "";
const feedId = EmailParser.extractFeedId(toAddress); const feedId = EmailParser.extractFeedId(toAddress);
if (!feedId) { if (!feedId) {
console.error(`Invalid email address format: ${toAddress}`); console.error(`Invalid email address format: ${toAddress}`);
return new Response('Invalid email address format', { status: 400 }); return new Response("Invalid email address format", { status: 400 });
} }
// Check if the feed exists by looking up the feed configuration // Check if the feed exists by looking up the feed configuration
const feedConfigKey = `feed:${feedId}:config`; const feedConfigKey = `feed:${feedId}:config`;
const feedConfig = await env.EMAIL_STORAGE.get(feedConfigKey, 'json'); const feedConfig = (await env.EMAIL_STORAGE.get(
feedConfigKey,
"json",
)) as FeedConfig | null;
if (!feedConfig) { if (!feedConfig) {
console.error(`Feed with ID ${feedId} does not exist or has been deleted`); console.error(
return new Response('Feed does not exist', { status: 404 }); `Feed with ID ${feedId} does not exist or has been deleted`,
);
return new Response("Feed does not exist", { status: 404 });
}
const allowedSenders = (feedConfig.allowed_senders || [])
.map(normalizeEmail)
.filter(Boolean);
if (allowedSenders.length > 0) {
const incomingSenders = extractIncomingSenderAddresses(payload);
const senderAllowed = incomingSenders.some((sender) =>
allowedSenders.some((allowedSender) =>
senderMatchesAllowlist(sender, allowedSender),
),
);
if (!senderAllowed) {
console.warn(
`Rejected email for feed ${feedId}; sender not in allowlist`,
{
incomingSenders,
allowedSenders,
},
);
return new Response("Sender not allowed for this feed", {
status: 403,
});
}
} }
// Parse the email using our simplified parser // Parse the email using our simplified parser
@@ -69,22 +145,25 @@ export async function handle(c: Context): Promise<Response> {
// Get existing feed metadata // Get existing feed metadata
const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = (await env.EMAIL_STORAGE.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata; const feedMetadata = ((await env.EMAIL_STORAGE.get(
feedMetadataKey,
"json",
)) || { emails: [] }) as FeedMetadata;
// Add this email to the feed metadata // Add this email to the feed metadata
feedMetadata.emails.unshift({ feedMetadata.emails.unshift({
key: emailKey, key: emailKey,
subject: emailData.subject, subject: emailData.subject,
receivedAt: emailData.receivedAt receivedAt: emailData.receivedAt,
}); });
// Store updated feed metadata // Store updated feed metadata
await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata)); await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata));
console.log(`Successfully processed email for feed ${feedId}`); console.log(`Successfully processed email for feed ${feedId}`);
return new Response('Email processed successfully', { status: 200 }); return new Response("Email processed successfully", { status: 200 });
} catch (error) { } catch (error) {
console.error('Error processing email:', error); console.error("Error processing email:", error);
return new Response('Error processing email', { status: 500 }); return new Response("Error processing email", { status: 500 });
} }
} }
+3 -43
View File
@@ -1,43 +1,3 @@
// Authentication helper functions // Legacy export retained for compatibility.
// Handles user authentication state // Authentication is now fully enforced server-side.
export const authHelpers = ``;
export const authHelpers = `
// Check if user is authenticated
function isAuthenticated() {
// Check localStorage first (client-side)
if (localStorage.getItem('authenticated') === 'true') {
return true;
}
// Check for cookie (server-side auth)
function getCookie(name) {
const value = \`; \${document.cookie}\`;
const parts = value.split(\`; \${name}=\`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
return getCookie('admin_auth') === 'true';
}
// Set authentication state
function setAuthenticated(value) {
localStorage.setItem('authenticated', value ? 'true' : 'false');
}
// Logout function
function logout() {
localStorage.removeItem('authenticated');
// Also clear the cookie by setting expiry in the past
document.cookie = 'admin_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
window.location.href = '/admin/login';
}
// Check authentication on page load
document.addEventListener('DOMContentLoaded', () => {
const path = window.location.pathname;
if (path !== '/admin/login' && !isAuthenticated()) {
window.location.href = '/admin/login';
}
});
`;
+10 -4
View File
@@ -1,9 +1,9 @@
// Main scripts exports file // Main scripts exports file
// Combines and re-exports all JavaScript functionality // Combines and re-exports all JavaScript functionality
import { modalScripts, emailViewScripts, initScripts } from './interactions'; import { modalScripts, emailViewScripts, initScripts } from "./interactions";
import { clipboardScripts } from './clipboard'; import { clipboardScripts } from "./clipboard";
import { authHelpers } from './auth'; import { authHelpers } from "./auth";
// Combine all scripts into a single JavaScript string // Combine all scripts into a single JavaScript string
export const interactiveScripts = ` export const interactiveScripts = `
@@ -14,4 +14,10 @@ export const interactiveScripts = `
`; `;
// Re-export for modular usage if needed // Re-export for modular usage if needed
export { modalScripts, emailViewScripts, initScripts, clipboardScripts, authHelpers }; export {
modalScripts,
emailViewScripts,
initScripts,
clipboardScripts,
authHelpers,
};
+180 -1
View File
@@ -413,7 +413,7 @@ export const componentStyles = `
.email-raw pre { .email-raw pre {
margin: 0; margin: 0;
font-family: 'Menlo', monospace; font-family: var(--font-family-mono);
font-size: 14px; font-size: 14px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -483,6 +483,185 @@ export const componentStyles = `
overflow: hidden; overflow: hidden;
} }
/* Toolbar + segmented control (Apple-ish) */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
flex-wrap: wrap;
margin-bottom: var(--spacing-md);
}
.toolbar-group {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.toolbar-group-fill {
width: 100%;
}
input.search {
min-width: 280px;
flex: 1;
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-md);
}
.pill {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background-color: rgba(60, 60, 67, 0.12);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
backdrop-filter: blur(var(--blur-sm));
-webkit-backdrop-filter: blur(var(--blur-sm));
}
.segmented {
display: inline-flex;
border-radius: var(--radius-pill);
padding: 2px;
border: 1px solid var(--color-border);
background-color: rgba(60, 60, 67, 0.12);
backdrop-filter: blur(var(--blur-sm));
-webkit-backdrop-filter: blur(var(--blur-sm));
box-shadow: var(--shadow-sm);
}
.segmented-item {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border-radius: var(--radius-pill);
text-decoration: none;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
user-select: none;
transition: all var(--transition-fast);
}
.segmented-item:hover {
color: var(--color-text-primary);
}
.segmented-item.is-active {
color: var(--color-text-primary);
background-color: rgba(255, 255, 255, 0.12);
box-shadow: var(--shadow-sm);
}
@media (prefers-color-scheme: light) {
.segmented-item.is-active {
background-color: rgba(255, 255, 255, 0.85);
}
}
/* Tables */
.table-wrap {
overflow-x: auto;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background-color: rgba(60, 60, 67, 0.05);
}
@media (prefers-color-scheme: light) {
.table-wrap {
background-color: rgba(255, 255, 255, 0.6);
}
}
table.table {
width: 100%;
border-collapse: collapse;
}
table.table.table-feeds {
min-width: 860px;
}
table.table.table-emails {
min-width: 760px;
}
table.table th,
table.table td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
table.table thead th {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
background-color: rgba(44, 44, 46, 0.35);
backdrop-filter: blur(var(--blur-sm));
-webkit-backdrop-filter: blur(var(--blur-sm));
}
@media (prefers-color-scheme: light) {
table.table thead th {
background-color: rgba(255, 255, 255, 0.55);
}
}
table.table tbody tr:hover {
background-color: rgba(255, 255, 255, 0.04);
}
@media (prefers-color-scheme: light) {
table.table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.03);
}
}
table.table tbody tr:last-child td {
border-bottom: none;
}
table.table code {
font-family: var(--font-family-mono);
font-size: 13px;
}
/* Compact copy-to-clipboard for table cells */
.copyable.copyable-inline {
margin-bottom: 0;
padding: 0;
background-color: transparent;
border: none;
}
.copyable.copyable-inline .copyable-content {
padding: 6px 8px;
border-radius: var(--radius-sm);
}
.copyable.copyable-inline .copyable-value {
margin-right: var(--spacing-xs);
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* Feed and Email Lists */ /* Feed and Email Lists */
.feed-list, .feed-list,
.email-list { .email-list {
+9 -5
View File
@@ -15,18 +15,22 @@ export const layoutStyles = `
overflow-x: hidden; overflow-x: hidden;
margin: 0; margin: 0;
padding: 0; padding: 0;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Add subtle gradient background */ /* Liquid-glass-ish background (subtle, non-distracting) */
background-image: linear-gradient(135deg, background-image:
rgba(0, 0, 0, 0.02) 0%, radial-gradient(1200px circle at 20% 10%, rgba(10, 132, 255, 0.18), transparent 55%),
rgba(255, 255, 255, 0.02) 100%); radial-gradient(900px circle at 80% 20%, rgba(94, 92, 230, 0.14), transparent 60%),
radial-gradient(700px circle at 50% 100%, rgba(48, 209, 88, 0.10), transparent 60%),
linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(0, 0, 0, 0.03) 100%);
background-attachment: fixed; background-attachment: fixed;
} }
/* Main Container */ /* Main Container */
.container { .container {
width: 100%; width: 100%;
max-width: 800px; max-width: 980px;
margin: 0 auto; margin: 0 auto;
padding: var(--spacing-xl); padding: var(--spacing-xl);
box-sizing: border-box; box-sizing: border-box;
+15
View File
@@ -12,6 +12,21 @@ export const utilityStyles = `
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
.muted {
color: var(--color-text-secondary);
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
}
* {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
/* Copyable content styling */ /* Copyable content styling */
.copyable { .copyable {
position: relative; position: relative;
+5 -4
View File
@@ -3,8 +3,10 @@
export const variables = ` export const variables = `
:root { :root {
/* Typography - Using Inter font */ /* Typography - Prefer system UI fonts (Apple HIG-ish) */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; --font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color-scheme: light dark;
--font-size-xs: 12px; --font-size-xs: 12px;
--font-size-sm: 14px; --font-size-sm: 14px;
--font-size-md: 16px; --font-size-md: 16px;
@@ -103,6 +105,5 @@ export const lightModeTheme = `
// Inter font import // Inter font import
export const fontImport = ` export const fontImport = `
/* Inter Font Import */ /* No external font imports. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
`; `;
+20 -6
View File
@@ -18,6 +18,7 @@ export interface EmailData {
export interface FeedConfig { export interface FeedConfig {
title: string; title: string;
description?: string; description?: string;
allowed_senders?: string[];
language: string; language: string;
site_url: string; site_url: string;
feed_url: string; feed_url: string;
@@ -53,13 +54,26 @@ export interface FeedListItem {
declare global { declare global {
// This is not an ideal solution but works for our example // This is not an ideal solution but works for our example
interface KVNamespace { interface KVNamespace {
get(key: string, options?: { type: 'text' }): Promise<string | null>; get(key: string, options?: { type: "text" }): Promise<string | null>;
get(key: string, options: { type: 'json' }): Promise<any | null>; get(key: string, options: { type: "json" }): Promise<any | null>;
get(key: string, options: { type: 'arrayBuffer' }): Promise<ArrayBuffer | null>; get(
get(key: string, options: { type: 'stream' }): Promise<ReadableStream | null>; key: string,
put(key: string, value: string | ArrayBuffer | ReadableStream | FormData): Promise<void>; options: { type: "arrayBuffer" },
): Promise<ArrayBuffer | null>;
get(
key: string,
options: { type: "stream" },
): Promise<ReadableStream | null>;
put(
key: string,
value: string | ArrayBuffer | ReadableStream | FormData,
): Promise<void>;
delete(key: string): Promise<void>; delete(key: string): Promise<void>;
list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ list(options?: {
prefix?: string;
limit?: number;
cursor?: string;
}): Promise<{
keys: { name: string; expiration?: number }[]; keys: { name: string; expiration?: number }[];
list_complete: boolean; list_complete: boolean;
cursor?: string; cursor?: string;