chore: modernize setup, dependencies, and project docs

This commit is contained in:
Young Lee
2026-02-05 22:34:13 -08:00
parent 6e546d31a0
commit daf54a0fc0
10 changed files with 476 additions and 420 deletions
+86
View File
@@ -0,0 +1,86 @@
# AGENTS.md
This file gives coding agents fast context for working in this repository.
## Project summary
Email-to-RSS is a Cloudflare Worker that ingests newsletters from ForwardEmail and exposes them as RSS feeds.
Core goals:
- Self-hosted and private
- Free-tier-friendly (Cloudflare + ForwardEmail)
- Minimal operational overhead
## Runtime and stack
- Runtime: Cloudflare Workers
- Framework: Hono (`src/index.ts` + `src/routes/*`)
- Storage: Cloudflare KV (`EMAIL_STORAGE` binding)
- Typescript + Vitest for development/testing
## Important files
- `setup.sh`: bootstraps local setup, KV namespaces, secrets, and local Wrangler config
- `wrangler-example.toml`: template used by setup
- `src/index.ts`: app boot + CORS + inbound IP allowlist middleware
- `src/routes/inbound.ts`: email ingestion endpoint
- `src/routes/rss.ts`: RSS rendering endpoint
- `src/routes/admin.ts`: admin UI and feed/email management
- `src/test/setup.ts`: test runtime mocks (KV + Cache)
## KV data model
Current keys used by routes:
- `feeds:list` -> `{ feeds: Array<{ id, title }> }`
- `feed:<feedId>:config` -> feed config object
- `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }`
- `feed:<feedId>:<timestamp>` -> stored email body/metadata
Notes:
- Some utility files contain alternate key helpers not used by routes (`src/utils/storage.ts`).
- Keep route behavior and key schema consistent when refactoring.
## Setup/deploy workflow
1. `npx wrangler login`
2. `bash setup.sh`
3. Configure ForwardEmail DNS records in Cloudflare
4. `npm run deploy`
`setup.sh` assumes Wrangler v4 command syntax (`wrangler kv namespace ...`).
## Development workflow
- Install: `npm install`
- Test: `npm test`
- Build (dry-run deploy bundle): `npm run build`
- Dev server: `npm run dev`
## Testing notes
- Tests run in Node environment (`vitest.config.ts`), not DOM.
- Hono v4 test requests pass env as the 3rd arg: `app.request(path, init, env)`.
- Some tests intentionally hit validation errors; stderr logs are expected.
## Security assumptions
- Inbound endpoint only accepts requests from ForwardEmail source IPs.
- Admin access uses cookie gate and password stored in Worker secret (`ADMIN_PASSWORD`).
- Do not hardcode credentials or domain-specific secrets into tracked files.
## Cloudflare/Wrangler conventions
- `wrangler.toml` is generated locally from `wrangler-example.toml`.
- Keep `compatibility_date` current on meaningful runtime upgrades.
- Prefer explicit `--env production` for deploy/secret commands.
## If you change behavior
Update all of the following together:
- `README.md`
- `setup.sh` (if setup/deploy assumptions changed)
- tests under `src/routes/*.test.ts` and `src/test/setup.ts`
+81 -112
View File
@@ -1,148 +1,117 @@
# Email-to-RSS # Email-to-RSS
A modern service that turns email newsletters into RSS feeds, built with Cloudflare Workers and ForwardEmail.net. This service provides unique email addresses per feed, a front-end admin panel, and long-term storage of newsletters. Convert email newsletters into a private RSS feed using Cloudflare Workers + ForwardEmail.
## Why Email to RSS? This project is self-hosted, uses your own domain, and keeps your data in your own Cloudflare account.
I love consolidating my newsletters into a centralized reading app like [Reeder](https://reederapp.com). They make for a better reading experience and prevents my email inbox from getting clogged with newsletters. However, Reeder requires RSS support and many email newsletters don't support native RSS feeds. ## Why this exists
There are some free services online that do the same thing (e.g. [kill-the-newsletter.com](kill-the-newsletter.com)), but there are several downsides to this approach: Many newsletters only support email delivery. RSS readers offer a better reading experience, but getting email-only newsletters into RSS usually means relying on shared third-party infrastructure.
- **No long-term retention**: old RSS posts are deleted to save space. Email-to-RSS keeps the same workflow while avoiding shared domains and shared data stores.
- **Risk of blocklisting**: being forced to use the same domain (@kill-the-newsletter.com) as everyone else increases the likelihood that an email newsletter can blocklist you from signing up.
- **Self-hosting is non-trivial**: Kill The Newsletter is also [open source](https://github.com/leafac/kill-the-newsletter), but the self-hosting steps seem neither straightforward nor does it focus on exclusively leveraging free services.
On the other hand, while Email-to-RSS isn't necessarily a one-click-deploy solution, it works just as well, is customized to your domain, and is **almost** free (you still need to buy a custom domain)!
## Features ## Features
- **Autogenerate Custom Emails**: Creates custom email addresses in the format `noun1.noun2.XY@yourdomain.com` for each feed - One-click feed creation from an admin dashboard
- **ForwardEmail.net Integration**: Processes incoming emails via webhook with robust IP verification - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
- **Minimalist Email Parser**: Custom-built lightweight parser that works efficiently in edge environments - ForwardEmail webhook ingestion with source-IP verification
- **RSS Feed Generation**: Serves standards-compliant RSS feeds using the modern Feed library - RSS generation on demand (`/rss/:feedId`)
- **Admin Dashboard**: Complete web UI for managing feeds and viewing emails - Cloudflare KV storage for feed config + email metadata/content
- **Secure Authentication**: Password-protected admin interface - Password-protected admin UI
- **Cloudflare KV Storage**: Efficient, low-cost storage solution for feed data - Fully self-hosted on your Cloudflare account
- **Minimal Dependencies**: Built using modern, web-friendly libraries
- **Lightweight**: Entire worker bundle is optimized for edge deployment
- **Deletion Support**: Email content can be removed from feeds in the admin UI, with automatic cache updates
## Setup
1. Buy your custom domain. You can buy it directly from Cloudflare for convenience. If purchased elsewhere, add the domain to your Cloudflare account to manage the DNS there.
1. **IMPORTANT!** Make sure your domain name extension (e.g. `.com`) is one of the ones allowed by [ForwardEmail.com's free tier](https://forwardemail.net/en/faq#what-domain-name-extensions-can-be-used-for-free).
2. Clone this repository.
3. Open your command line in the cloned repo folder and run: `bash setup.sh` this will install dependencies and set up the Cloudflare Worker/KV with your admin password.
4. Set up your ForwardEmail.net account and configure it to forward to Cloudflare (replace `yourdomain.com` with your custom domain):
1. Sign up for a free account using any email (doesn't necessarily have to be the one used for the newsletter).
2. Add your custom domain.
3. To verify your domain, add the following DNS records to your Cloudflare DNS configuration:
| Type | Name | Content | TTL | Proxy Status | Notes |
| ---- | ---- | -------------------------------------------------- | ---- | ------------ | ---------------------------------------- |
| MX | @ | mx1.forwardemail.net | Auto | DNS only | Set Priority to 10. |
| MX | @ | mx2.forwardemail.net | Auto | DNS only | Set Priority to 10. |
| TXT | @ | "forward-email=https://yourdomain.com/api/inbound" | Auto | DNS only | This forwards your emails to the webhook |
| TXT | @ | "v=spf1 include:spf.forwardemail.net -all" | Auto | DNS only | Email security |
5. Deploy with `npm run deploy`.
6. Go to yourdomain.com to open up the admin panel and log in!
Tip: If you're unsure about any of these steps, ask ChatGPT or Cursor to guide you through them.
## Architecture ## Architecture
### Email Flow 1. ForwardEmail forwards inbound messages to `https://yourdomain.com/api/inbound`.
2. The Worker validates the request source against ForwardEmail MX IP ranges.
3. The Worker parses and stores incoming content in KV.
4. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
5. `/admin` provides feed management and email deletion.
1. A newsletter email arrives at `apple.mountain.42@yourdomain.com` (feed ID format: noun1.noun2.XY). Main routes:
2. ForwardEmail.net forwards it to your Cloudflare Worker endpoint via webhook.
3. The Worker validates the request is from ForwardEmail.net based on IP address.
4. The email is parsed, content extracted, and stored in Cloudflare KV.
5. Feed metadata is updated to include the new email.
6. The RSS feed endpoint dynamically generates the feed from stored emails.
### Key Components - `src/routes/inbound.ts`: webhook ingestion
- `src/routes/rss.ts`: RSS rendering
- `src/routes/admin.ts`: admin UI + feed CRUD
- **Email Parser**: Extracts content from ForwardEmail.net webhook payload ## Requirements
- **Feed Generator**: Creates standard-compliant RSS feeds from stored emails
- **Admin UI**: Interface for creating, viewing, and managing feeds
- **ID Generator**: Creates memorable, collision-resistant feed IDs using common nouns
- **Security Layer**: Validates webhook requests against ForwardEmail.net IP addresses
- **Storage Manager**: Organized module for storing and retrieving data from KV
### Code Structure - Node.js 20+
- A Cloudflare account
- A domain managed in Cloudflare DNS
- A ForwardEmail account
- `src/routes/`: API and UI route handlers for inbound emails, RSS feeds, and admin panel ## Setup
- `src/utils/`: Utility functions including email parsing, feed generation, and ID creation
- `src/data/`: Data files including the nouns list used in ID generation 1. Clone this repository.
- `src/types/`: TypeScript type definitions 2. Authenticate Wrangler:
- `src/scripts/`: Client-side JavaScript for the admin interface ```bash
- `src/styles/`: CSS styling for the admin interface npx wrangler login
- `src/index.ts`: Main application entry point with middleware and routing configuration ```
3. Run setup:
```bash
bash setup.sh
```
`setup.sh` will:
- install npm dependencies
- verify Cloudflare auth (`wrangler whoami`)
- create KV namespaces (`EMAIL_STORAGE` + preview)
- set the `ADMIN_PASSWORD` secret in `production`
- generate `wrangler.toml` from `wrangler-example.toml`
- stamp `compatibility_date` to the current date
4. Configure ForwardEmail DNS records in Cloudflare:
| Type | Name | Content | Notes |
| ---- | ---- | ---------------------------------------------------- | ----------------------- |
| MX | @ | `mx1.forwardemail.net` | Priority `10`, DNS only |
| MX | @ | `mx2.forwardemail.net` | Priority `10`, DNS only |
| TXT | @ | `"forward-email=https://yourdomain.com/api/inbound"` | webhook target |
| TXT | @ | `"v=spf1 include:spf.forwardemail.net -all"` | SPF |
5. Deploy:
```bash
npm run deploy
```
6. Open `https://yourdomain.com/admin` and sign in.
## Development ## Development
This project uses a modern build process with Cloudflare Wrangler's built-in bundling (powered by `esbuild`):
```bash ```bash
# Install dependencies
npm install npm install
# Run development server
npm run dev npm run dev
npm test
# Build for production
npm run build npm run build
# Deploy to Cloudflare
npm run deploy
``` ```
## Technology Stack ## Configuration notes
- **Cloudflare Workers**: Edge computing platform for running the service - `wrangler-example.toml` is the template; `wrangler.toml` is generated locally.
- **Cloudflare KV**: Key-value storage for email and feed data - Keep `compatibility_date` fresh when doing runtime upgrades.
- **Hono**: Lightweight web framework for routing and middleware - `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config.
- **TypeScript**: Type-safe JavaScript for reliable code
- **Feed**: Modern RSS feed generator
- **Zod**: Schema validation for input data
## Minimalist Approach ## Security notes
This project follows a minimalist approach: - Inbound webhook access is IP-restricted to ForwardEmail MX sources.
- Admin auth is cookie-based (`HttpOnly`, `SameSite=Strict`).
- You should use a strong admin password and rotate periodically.
- No unnecessary dependencies ## Upgrading dependencies
- Web-standard APIs where possible
- No Node.js-specific modules or polyfills
- Modern TypeScript features
- Clean, maintainable code structure
- Modular organization for improved maintainability
## Feed ID System To refresh dependencies to latest:
The system generates memorable, user-friendly feed IDs in the format `noun1.noun2.XY` where: ```bash
npm outdated
npm install
npm test
npm run build
```
- `noun1` and `noun2` are randomly selected from a curated list of ~450 common, neutral nouns Then update `compatibility_date` and redeploy.
- `XY` is a random two-digit number between 10 and 99
This is inspired by iCloud's Hide My Email feature.
This format provides:
- Easy to read and share email addresses
- Low collision probability (can handle thousands of feeds)
- Simple to remember for users
- ~20 million possible combinations
### Noun Selection
The noun list has been carefully curated to:
- Include only common, everyday objects and concepts
- Exclude any potentially problematic terms
- Ensure appropriate combinations when nouns are randomly paired
- Maintain a professional appearance for all generated feed IDs
## License ## License
+12 -12
View File
@@ -15,20 +15,20 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250224.0", "@cloudflare/workers-types": "^4.20260206.0",
"@types/mailparser": "^3.4.5", "@types/mailparser": "^3.4.6",
"@types/rss": "^0.0.32", "@types/rss": "^0.0.32",
"@vitest/coverage-v8": "^1.3.1", "@vitest/coverage-v8": "^4.0.18",
"happy-dom": "^13.3.8", "happy-dom": "^20.5.0",
"msw": "^2.2.1", "msw": "^2.12.8",
"prettier": "^3.5.2", "prettier": "^3.8.1",
"typescript": "^5.7.3", "typescript": "^5.9.3",
"vitest": "^1.3.1", "vitest": "^4.0.18",
"wrangler": "^3.111.0" "wrangler": "^4.63.0"
}, },
"dependencies": { "dependencies": {
"feed": "^4.2.2", "feed": "^5.2.0",
"hono": "^3.12.8", "hono": "^4.11.7",
"zod": "^3.22.4" "zod": "^4.3.6"
} }
} }
+112 -139
View File
@@ -1,29 +1,34 @@
#!/bin/bash #!/usr/bin/env bash
# Email to RSS setup script set -euo pipefail
echo "🚀 Setting up Email to RSS service..." echo "🚀 Setting up Email to RSS service..."
# Check if npm and npx are installed if ! command -v npm >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then
if ! command -v npm &> /dev/null || ! command -v npx &> /dev/null; then echo "❌ Error: Node.js (with npm and npx) is required but not found."
echo "❌ Error: npm and npx are required but not found." echo "Install Node.js from https://nodejs.org/en/download/ and run setup again."
echo "Please install Node.js from https://nodejs.org/en/download/"
exit 1 exit 1
fi fi
# Check if wrangler-example.toml exists early
if [ ! -f "wrangler-example.toml" ]; then if [ ! -f "wrangler-example.toml" ]; then
echo "❌ Error: wrangler-example.toml not found." echo "❌ Error: wrangler-example.toml not found."
exit 1 exit 1
fi fi
# Install dependencies WORKER_NAME="$(grep -E '^name = "' wrangler-example.toml | head -1 | cut -d'"' -f2)"
if [ -z "$WORKER_NAME" ]; then
WORKER_NAME="email-to-rss"
fi
echo "📦 Installing dependencies..." echo "📦 Installing dependencies..."
npm install npm install
# Check if user is logged in to Cloudflare
echo "🔒 Checking Cloudflare authentication..." echo "🔒 Checking Cloudflare authentication..."
if ! npx wrangler whoami &>/dev/null; then set +e
WHOAMI_OUTPUT="$(npx wrangler whoami 2>&1)"
WHOAMI_STATUS=$?
set -e
if [ "$WHOAMI_STATUS" -ne 0 ] || echo "$WHOAMI_OUTPUT" | grep -qi "not authenticated"; then
echo "❌ You are not logged in to Cloudflare. Please run:" echo "❌ You are not logged in to Cloudflare. Please run:"
echo "npx wrangler login" echo "npx wrangler login"
echo "After login completes, run this setup script again." echo "After login completes, run this setup script again."
@@ -31,162 +36,130 @@ if ! npx wrangler whoami &>/dev/null; then
fi fi
echo "✅ Cloudflare authentication verified" echo "✅ Cloudflare authentication verified"
# Function to get KV namespace IDs extract_namespace_ids_from_json() {
local worker_name="$1"
node - "$worker_name" <<'NODE'
const fs = require("node:fs");
const workerName = process.argv[2];
let namespaces;
try {
namespaces = JSON.parse(fs.readFileSync(0, "utf8"));
} catch {
process.exit(0);
}
if (!Array.isArray(namespaces)) {
process.exit(0);
}
const findByTitle = (title) => {
const match = namespaces.find((namespace) => namespace?.title === title && typeof namespace?.id === "string");
return match?.id ?? "";
};
const mainId = findByTitle(`${workerName}-EMAIL_STORAGE`);
const previewId = findByTitle(`${workerName}-EMAIL_STORAGE_preview`);
process.stdout.write(`${mainId}\n${previewId}`);
NODE
}
get_kv_namespace_ids() { get_kv_namespace_ids() {
echo " Retrieving KV namespace IDs..." echo "🔍 Retrieving KV namespace IDs..."
# Get the complete KV namespace list
local output local output
output=$(npx wrangler kv:namespace list 2>/dev/null) if ! output="$(npx wrangler kv namespace list --json 2>/dev/null)"; then
if [ $? -ne 0 ]; then
echo "❌ Error listing KV namespaces. Please check your Cloudflare authentication." echo "❌ Error listing KV namespaces. Please check your Cloudflare authentication."
return 1 return 1
fi fi
# Try the direct approach first (most reliable) local ids
MAIN_ID=$(echo "$output" | grep -o '"id": *"[^"]*"' | head -1 | cut -d'"' -f4) ids="$(printf '%s' "$output" | extract_namespace_ids_from_json "$WORKER_NAME")"
PREVIEW_ID=$(echo "$output" | grep -o '"id": *"[^"]*"' | head -2 | tail -1 | cut -d'"' -f4) MAIN_ID="$(printf '%s\n' "$ids" | sed -n '1p')"
PREVIEW_ID="$(printf '%s\n' "$ids" | sed -n '2p')"
# If the direct approach failed, try to match by namespace title
if [ -z "$MAIN_ID" ] || [ -z "$PREVIEW_ID" ]; then if [ -z "$MAIN_ID" ] || [ -z "$PREVIEW_ID" ]; then
# Save the output to a file for more complex processing MAIN_ID="$(echo "$output" | grep -o '"id": *"[^"]*"' | head -1 | cut -d'"' -f4)"
local temp_file=$(mktemp) PREVIEW_ID="$(echo "$output" | grep -o '"id": *"[^"]*"' | head -2 | tail -1 | cut -d'"' -f4)"
echo "$output" > "$temp_file"
# Try with different patterns
if [ -z "$MAIN_ID" ]; then
MAIN_ID=$(grep -A3 "email-to-rss-EMAIL_STORAGE\"" "$temp_file" | grep -o '"id": "[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$MAIN_ID" ]; then
MAIN_ID=$(grep -A3 "email-to-rss-EMAIL_STORAGE\"" "$temp_file" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
fi
fi
if [ -z "$PREVIEW_ID" ]; then
PREVIEW_ID=$(grep -A3 "email-to-rss-EMAIL_STORAGE_preview\"" "$temp_file" | grep -o '"id": "[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$PREVIEW_ID" ]; then
PREVIEW_ID=$(grep -A3 "email-to-rss-EMAIL_STORAGE_preview\"" "$temp_file" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
fi
fi
# Clean up
rm -f "$temp_file"
fi fi
# Check if we found both IDs
if [ -z "$MAIN_ID" ] || [ -z "$PREVIEW_ID" ]; then if [ -z "$MAIN_ID" ] || [ -z "$PREVIEW_ID" ]; then
echo "❌ Failed to extract KV namespace IDs. Please run manually:" echo "❌ Failed to extract KV namespace IDs. Please run manually:"
echo "npx wrangler kv:namespace list" echo "npx wrangler kv namespace list"
echo "And update the IDs in wrangler.toml" echo "And update the IDs in wrangler.toml"
return 1 return 1
fi fi
return 0 return 0
} }
# Create KV namespaces (suppressing output)
echo "🗄️ Creating KV namespaces..." echo "🗄️ Creating KV namespaces..."
npx wrangler kv:namespace create EMAIL_STORAGE > /dev/null 2>&1 || true npx wrangler kv namespace create EMAIL_STORAGE >/dev/null 2>&1 || true
npx wrangler kv:namespace create EMAIL_STORAGE --preview > /dev/null 2>&1 || true npx wrangler kv namespace create EMAIL_STORAGE --preview >/dev/null 2>&1 || true
# Get KV namespace IDs if ! get_kv_namespace_ids; then
get_kv_namespace_ids echo "❌ Setup cannot continue without KV namespace IDs."
if [ $? -ne 0 ]; then
echo "⚠️ Will continue without KV namespace IDs"
KV_ID=""
KV_PREVIEW_ID=""
else
KV_ID="$MAIN_ID"
KV_PREVIEW_ID="$PREVIEW_ID"
fi
# Summarize KV namespace status
echo "📊 KV Namespace Status:"
if [ -z "$KV_ID" ]; then
echo " ❌ Main KV namespace ID: Not found"
SETUP_SUCCESS=false
else
echo " ✅ Main KV namespace ID: $KV_ID"
SETUP_SUCCESS=true
fi
if [ -z "$KV_PREVIEW_ID" ]; then
echo " ❌ Preview KV namespace ID: Not found"
SETUP_SUCCESS=false
else
echo " ✅ Preview KV namespace ID: $KV_PREVIEW_ID"
fi
# Set up admin password
echo "🔐 Setting up admin password..."
read -p "Enter admin password: " admin_password
echo "Setting admin password for production environment..."
# Initialize SETUP_SUCCESS if not already set
if [ -z "$SETUP_SUCCESS" ]; then
SETUP_SUCCESS=true
fi
# Try to set the secret without redirecting stderr to see any errors
if [ -z "$admin_password" ]; then
echo "⚠️ No admin password provided. Skipping secret creation."
else
# Run the command and capture its output
SECRET_OUTPUT=$(echo "$admin_password" | npx wrangler secret put ADMIN_PASSWORD --env production --name email-to-rss 2>&1)
SECRET_STATUS=$?
if [ $SECRET_STATUS -ne 0 ]; then
echo "⚠️ Failed to set admin password for production environment"
echo "Error: $SECRET_OUTPUT"
SETUP_SUCCESS=false
else
echo "✅ Admin password set for production environment"
fi
fi
# Prompt for domain
read -p "Enter your domain (e.g., yourdomain.com): " domain
if [ -z "$domain" ]; then
echo "❌ No domain provided. Cannot continue."
SETUP_SUCCESS=false
else
echo "✅ Domain: $domain"
fi
# Create and update wrangler.toml only if everything is successful
if [ "$SETUP_SUCCESS" = false ]; then
echo "⚠️ Some parts of the setup failed. Will not create wrangler.toml."
echo "Please fix the issues and run the script again."
exit 1 exit 1
fi fi
# Create and configure wrangler.toml echo "📊 KV Namespace Status:"
echo " ✅ Main KV namespace ID: $MAIN_ID"
echo " ✅ Preview KV namespace ID: $PREVIEW_ID"
echo "🔐 Setting up admin password..."
read -r -p "Enter admin password: " admin_password
if [ -z "$admin_password" ]; then
echo "❌ No admin password provided."
exit 1
fi
set +e
SECRET_OUTPUT="$(printf '%s' "$admin_password" | npx wrangler secret put ADMIN_PASSWORD --env production --name "$WORKER_NAME" 2>&1)"
SECRET_STATUS=$?
set -e
if [ "$SECRET_STATUS" -ne 0 ]; then
echo "❌ Failed to set admin password for production environment"
echo "Error: $SECRET_OUTPUT"
exit 1
fi
echo "✅ Admin password set for production environment"
read -r -p "Enter your domain (e.g., yourdomain.com): " domain
domain="${domain#https://}"
domain="${domain#http://}"
domain="${domain%%/*}"
if [ -z "$domain" ]; then
echo "❌ No domain provided. Cannot continue."
exit 1
fi
echo "✅ Domain: $domain"
escape_sed_replacement() {
printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'
}
KV_ID_ESCAPED="$(escape_sed_replacement "$MAIN_ID")"
KV_PREVIEW_ID_ESCAPED="$(escape_sed_replacement "$PREVIEW_ID")"
DOMAIN_ESCAPED="$(escape_sed_replacement "$domain")"
COMPATIBILITY_DATE_ESCAPED="$(escape_sed_replacement "$(date +%F)")"
echo "📝 Creating and configuring wrangler.toml..." echo "📝 Creating and configuring wrangler.toml..."
cp wrangler-example.toml wrangler.toml cp wrangler-example.toml wrangler.toml
# Update wrangler.toml with domain and KV IDs
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS requires empty string for -i sed -i '' "s/REPLACE_WITH_YOUR_DOMAIN/$DOMAIN_ESCAPED/g" wrangler.toml
sed -i '' "s/REPLACE_WITH_YOUR_DOMAIN/$domain/g" wrangler.toml sed -i '' "s/REPLACE_WITH_YOUR_KV_NAMESPACE_ID/$KV_ID_ESCAPED/g" wrangler.toml
if [ ! -z "$KV_ID" ]; then sed -i '' "s/REPLACE_WITH_YOUR_PREVIEW_KV_NAMESPACE_ID/$KV_PREVIEW_ID_ESCAPED/g" wrangler.toml
sed -i '' "s/REPLACE_WITH_YOUR_KV_NAMESPACE_ID/$KV_ID/g" wrangler.toml sed -i '' "s/REPLACE_WITH_COMPATIBILITY_DATE/$COMPATIBILITY_DATE_ESCAPED/g" wrangler.toml
fi
if [ ! -z "$KV_PREVIEW_ID" ]; then
sed -i '' "s/REPLACE_WITH_YOUR_PREVIEW_KV_NAMESPACE_ID/$KV_PREVIEW_ID/g" wrangler.toml
fi
else else
# Linux and others sed -i "s/REPLACE_WITH_YOUR_DOMAIN/$DOMAIN_ESCAPED/g" wrangler.toml
sed -i "s/REPLACE_WITH_YOUR_DOMAIN/$domain/g" wrangler.toml sed -i "s/REPLACE_WITH_YOUR_KV_NAMESPACE_ID/$KV_ID_ESCAPED/g" wrangler.toml
if [ ! -z "$KV_ID" ]; then sed -i "s/REPLACE_WITH_YOUR_PREVIEW_KV_NAMESPACE_ID/$KV_PREVIEW_ID_ESCAPED/g" wrangler.toml
sed -i "s/REPLACE_WITH_YOUR_KV_NAMESPACE_ID/$KV_ID/g" wrangler.toml sed -i "s/REPLACE_WITH_COMPATIBILITY_DATE/$COMPATIBILITY_DATE_ESCAPED/g" wrangler.toml
fi
if [ ! -z "$KV_PREVIEW_ID" ]; then
sed -i "s/REPLACE_WITH_YOUR_PREVIEW_KV_NAMESPACE_ID/$KV_PREVIEW_ID/g" wrangler.toml
fi
fi fi
echo "✅ wrangler.toml has been created and configured successfully!" echo "✅ wrangler.toml has been created and configured successfully!"
echo "" echo ""
echo "✅ Setup complete! Next steps:" echo "✅ Setup complete! Next steps:"
echo "1. Set up MX records for your domain with ForwardEmail.net (see README for more details)" echo "1. Set up MX records for your domain with ForwardEmail.net (see README for details)"
echo "2. Deploy with 'npm run deploy'" echo "2. Deploy with 'npm run deploy'"
+2 -2
View File
@@ -118,7 +118,7 @@ api.use('/inbound', async (c, next) => {
return c.text('Unauthorized', 401); return c.text('Unauthorized', 401);
} }
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP}`); console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`);
await next(); await next();
}); });
@@ -143,4 +143,4 @@ app.get('/', (c) => c.redirect('/admin'));
app.all('*', (c) => c.text('Not Found', 404)); app.all('*', (c) => c.text('Not Found', 404));
// Export the worker handler // Export the worker handler
export default app; export default app;
+110 -108
View File
@@ -1,236 +1,238 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from "vitest";
import { Hono } from 'hono'; import { Hono } from "hono";
import app from './admin'; import app from "./admin";
import { createMockEnv } from '../test/setup'; import { createMockEnv } from "../test/setup";
import { Env } from '../types'; import { Env } from "../types";
describe('Admin Routes', () => { describe("Admin Routes", () => {
let testApp: Hono; let testApp: Hono;
let mockEnv: Env; let mockEnv: Env;
let request: (path: string, init?: RequestInit) => Promise<Response>;
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);
}); });
describe('Authentication', () => { describe("Authentication", () => {
it('should redirect to login page when not authenticated', async () => { it("should redirect to login page when not authenticated", async () => {
const res = await testApp.request('/admin', { const res = await request("/admin");
env: mockEnv
});
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login'); expect(res.headers.get("Location")).toBe("/admin/login");
}); });
it('should allow access to login page without authentication', async () => { it("should allow access to login page without authentication", async () => {
const res = await testApp.request('/admin/login', { const res = await request("/admin/login");
env: mockEnv
});
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('text/html'); expect(res.headers.get("Content-Type")).toContain("text/html");
}); });
it('should set auth cookie and redirect on successful login', async () => { it("should set auth cookie and redirect on successful login", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append('password', 'test-password'); formData.append("password", "test-password");
const res = await testApp.request('/admin/login', { const res = await request("/admin/login", {
method: 'POST', method: "POST",
body: formData, body: formData,
env: mockEnv
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
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=true");
expect(cookie).toContain('HttpOnly'); expect(cookie).toContain("HttpOnly");
expect(cookie).toContain('SameSite=Strict'); expect(cookie).toContain("SameSite=Strict");
expect(cookie).toContain('Path=/'); expect(cookie).toContain("Path=/");
}); });
it('should reject login with incorrect password', async () => { it("should reject login with incorrect password", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append('password', 'wrong-password'); formData.append("password", "wrong-password");
const res = await testApp.request('/admin/login', { const res = await request("/admin/login", {
method: 'POST', method: "POST",
body: formData, body: formData,
env: mockEnv
}); });
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login?error=invalid'); expect(res.headers.get("Location")).toBe("/admin/login?error=invalid");
}); });
it('should reject login with missing password', async () => { it("should reject login with missing password", async () => {
const formData = new FormData(); const formData = new FormData();
const res = await testApp.request('/admin/login', { const res = await request("/admin/login", {
method: 'POST', method: "POST",
body: formData, body: formData,
env: mockEnv
}); });
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login?error=invalid'); expect(res.headers.get("Location")).toBe("/admin/login?error=invalid");
}); });
}); });
describe('Protected Routes', () => { describe("Protected Routes", () => {
const authCookie = 'admin_auth=true'; 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 res = await testApp.request('/admin', { const res = await request("/admin", {
headers: { headers: {
Cookie: authCookie Cookie: authCookie,
}, },
env: mockEnv
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('text/html'); expect(res.headers.get("Content-Type")).toContain("text/html");
}); });
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();
formData.append('title', 'Test Feed'); formData.append("title", "Test Feed");
formData.append('description', 'Test Description'); formData.append("description", "Test Description");
const res = await testApp.request('/admin/feeds/create', { const res = await request("/admin/feeds/create", {
method: 'POST', method: "POST",
body: formData, body: formData,
env: mockEnv
}); });
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login'); expect(res.headers.get("Location")).toBe("/admin/login");
// Verify no feed was created // Verify no feed was created
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json'); const feedList = await mockEnv.EMAIL_STORAGE.get("feeds:list", "json");
expect(feedList).toBeNull(); expect(feedList).toBeNull();
}); });
it('should allow feed creation with valid authentication', async () => { it("should allow feed creation with valid authentication", async () => {
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");
const res = await testApp.request('/admin/feeds/create', { const res = await request("/admin/feeds/create", {
method: 'POST', method: "POST",
headers: { headers: {
Cookie: authCookie Cookie: authCookie,
}, },
body: formData, body: formData,
env: mockEnv
}); });
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");
// Verify feed was created in KV // Verify feed was created in KV
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json'); const feedList = (await mockEnv.EMAIL_STORAGE.get(
"feeds:list",
"json",
)) as { feeds: Array<{ id: string; title: string }> } | null;
expect(feedList).toBeTruthy(); expect(feedList).toBeTruthy();
expect(feedList.length).toBe(1); expect(feedList?.feeds.length).toBe(1);
expect(feedList[0].title).toBe('Test Feed'); expect(feedList?.feeds[0].title).toBe("Test Feed");
// Verify feed config was created // Verify feed config was created
const feedId = feedList[0].id; const feedId = feedList?.feeds[0].id as string;
const feedConfig = await mockEnv.EMAIL_STORAGE.get(`feed:${feedId}:config`, 'json'); const feedConfig = await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
);
expect(feedConfig).toBeTruthy(); expect(feedConfig).toBeTruthy();
expect(feedConfig.title).toBe('Test Feed'); expect(feedConfig.title).toBe("Test Feed");
expect(feedConfig.description).toBe('Test Description'); expect(feedConfig.description).toBe("Test Description");
}); });
it('should reject feed creation with missing title', async () => { it("should reject feed creation with missing title", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append('description', 'Test Description'); formData.append("description", "Test Description");
const res = await testApp.request('/admin/feeds/create', { const res = await request("/admin/feeds/create", {
method: 'POST', method: "POST",
headers: { headers: {
Cookie: authCookie Cookie: authCookie,
}, },
body: formData, body: formData,
env: mockEnv
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
// Verify no feed was created // Verify no feed was created
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json'); const feedList = await mockEnv.EMAIL_STORAGE.get("feeds:list", "json");
expect(feedList).toBeNull(); expect(feedList).toBeNull();
}); });
}); });
describe('Feed Management', () => { describe("Feed Management", () => {
it('should prevent feed deletion without authentication', async () => { it("should prevent feed deletion without authentication", async () => {
const res = await testApp.request('/admin/feeds/test-feed/delete', { const res = await request("/admin/feeds/test-feed/delete", {
method: 'POST', method: "POST",
env: mockEnv
}); });
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login'); expect(res.headers.get("Location")).toBe("/admin/login");
}); });
it('should prevent API feed updates without authentication', async () => { it("should prevent API feed updates without authentication", async () => {
const res = await testApp.request('/admin/api/feeds/test-feed/update', { const res = await request("/admin/api/feeds/test-feed/update", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
title: 'Updated Title', title: "Updated Title",
description: 'Updated Description' description: "Updated Description",
}), }),
env: mockEnv
}); });
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login'); expect(res.headers.get("Location")).toBe("/admin/login");
}); });
it('should allow feed deletion with valid authentication', async () => { it("should allow feed deletion with valid authentication", async () => {
// 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");
formData.append('description', 'Test Description'); formData.append("description", "Test Description");
const createRes = await testApp.request('/admin/feeds/create', { const createRes = await request("/admin/feeds/create", {
method: 'POST', method: "POST",
headers: { headers: {
Cookie: authCookie Cookie: authCookie,
}, },
body: formData, body: formData,
env: mockEnv
}); });
expect(createRes.status).toBe(302); expect(createRes.status).toBe(302);
// Get the feed ID // Get the feed ID
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json'); const feedList = (await mockEnv.EMAIL_STORAGE.get(
const feedId = feedList[0].id; "feeds:list",
"json",
)) as { feeds: Array<{ id: string; title: string }> } | null;
const feedId = feedList?.feeds[0].id as string;
// Now delete it // Now delete it
const deleteRes = await testApp.request(`/admin/feeds/${feedId}/delete`, { const deleteRes = await request(`/admin/feeds/${feedId}/delete`, {
method: 'POST', method: "POST",
headers: { headers: {
Cookie: authCookie Cookie: authCookie,
}, },
env: mockEnv
}); });
expect(deleteRes.status).toBe(302); expect(deleteRes.status).toBe(302);
expect(deleteRes.headers.get('Location')).toBe('/admin'); expect(deleteRes.headers.get("Location")).toBe("/admin");
// Verify feed was deleted // Verify feed was deleted
const updatedFeedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json'); const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get(
"feeds:list",
"json",
)) as { feeds: Array<{ id: string; title: string }> } | null;
expect(updatedFeedList).toBeTruthy(); expect(updatedFeedList).toBeTruthy();
expect(updatedFeedList.length).toBe(0); expect(updatedFeedList?.feeds.length).toBe(0);
// Verify feed config was deleted // Verify feed config was deleted
const feedConfig = await mockEnv.EMAIL_STORAGE.get(`feed:${feedId}:config`, 'json'); const feedConfig = await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
);
expect(feedConfig).toBeNull(); expect(feedConfig).toBeNull();
}); });
}); });
+3 -2
View File
@@ -1,4 +1,5 @@
import { Context, Hono } from 'hono'; import { Context, Hono } from 'hono';
import { getCookie } from 'hono/cookie';
import { html, raw } from 'hono/html'; import { html, raw } from 'hono/html';
import { z } from 'zod'; import { z } from 'zod';
import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem } from '../types'; import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem } from '../types';
@@ -28,7 +29,7 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
return next(); return next();
} }
const authCookie = c.req.cookie('admin_auth'); const authCookie = getCookie(c, 'admin_auth');
if (!authCookie || authCookie !== 'true') { if (!authCookie || authCookie !== 'true') {
return c.redirect('/admin/login'); return c.redirect('/admin/login');
} }
@@ -1047,4 +1048,4 @@ app.post('/api/feeds/:feedId/update', async (c) => {
}); });
// Export the Hono app // Export the Hono app
export const handle = app; export const handle = app;
+48 -23
View File
@@ -1,5 +1,5 @@
import { beforeAll, afterAll, afterEach } from 'vitest'; import { beforeAll, afterAll, afterEach } from "vitest";
import { setupServer } from 'msw/node'; import { setupServer } from "msw/node";
/** /**
* Mock implementation of Cloudflare Workers runtime environment * Mock implementation of Cloudflare Workers runtime environment
@@ -29,14 +29,27 @@ declare global {
class MockKV { class MockKV {
private store: Map<string, any> = new Map(); private store: Map<string, any> = new Map();
async get(key: string, type: 'text' | 'json' | 'arrayBuffer' | 'stream' = 'text') { async get(
key: string,
typeOrOptions:
| "text"
| "json"
| "arrayBuffer"
| "stream"
| { type: "text" | "json" | "arrayBuffer" | "stream" } = "text",
) {
const type =
typeof typeOrOptions === "string" ? typeOrOptions : typeOrOptions.type;
const value = this.store.get(key); const value = this.store.get(key);
if (!value) return null; if (!value) return null;
return type === 'json' ? JSON.parse(value) : value; return type === "json" ? JSON.parse(value) : value;
} }
async put(key: string, value: any) { async put(key: string, value: any) {
this.store.set(key, typeof value === 'string' ? value : JSON.stringify(value)); this.store.set(
key,
typeof value === "string" ? value : JSON.stringify(value),
);
return undefined; // Match CF Workers KV behavior return undefined; // Match CF Workers KV behavior
} }
@@ -47,14 +60,14 @@ class MockKV {
async list(options?: { prefix?: string; cursor?: string; limit?: number }) { async list(options?: { prefix?: string; cursor?: string; limit?: number }) {
const keys = Array.from(this.store.keys()) const keys = Array.from(this.store.keys())
.filter(key => !options?.prefix || key.startsWith(options.prefix)) .filter((key) => !options?.prefix || key.startsWith(options.prefix))
.slice(0, options?.limit || undefined) .slice(0, options?.limit || undefined)
.map(name => ({ name })); .map((name) => ({ name }));
return { return {
keys, keys,
list_complete: true, list_complete: true,
cursor: '' cursor: "",
}; };
} }
} }
@@ -72,21 +85,33 @@ class MockCache implements Cache {
return undefined; return undefined;
} }
async match(request: RequestInfo, options?: CacheQueryOptions): Promise<Response | undefined> { async match(
request: RequestInfo,
options?: CacheQueryOptions,
): Promise<Response | undefined> {
const key = request instanceof Request ? request.url : request; const key = request instanceof Request ? request.url : request;
const response = this.store.get(key); const response = this.store.get(key);
return response?.clone(); return response?.clone();
} }
async delete(request: RequestInfo, options?: CacheQueryOptions): Promise<boolean> { async delete(
request: RequestInfo,
options?: CacheQueryOptions,
): Promise<boolean> {
const key = request instanceof Request ? request.url : request; const key = request instanceof Request ? request.url : request;
return this.store.delete(key); return this.store.delete(key);
} }
// Required Cache interface methods with minimal implementations // Required Cache interface methods with minimal implementations
async add(): Promise<void> { throw new Error('Not implemented'); } async add(): Promise<void> {
async addAll(): Promise<void> { throw new Error('Not implemented'); } throw new Error("Not implemented");
async keys(): Promise<Request[]> { return []; } }
async addAll(): Promise<void> {
throw new Error("Not implemented");
}
async keys(): Promise<Request[]> {
return [];
}
} }
// Create MSW server for mocking external requests // Create MSW server for mocking external requests
@@ -95,37 +120,37 @@ export const server = setupServer();
// Setup before tests // Setup before tests
beforeAll(() => { beforeAll(() => {
// Setup MSW server // Setup MSW server
server.listen({ onUnhandledRequest: 'error' }); server.listen({ onUnhandledRequest: "error" });
// Mock Cloudflare Workers runtime globals // Mock Cloudflare Workers runtime globals
global.caches = { global.caches = {
default: new MockCache(), default: new MockCache(),
open: async () => new MockCache() open: async () => new MockCache(),
} as unknown as CacheStorage; } as unknown as CacheStorage;
// Mock crypto for generating random values // Mock crypto for generating random values
if (!global.crypto) { if (!global.crypto) {
global.crypto = require('crypto').webcrypto; global.crypto = require("crypto").webcrypto;
} }
// Ensure other required globals are available // Ensure other required globals are available
if (!global.FormData) { if (!global.FormData) {
const { FormData } = require('undici'); const { FormData } = require("undici");
global.FormData = FormData; global.FormData = FormData;
} }
if (!global.Headers) { if (!global.Headers) {
const { Headers } = require('undici'); const { Headers } = require("undici");
global.Headers = Headers; global.Headers = Headers;
} }
if (!global.Request) { if (!global.Request) {
const { Request } = require('undici'); const { Request } = require("undici");
global.Request = Request; global.Request = Request;
} }
if (!global.Response) { if (!global.Response) {
const { Response } = require('undici'); const { Response } = require("undici");
global.Response = Response; global.Response = Response;
} }
}); });
@@ -145,6 +170,6 @@ afterEach(() => {
*/ */
export const createMockEnv = () => ({ export const createMockEnv = () => ({
EMAIL_STORAGE: new MockKV(), EMAIL_STORAGE: new MockKV(),
DOMAIN: 'test.getmynews.app', DOMAIN: "test.getmynews.app",
ADMIN_PASSWORD: 'test-password', ADMIN_PASSWORD: "test-password",
}); });
+20 -20
View File
@@ -1,34 +1,34 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
// Use happy-dom for browser API simulation // Node runtime is a better match for Worker route/unit tests
environment: 'happy-dom', environment: "node",
// Include source files for coverage // Include source files for coverage
include: ['src/**/*.{test,spec}.{js,ts}'], include: ["src/**/*.{test,spec}.{js,ts}"],
// Coverage configuration // Coverage configuration
coverage: { coverage: {
provider: 'v8', provider: "v8",
reporter: ['text', 'json', 'html'], reporter: ["text", "json", "html"],
include: ['src/**/*.ts'], include: ["src/**/*.ts"],
exclude: [ exclude: [
'src/**/*.{test,spec}.ts', "src/**/*.{test,spec}.ts",
'src/types/**', "src/types/**",
'src/scripts/**', "src/scripts/**",
'src/styles/**' "src/styles/**",
] ],
}, },
// Global setup files // Global setup files
setupFiles: ['src/test/setup.ts'], setupFiles: ["src/test/setup.ts"],
// Mock Cloudflare Workers runtime // Mock Cloudflare Workers runtime
globals: true, globals: true,
// Timeouts // Timeouts
testTimeout: 10000, testTimeout: 10000,
hookTimeout: 10000 hookTimeout: 10000,
} },
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
name = "email-to-rss" name = "email-to-rss"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2024-09-23" compatibility_date = "REPLACE_WITH_COMPATIBILITY_DATE"
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
# Global KV Namespace bindings # Global KV Namespace bindings
@@ -30,4 +30,4 @@ routes = [
] ]
[env.production.vars] [env.production.vars]
DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" DOMAIN = "REPLACE_WITH_YOUR_DOMAIN"