mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
Set up initial project and files
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
description: Project Implementation Plan
|
||||||
|
globs: *
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Detailed Project Implementation Plan
|
||||||
|
|
||||||
|
This section is a top-level "blueprint" describing the solution architecture and how each component fits together.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Goal:** Build a service that turns email newsletters into RSS feeds, so you can subscribe in an RSS reader like Reeder. The service should provide unique email addresses per feed, a front-end admin panel, indefinite (or long-term) storage of newsletters, and minimal cost—preferably using Cloudflare services plus ForwardEmail.net.
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
1. **ForwardEmail.net**
|
||||||
|
- Accept incoming newsletters on your custom domain’s email addresses.
|
||||||
|
- Forward them (via webhook) to your API endpoint for processing.
|
||||||
|
- Free inbound plan includes JSON + raw MIME data.
|
||||||
|
2. **Cloudflare Workers**
|
||||||
|
- **Inbound Worker:** Receives the webhook from ForwardEmail.net, parses/stores newsletter data in KV (or R2).
|
||||||
|
- **RSS Worker:** Serves RSS feeds by reading from KV and outputting XML.
|
||||||
|
- **Admin Worker (potential):** Could serve a small UI or JSON API for feed management.
|
||||||
|
3. **Cloudflare KV**
|
||||||
|
- Key-value store for storing newsletter items (subject, date, HTML, etc.).
|
||||||
|
- Minimal cost for text data.
|
||||||
|
- Indefinite retention if you keep usage under limits.
|
||||||
|
4. **Cloudflare Pages (Optional)**
|
||||||
|
- Could host a separate front-end for admin tasks.
|
||||||
|
- Alternatively, build a simple admin UI directly within the Worker.
|
||||||
|
5. **Admin Dashboard**
|
||||||
|
- Basic login and feed creation (generate random email addresses).
|
||||||
|
- List newsletters and optionally delete them or rename feed titles.
|
||||||
|
- For a simple approach, implement a minimal password-protected area or JSON endpoints.
|
||||||
|
6. **Domain / DNS Setup**
|
||||||
|
- Use your custom domain (e.g. `mynewsletters.dev`).
|
||||||
|
- Add DNS records so ForwardEmail.net is the MX handler.
|
||||||
|
- Configure Cloudflare for general DNS (with “Orange Cloud” or not, depending on your proxying preferences).
|
||||||
|
- Verify your domain following ForwardEmail.net’s instructions.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. A newsletter arrives at `newsletterXYZ@mynewsletters.dev`.
|
||||||
|
2. ForwardEmail.net triggers a webhook to `https://your-worker.example.com/api/inbound?feed=XYZ` with JSON + raw MIME.
|
||||||
|
3. The Worker parses the email, extracts relevant information (date, subject, HTML body), and stores it in KV under a key like `feed:XYZ:timestamp`.
|
||||||
|
4. When your RSS reader (e.g. Reeder) requests `GET https://your-worker.example.com/rss/XYZ`, the Worker fetches all items from KV for that feed, builds an RSS XML response, and returns it.
|
||||||
|
5. *(Optional)* The Admin Dashboard (via a password-protected route or a separate Cloudflare Pages front-end) can create new feed IDs, display items, etc.
|
||||||
|
|
||||||
|
## Summary of Implementation Steps
|
||||||
|
|
||||||
|
1. Set up the Domain and ForwardEmail.net for inbound mail.
|
||||||
|
2. Create a Cloudflare Worker to handle the inbound webhook.
|
||||||
|
3. Parse the email (using ForwardEmail.net’s parsed data or parsing the raw MIME if necessary).
|
||||||
|
4. Store the data in KV.
|
||||||
|
5. Create an RSS Worker endpoint to retrieve the data and output XML.
|
||||||
|
6. *(Optional)* Develop an Admin UI to create new feeds, list items, and manage them.
|
||||||
|
7. Deploy and test the solution. Subscribe to the feed with Reeder.
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# System
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Email-to-RSS
|
||||||
|
|
||||||
|
A modern service that turns email newsletters into RSS feeds, built with Cloudflare Workers. This service provides unique email addresses per feed, a front-end admin panel, and long-term storage of newsletters.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Minimal Dependencies**: Built using modern, web-friendly libraries without Node.js-specific dependencies
|
||||||
|
- **Lightweight**: Entire worker bundle is only ~360KB (gzipped: ~65KB)
|
||||||
|
- **Email Processing**: Handles emails from ForwardEmail.net webhook
|
||||||
|
- **RSS Generation**: Serves standards-compliant RSS feeds
|
||||||
|
- **Admin Interface**: Simple management UI for feeds and emails
|
||||||
|
- **Storage**: Uses Cloudflare KV for efficient, low-cost storage
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Email Flow
|
||||||
|
1. A newsletter arrives at `newsletter-XYZ@yourdomain.com`
|
||||||
|
2. ForwardEmail.net forwards it to your Cloudflare Worker
|
||||||
|
3. The Worker parses the email, extracts content, and stores it in KV
|
||||||
|
4. The RSS feed is updated with the new content
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
- **Email Parser**: Lightweight custom parser that works in edge environments
|
||||||
|
- **Feed Generator**: Modern RSS feed generator with minimal dependencies
|
||||||
|
- **Admin UI**: Simple interface to manage feeds and view emails
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
This project uses a modern build process with Wrangler's built-in bundling (powered by esbuild):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Deploy to Cloudflare
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `ADMIN_PASSWORD`: Password for the admin interface
|
||||||
|
- `DOMAIN`: Your custom domain for receiving emails
|
||||||
|
- `FORWARDEMAIL_TOKEN`: Token for ForwardEmail.net webhook authentication
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Cloudflare Workers**: Edge computing platform
|
||||||
|
- **Cloudflare KV**: Key-value storage
|
||||||
|
- **Hono**: Lightweight web framework
|
||||||
|
- **TypeScript**: Type-safe JavaScript
|
||||||
|
- **Feed**: Modern RSS feed generator
|
||||||
|
- **Zod**: Schema validation
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Clone this repository
|
||||||
|
2. Install dependencies with `npm install`
|
||||||
|
3. Copy `wrangler.toml.example` to `wrangler.toml` and set your values
|
||||||
|
4. Run `npm run dev` to start the development server
|
||||||
|
5. Deploy with `npm run deploy`
|
||||||
|
|
||||||
|
## Minimalist Approach
|
||||||
|
|
||||||
|
This project follows a minimalist approach:
|
||||||
|
|
||||||
|
- No unnecessary dependencies
|
||||||
|
- Web-standard APIs where possible
|
||||||
|
- No Node.js-specific modules or polyfills
|
||||||
|
- Modern TypeScript features
|
||||||
|
- Clean, maintainable code structure
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "email-to-rss",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A service that converts email newsletters to RSS feeds using Cloudflare Workers",
|
||||||
|
"main": "dist/worker.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "wrangler deploy --dry-run --outdir=dist",
|
||||||
|
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250224.0",
|
||||||
|
"@types/mailparser": "^3.4.5",
|
||||||
|
"@types/rss": "^0.0.32",
|
||||||
|
"prettier": "^3.5.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"wrangler": "^3.111.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"feed": "^4.2.2",
|
||||||
|
"hono": "^3.12.8",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Email to RSS setup script
|
||||||
|
|
||||||
|
echo "🚀 Setting up Email to RSS service..."
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Create KV namespaces
|
||||||
|
echo "🗄️ Creating KV namespaces..."
|
||||||
|
echo "You'll need to update wrangler.toml with these IDs."
|
||||||
|
npx wrangler kv:namespace create EMAIL_STORAGE
|
||||||
|
npx wrangler kv:namespace create EMAIL_STORAGE --preview
|
||||||
|
|
||||||
|
# Set up admin password
|
||||||
|
echo "🔐 Setting up admin password..."
|
||||||
|
read -p "Enter admin password: " admin_password
|
||||||
|
echo "$admin_password" | npx wrangler secret put ADMIN_PASSWORD
|
||||||
|
|
||||||
|
# Prompt for domain
|
||||||
|
read -p "Enter your domain (e.g., yourdomain.com): " domain
|
||||||
|
echo "📝 Please update your domain in wrangler.toml"
|
||||||
|
|
||||||
|
echo "✅ Setup complete! Next steps:"
|
||||||
|
echo "1. Update wrangler.toml with your KV namespace IDs and domain"
|
||||||
|
echo "2. Set up MX records for your domain with ForwardEmail.net"
|
||||||
|
echo "3. Deploy with 'npm run deploy'"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { handle as handleInbound } from './routes/inbound';
|
||||||
|
import { handle as handleRSS } from './routes/rss';
|
||||||
|
import { handle as handleAdmin } from './routes/admin';
|
||||||
|
import { Context, Next } from 'hono';
|
||||||
|
import { Env } from './types';
|
||||||
|
|
||||||
|
// Define allowed origins for CORS
|
||||||
|
const ALLOWED_ORIGINS = ['https://getmynews.app', 'https://api.getmynews.app'];
|
||||||
|
|
||||||
|
// Create the main Hono app
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// CORS middleware
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
const origin = c.req.header('Origin');
|
||||||
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||||
|
c.header('Access-Control-Allow-Origin', origin);
|
||||||
|
c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
c.header('Access-Control-Max-Age', '86400');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if (c.req.method === 'OPTIONS') {
|
||||||
|
return c.text('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create auth middleware function
|
||||||
|
const authMiddleware = async (c: Context, next: Next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||||
|
c.header('WWW-Authenticate', 'Basic realm="Admin Area"');
|
||||||
|
return c.text('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Credentials = authHeader.split(' ')[1];
|
||||||
|
const credentials = atob(base64Credentials);
|
||||||
|
const [username, password] = credentials.split(':');
|
||||||
|
|
||||||
|
// Check against environment variable
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const adminPassword = env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (username !== 'admin' || password !== adminPassword) {
|
||||||
|
c.header('WWW-Authenticate', 'Basic realm="Admin Area"');
|
||||||
|
return c.text('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply auth middleware to admin routes
|
||||||
|
app.use('/admin/*', authMiddleware);
|
||||||
|
|
||||||
|
// Also apply auth middleware to root path
|
||||||
|
app.use('/', authMiddleware);
|
||||||
|
|
||||||
|
// Route handlers
|
||||||
|
app.post('/api/inbound', handleInbound);
|
||||||
|
app.get('/rss/:feedId', handleRSS);
|
||||||
|
app.route('/admin', handleAdmin);
|
||||||
|
|
||||||
|
// Root path uses admin handler
|
||||||
|
app.route('/', handleAdmin);
|
||||||
|
|
||||||
|
// Catch-all for 404s
|
||||||
|
app.all('*', (c) => c.text('Not Found', 404));
|
||||||
|
|
||||||
|
// Export the worker handler
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,811 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { html } from 'hono/html';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData } from '../types';
|
||||||
|
|
||||||
|
// Create a Hono app for admin routes
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Schema for feed creation
|
||||||
|
const createFeedSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
language: z.string().optional().default('en')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema for feed updates
|
||||||
|
const updateFeedSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
language: z.string().optional().default('en')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin dashboard route
|
||||||
|
app.get('/', async (c) => {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
// List all feeds
|
||||||
|
const feedList = await listAllFeeds(emailStorage);
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
html`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Email to RSS Admin</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
.feed-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.feed-item {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #0070f3;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #0051a8;
|
||||||
|
}
|
||||||
|
.delete-button {
|
||||||
|
background-color: #e00;
|
||||||
|
}
|
||||||
|
.delete-button:hover {
|
||||||
|
background-color: #c00;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.modal-bg {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 90%;
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
.modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function confirmDelete(feedId) {
|
||||||
|
if (confirm('Are you sure you want to delete this feed? This action cannot be undone.')) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/admin/feeds/' + feedId + '/delete';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Email to RSS Admin</h1>
|
||||||
|
<p>Manage your email newsletter feeds</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Create New Feed</h2>
|
||||||
|
<form action="/admin/feeds" method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Feed Title</label>
|
||||||
|
<input type="text" id="title" name="title" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="language">Language</label>
|
||||||
|
<input type="text" id="language" name="language" value="en">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button">Create Feed</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Your Feeds</h2>
|
||||||
|
${feedList.length > 0 ?
|
||||||
|
html`<ul class="feed-list">
|
||||||
|
${feedList.map(feed => html`
|
||||||
|
<li class="feed-item">
|
||||||
|
<h3>${feed.title}</h3>
|
||||||
|
<p>${feed.description || 'No description'}</p>
|
||||||
|
<p><strong>Email:</strong> newsletter-${feed.id}@${env.DOMAIN}</p>
|
||||||
|
<p><strong>RSS Feed:</strong> https://api.${env.DOMAIN}/rss/${feed.id}</p>
|
||||||
|
<p>
|
||||||
|
<a href="/admin/feeds/${feed.id}/edit" class="button">Edit</a>
|
||||||
|
<a href="/admin/feeds/${feed.id}/emails" class="button">View Emails</a>
|
||||||
|
<button onclick="confirmDelete('${feed.id}')" class="button delete-button">Delete</button>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
`)}
|
||||||
|
</ul>` :
|
||||||
|
html`<p>You don't have any feeds yet. Create one above.</p>`
|
||||||
|
}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new feed
|
||||||
|
app.post('/feeds', 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';
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const parsedData = createFeedSchema.parse({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
language
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate a unique feed ID
|
||||||
|
const feedId = generateRandomId();
|
||||||
|
|
||||||
|
// Store feed configuration
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
|
await emailStorage.put(feedConfigKey, JSON.stringify({
|
||||||
|
title: parsedData.title,
|
||||||
|
description: parsedData.description,
|
||||||
|
language: parsedData.language,
|
||||||
|
site_url: `https://api.${env.DOMAIN}/rss/${feedId}`,
|
||||||
|
feed_url: `https://api.${env.DOMAIN}/rss/${feedId}`,
|
||||||
|
created_at: Date.now()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create empty metadata for the feed
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
await emailStorage.put(feedMetadataKey, JSON.stringify({
|
||||||
|
emails: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add feed to the list of all feeds
|
||||||
|
await addFeedToList(emailStorage, feedId, parsedData.title);
|
||||||
|
|
||||||
|
// Redirect back to admin page
|
||||||
|
return c.redirect('/admin');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating feed:', error);
|
||||||
|
return c.text('Error creating feed. Please try again.', 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit feed form
|
||||||
|
app.get('/feeds/:feedId/edit', async (c) => {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const feedId = c.req.param('feedId');
|
||||||
|
|
||||||
|
// Get feed configuration
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
|
const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null;
|
||||||
|
|
||||||
|
if (!feedConfig) {
|
||||||
|
return c.text('Feed not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
html`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Edit Feed - Email to RSS Admin</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #0070f3;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #0051a8;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Edit Feed</h1>
|
||||||
|
<p><a href="/admin">Back to Dashboard</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/admin/feeds/${feedId}/edit" method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Feed Title</label>
|
||||||
|
<input type="text" id="title" name="title" value="${feedConfig.title}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="3">${feedConfig.description || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="language">Language</label>
|
||||||
|
<input type="text" id="language" name="language" value="${feedConfig.language || 'en'}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button">Update Feed</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update feed
|
||||||
|
app.post('/feeds/:feedId/edit', async (c) => {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const feedId = c.req.param('feedId');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await c.req.formData();
|
||||||
|
const title = formData.get('title')?.toString() || '';
|
||||||
|
const description = formData.get('description')?.toString();
|
||||||
|
const language = formData.get('language')?.toString() || 'en';
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const parsedData = updateFeedSchema.parse({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
language
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get existing feed config
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
|
const existingConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null;
|
||||||
|
|
||||||
|
if (!existingConfig) {
|
||||||
|
return c.text('Feed not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update feed configuration
|
||||||
|
await emailStorage.put(feedConfigKey, JSON.stringify({
|
||||||
|
...existingConfig,
|
||||||
|
title: parsedData.title,
|
||||||
|
description: parsedData.description,
|
||||||
|
language: parsedData.language,
|
||||||
|
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');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating feed:', error);
|
||||||
|
return c.text('Error updating feed. Please try again.', 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete feed
|
||||||
|
app.post('/feeds/:feedId/delete', async (c) => {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const feedId = c.req.param('feedId');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get feed metadata to find all email keys
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
const feedMetadata = await emailStorage.get(feedMetadataKey, 'json') as FeedMetadata | null;
|
||||||
|
|
||||||
|
if (!feedMetadata) {
|
||||||
|
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');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting feed:', error);
|
||||||
|
return c.text('Error deleting feed. Please try again.', 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// View emails for a feed
|
||||||
|
app.get('/feeds/:feedId/emails', async (c) => {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const feedId = c.req.param('feedId');
|
||||||
|
|
||||||
|
// Get feed configuration
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
|
const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null;
|
||||||
|
|
||||||
|
if (!feedConfig) {
|
||||||
|
return c.text('Feed not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get feed metadata (list of emails)
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
const feedMetadata = (await emailStorage.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata;
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
html`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>${feedConfig.title} - Emails</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
.email-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-item {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #0070f3;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #0051a8;
|
||||||
|
}
|
||||||
|
.delete-button {
|
||||||
|
background-color: #e00;
|
||||||
|
}
|
||||||
|
.delete-button:hover {
|
||||||
|
background-color: #c00;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>${feedConfig.title} - Emails</h1>
|
||||||
|
<p><a href="/admin">Back to Dashboard</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p><strong>Email Address:</strong> newsletter-${feedId}@${env.DOMAIN}</p>
|
||||||
|
<p><strong>RSS Feed:</strong> https://api.${env.DOMAIN}/rss/${feedId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Emails (${feedMetadata.emails.length})</h2>
|
||||||
|
${feedMetadata.emails.length > 0 ?
|
||||||
|
html`<ul class="email-list">
|
||||||
|
${feedMetadata.emails.map((email: EmailMetadata) => html`
|
||||||
|
<li class="email-item">
|
||||||
|
<h3>${email.subject}</h3>
|
||||||
|
<p>Received: ${new Date(email.receivedAt).toLocaleString()}</p>
|
||||||
|
<p>
|
||||||
|
<a href="/admin/emails/${email.key}" class="button">View Content</a>
|
||||||
|
<button onclick="confirmDeleteEmail('${email.key}', '${feedId}')" class="button delete-button">Delete</button>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
`)}
|
||||||
|
</ul>` :
|
||||||
|
html`<p>No emails received yet. Subscribe to newsletters using the email address above.</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function confirmDeleteEmail(emailKey, feedId) {
|
||||||
|
if (confirm('Are you sure you want to delete this email? This action cannot be undone.')) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/admin/emails/' + emailKey + '/delete';
|
||||||
|
form.innerHTML = '<input type="hidden" name="feedId" value="' + feedId + '">';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// View email content
|
||||||
|
app.get('/emails/:emailKey', async (c) => {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const emailKey = c.req.param('emailKey');
|
||||||
|
|
||||||
|
// Get email content
|
||||||
|
const emailData = await emailStorage.get(emailKey, 'json') as EmailData | null;
|
||||||
|
|
||||||
|
if (!emailData) {
|
||||||
|
return c.text('Email not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract feed ID from the key
|
||||||
|
const feedId = emailKey.split(':')[1];
|
||||||
|
|
||||||
|
// Create a sanitized HTML content with CSS for the iframe
|
||||||
|
const htmlContent = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0070f3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${emailData.content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Properly encode the HTML content to handle Unicode characters
|
||||||
|
const encodedHtmlContent = (() => {
|
||||||
|
// Convert the string to UTF-8
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(htmlContent);
|
||||||
|
// Convert bytes to base64
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
html`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>${emailData.subject}</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
.email-content {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.email-meta {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
.email-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 800px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.email-raw {
|
||||||
|
padding: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.toggle-view {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.toggle-button {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-button.active {
|
||||||
|
background-color: #0070f3;
|
||||||
|
color: white;
|
||||||
|
border-color: #0070f3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function toggleView(view) {
|
||||||
|
const renderedView = document.getElementById('rendered-view');
|
||||||
|
const rawView = document.getElementById('raw-view');
|
||||||
|
const renderedButton = document.getElementById('rendered-button');
|
||||||
|
const rawButton = document.getElementById('raw-button');
|
||||||
|
|
||||||
|
if (view === 'rendered') {
|
||||||
|
renderedView.style.display = 'block';
|
||||||
|
rawView.style.display = 'none';
|
||||||
|
renderedButton.classList.add('active');
|
||||||
|
rawButton.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
renderedView.style.display = 'none';
|
||||||
|
rawView.style.display = 'block';
|
||||||
|
rawButton.classList.add('active');
|
||||||
|
renderedButton.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>${emailData.subject}</h1>
|
||||||
|
<p><a href="/admin/feeds/${feedId}/emails">Back to Emails</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-meta">
|
||||||
|
<p><strong>From:</strong> ${emailData.from}</p>
|
||||||
|
<p><strong>Received:</strong> ${new Date(emailData.receivedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-view">
|
||||||
|
<button id="rendered-button" class="toggle-button active" onclick="toggleView('rendered')">Rendered View</button>
|
||||||
|
<button id="raw-button" class="toggle-button" onclick="toggleView('raw')">Raw HTML</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-content">
|
||||||
|
<div id="rendered-view">
|
||||||
|
<iframe
|
||||||
|
class="email-iframe"
|
||||||
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
src="data:text/html;base64,${encodedHtmlContent}"
|
||||||
|
title="Email Content"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="raw-view" class="email-raw" style="display: none;">
|
||||||
|
${emailData.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete an email
|
||||||
|
app.post('/emails/:emailKey/delete', async (c) => {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const emailKey = c.req.param('emailKey');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the feed ID from form data
|
||||||
|
const formData = await c.req.formData();
|
||||||
|
const feedId = formData.get('feedId')?.toString() || '';
|
||||||
|
|
||||||
|
if (!feedId) {
|
||||||
|
return c.text('Missing feed ID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the email from KV storage
|
||||||
|
await emailStorage.delete(emailKey);
|
||||||
|
|
||||||
|
// Remove the email from the feed metadata
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
const feedMetadata = (await emailStorage.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata;
|
||||||
|
|
||||||
|
// Filter out the deleted email
|
||||||
|
feedMetadata.emails = feedMetadata.emails.filter(email => email.key !== emailKey);
|
||||||
|
|
||||||
|
// Update the feed metadata
|
||||||
|
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||||
|
|
||||||
|
// Redirect back to the emails list
|
||||||
|
return c.redirect(`/admin/feeds/${feedId}/emails`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting email:', error);
|
||||||
|
return c.text('Error deleting email. Please try again.', 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to generate a random feed ID
|
||||||
|
function generateRandomId(length = 8): string {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to list all feeds
|
||||||
|
async function listAllFeeds(emailStorage: KVNamespace): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const feedListKey = 'feeds:list';
|
||||||
|
const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] };
|
||||||
|
|
||||||
|
// Fetch detailed information for each feed
|
||||||
|
const feeds = [];
|
||||||
|
for (const feed of feedList.feeds) {
|
||||||
|
const feedConfigKey = `feed:${feed.id}:config`;
|
||||||
|
const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null;
|
||||||
|
|
||||||
|
if (feedConfig) {
|
||||||
|
feeds.push({
|
||||||
|
id: feed.id,
|
||||||
|
title: feedConfig.title,
|
||||||
|
description: feedConfig.description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return feeds;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing feeds:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to add a feed to the list of all feeds
|
||||||
|
async function addFeedToList(emailStorage: KVNamespace, feedId: string, title: string): Promise<void> {
|
||||||
|
const feedListKey = 'feeds:list';
|
||||||
|
const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] };
|
||||||
|
|
||||||
|
feedList.feeds.push({
|
||||||
|
id: feedId,
|
||||||
|
title
|
||||||
|
});
|
||||||
|
|
||||||
|
await emailStorage.put(feedListKey, JSON.stringify(feedList));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to update a feed in the list of all feeds
|
||||||
|
async function updateFeedInList(emailStorage: KVNamespace, feedId: string, title: string): Promise<void> {
|
||||||
|
const feedListKey = 'feeds:list';
|
||||||
|
const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] };
|
||||||
|
|
||||||
|
const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId);
|
||||||
|
|
||||||
|
if (feedIndex >= 0) {
|
||||||
|
feedList.feeds[feedIndex].title = title;
|
||||||
|
await emailStorage.put(feedListKey, JSON.stringify(feedList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to remove a feed from the list of all feeds
|
||||||
|
async function removeFeedFromList(emailStorage: KVNamespace, feedId: string): Promise<void> {
|
||||||
|
const feedListKey = 'feeds:list';
|
||||||
|
const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] };
|
||||||
|
|
||||||
|
feedList.feeds = feedList.feeds.filter((feed) => feed.id !== feedId);
|
||||||
|
|
||||||
|
await emailStorage.put(feedListKey, JSON.stringify(feedList));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the Hono app
|
||||||
|
export const handle = app;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { EmailParser } from '../utils/email-parser';
|
||||||
|
import { Env, FeedMetadata } from '../types';
|
||||||
|
|
||||||
|
// Interface for ForwardEmail.net webhook payload
|
||||||
|
interface ForwardEmailPayload {
|
||||||
|
recipients?: string[];
|
||||||
|
from?: {
|
||||||
|
value?: Array<{address?: string; name?: string}>;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
};
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
date?: string;
|
||||||
|
messageId?: string;
|
||||||
|
headerLines?: Array<{key: string; line: string}>;
|
||||||
|
headers?: string;
|
||||||
|
raw?: string;
|
||||||
|
attachments?: Array<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming emails from ForwardEmail.net webhook
|
||||||
|
*/
|
||||||
|
export async function handle(c: Context): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
|
||||||
|
// Parse the incoming JSON payload
|
||||||
|
const payload: ForwardEmailPayload = await c.req.json();
|
||||||
|
|
||||||
|
// Log basic information about the incoming email
|
||||||
|
console.log("Received email:", {
|
||||||
|
to: payload.recipients?.[0],
|
||||||
|
from: payload.from?.text || 'Unknown',
|
||||||
|
subject: payload.subject,
|
||||||
|
contentType: payload.html ? 'HTML' : 'Text'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract feed ID from email address (e.g., newsletter-xyz@domain.com -> xyz)
|
||||||
|
const toAddress = payload.recipients?.[0] || '';
|
||||||
|
const feedId = EmailParser.extractFeedId(toAddress);
|
||||||
|
|
||||||
|
if (!feedId) {
|
||||||
|
console.error(`Invalid email address format: ${toAddress}`);
|
||||||
|
return new Response('Invalid email address format', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the email using our simplified parser
|
||||||
|
const emailData = EmailParser.parseForwardEmailPayload(payload);
|
||||||
|
|
||||||
|
// Generate a unique key for this email in KV storage
|
||||||
|
const emailKey = `feed:${feedId}:${Date.now()}`;
|
||||||
|
|
||||||
|
// Store the email data in KV
|
||||||
|
await env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData));
|
||||||
|
|
||||||
|
// Get existing feed metadata
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
const feedMetadata = (await env.EMAIL_STORAGE.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata;
|
||||||
|
|
||||||
|
// Add this email to the feed metadata
|
||||||
|
feedMetadata.emails.unshift({
|
||||||
|
key: emailKey,
|
||||||
|
subject: emailData.subject,
|
||||||
|
receivedAt: emailData.receivedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store updated feed metadata
|
||||||
|
await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||||
|
|
||||||
|
console.log(`Successfully processed email for feed ${feedId}`);
|
||||||
|
return new Response('Email processed successfully', { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing email:', error);
|
||||||
|
return new Response('Error processing email', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { Env, FeedConfig, FeedMetadata, EmailData } from '../types';
|
||||||
|
import { generateRssFeed } from '../utils/feed-generator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an RSS feed for a specific feed ID
|
||||||
|
*/
|
||||||
|
export async function handle(c: Context): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Type assertion for environment variables
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
|
||||||
|
// Extract the feed ID from the route params
|
||||||
|
const feedId = c.req.param('feedId');
|
||||||
|
|
||||||
|
if (!feedId) {
|
||||||
|
return new Response('Feed ID is required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the KV namespace
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
// Check if the feed exists
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
const feedMetadata = await emailStorage.get(feedMetadataKey, 'json') as FeedMetadata | null;
|
||||||
|
|
||||||
|
if (!feedMetadata) {
|
||||||
|
return new Response('Feed not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get feed configuration (title, description, etc.)
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
|
const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null || {
|
||||||
|
title: `Newsletter Feed ${feedId}`,
|
||||||
|
description: 'Converted email newsletter',
|
||||||
|
site_url: `https://api.${env.DOMAIN}/rss/${feedId}`,
|
||||||
|
feed_url: `https://api.${env.DOMAIN}/rss/${feedId}`,
|
||||||
|
language: 'en',
|
||||||
|
created_at: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the emails for this feed (up to the last 20)
|
||||||
|
const emails = feedMetadata.emails.slice(0, 20);
|
||||||
|
const emailsData: EmailData[] = [];
|
||||||
|
|
||||||
|
// Fetch all email content
|
||||||
|
for (const email of emails) {
|
||||||
|
const emailData = await emailStorage.get(email.key, 'json') as EmailData | null;
|
||||||
|
if (emailData) {
|
||||||
|
emailsData.push(emailData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the RSS feed XML
|
||||||
|
const baseUrl = `https://api.${env.DOMAIN}`;
|
||||||
|
const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl);
|
||||||
|
|
||||||
|
// Return the RSS feed with appropriate content type
|
||||||
|
return new Response(rssXml, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/rss+xml',
|
||||||
|
'Cache-Control': 'max-age=1800' // 30 minutes cache
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating RSS feed:', error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import { Env } from './index';
|
||||||
|
|
||||||
|
// Extend Hono's types to include our custom environment
|
||||||
|
declare module 'hono' {
|
||||||
|
interface ContextVariableMap {
|
||||||
|
env: Env;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Global environment interface for Cloudflare Workers
|
||||||
|
export interface Env {
|
||||||
|
EMAIL_STORAGE: KVNamespace;
|
||||||
|
ADMIN_PASSWORD: string;
|
||||||
|
DOMAIN: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email interface for stored emails
|
||||||
|
export interface EmailData {
|
||||||
|
subject: string;
|
||||||
|
from: string;
|
||||||
|
content: string;
|
||||||
|
receivedAt: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed configuration interface
|
||||||
|
export interface FeedConfig {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
language: string;
|
||||||
|
site_url: string;
|
||||||
|
feed_url: string;
|
||||||
|
author?: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed metadata interface
|
||||||
|
export interface FeedMetadata {
|
||||||
|
emails: EmailMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email metadata interface (summary info for listing)
|
||||||
|
export interface EmailMetadata {
|
||||||
|
key: string;
|
||||||
|
subject: string;
|
||||||
|
receivedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed list interface
|
||||||
|
export interface FeedList {
|
||||||
|
feeds: FeedListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed summary interface (for the global feed list)
|
||||||
|
export interface FeedListItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare KVNamespace for TypeScript
|
||||||
|
declare global {
|
||||||
|
// This is not an ideal solution but works for our example
|
||||||
|
interface KVNamespace {
|
||||||
|
get(key: string, options?: { type: 'text' }): Promise<string | null>;
|
||||||
|
get(key: string, options: { type: 'json' }): Promise<any | null>;
|
||||||
|
get(key: string, 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>;
|
||||||
|
list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{
|
||||||
|
keys: { name: string; expiration?: number }[];
|
||||||
|
list_complete: boolean;
|
||||||
|
cursor?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
// Extend mailparser types for Buffer in worker environment
|
||||||
|
declare module 'buffer-polyfill' {
|
||||||
|
global {
|
||||||
|
var Buffer: {
|
||||||
|
from(data: string, encoding?: string): {
|
||||||
|
toString(encoding?: string): string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add missing atob declaration
|
||||||
|
declare module 'atob-polyfill' {
|
||||||
|
global {
|
||||||
|
function atob(data: string): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+51
@@ -0,0 +1,51 @@
|
|||||||
|
declare module 'rss' {
|
||||||
|
interface RSSOptions {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
feed_url: string;
|
||||||
|
site_url: string;
|
||||||
|
language?: string;
|
||||||
|
image_url?: string;
|
||||||
|
docs?: string;
|
||||||
|
managingEditor?: string;
|
||||||
|
webMaster?: string;
|
||||||
|
copyright?: string;
|
||||||
|
pubDate?: Date;
|
||||||
|
ttl?: number;
|
||||||
|
generator?: string;
|
||||||
|
categories?: string[];
|
||||||
|
custom_namespaces?: Record<string, string>;
|
||||||
|
custom_elements?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RSSItemOptions {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
guid?: string;
|
||||||
|
categories?: string[];
|
||||||
|
author?: string;
|
||||||
|
date?: Date;
|
||||||
|
lat?: number;
|
||||||
|
long?: number;
|
||||||
|
enclosure?: {
|
||||||
|
url: string;
|
||||||
|
file?: string;
|
||||||
|
size?: number;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
custom_elements?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RSSXMLOptions {
|
||||||
|
indent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RSS {
|
||||||
|
constructor(options: RSSOptions);
|
||||||
|
item(options: RSSItemOptions): void;
|
||||||
|
xml(options?: RSSXMLOptions): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export = RSS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { EmailData } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple email parser specialized for ForwardEmail.net's webhook format
|
||||||
|
*/
|
||||||
|
export class EmailParser {
|
||||||
|
/**
|
||||||
|
* Extract the feed ID from an email address
|
||||||
|
* @param emailAddress The email address (e.g., newsletter-xyz@domain.com)
|
||||||
|
* @returns The feed ID or null if not found
|
||||||
|
*/
|
||||||
|
static extractFeedId(emailAddress: string): string | null {
|
||||||
|
const match = emailAddress.match(/^newsletter-([a-zA-Z0-9]+)@/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse email data from ForwardEmail.net's webhook payload
|
||||||
|
* @param payload ForwardEmail.net webhook payload
|
||||||
|
*/
|
||||||
|
static parseForwardEmailPayload(payload: any): EmailData {
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Missing or invalid webhook payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the "to" address
|
||||||
|
const toAddress = payload.recipients?.[0] || '';
|
||||||
|
|
||||||
|
// Extract the sender information using ForwardEmail's structure
|
||||||
|
const fromAddress = payload.from?.text ||
|
||||||
|
(payload.from?.value?.[0]?.address ?
|
||||||
|
`${payload.from.value[0].name || ''} <${payload.from.value[0].address}>` :
|
||||||
|
'Unknown Sender');
|
||||||
|
|
||||||
|
// Extract subject
|
||||||
|
let subject = payload.subject || 'No Subject';
|
||||||
|
// Decode any encoded words in the subject
|
||||||
|
subject = this.decodeEncodedWords(subject);
|
||||||
|
|
||||||
|
// Get content, preferring HTML over plain text
|
||||||
|
const content = payload.html || payload.text || '';
|
||||||
|
|
||||||
|
// Create simple email data object
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
from: fromAddress,
|
||||||
|
content,
|
||||||
|
receivedAt: payload.date ? new Date(payload.date).getTime() : Date.now(),
|
||||||
|
headers: this.extractHeaders(payload)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract headers from ForwardEmail payload
|
||||||
|
*/
|
||||||
|
private static extractHeaders(payload: any): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Extract headers from headerLines if available
|
||||||
|
if (payload.headerLines && Array.isArray(payload.headerLines)) {
|
||||||
|
payload.headerLines.forEach((h: {key: string; line: string}) => {
|
||||||
|
const key = h.key.toLowerCase();
|
||||||
|
const value = h.line.replace(new RegExp(`^${h.key}:\\s*`, 'i'), '').trim();
|
||||||
|
headers[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Or from headers string if provided
|
||||||
|
else if (typeof payload.headers === 'string') {
|
||||||
|
payload.headers.split(/\r?\n/).forEach((line: string) => {
|
||||||
|
const match = line.match(/^([^:]+):\s*(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
headers[match[1].toLowerCase()] = match[2];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode RFC 2047 encoded words in headers
|
||||||
|
* @param text Text that may contain encoded words like =?UTF-8?Q?Hello_World?=
|
||||||
|
*/
|
||||||
|
static decodeEncodedWords(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Simple RFC 2047 encoded-word decoder
|
||||||
|
return text.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (_, charset, encoding, text) => {
|
||||||
|
if (encoding.toUpperCase() === 'B') {
|
||||||
|
// Base64 encoding
|
||||||
|
try {
|
||||||
|
const decoded = atob(text);
|
||||||
|
return decoded;
|
||||||
|
} catch (e) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
} else if (encoding.toUpperCase() === 'Q') {
|
||||||
|
// Quoted-printable encoding
|
||||||
|
return this.decodeQuotedPrintable(text.replace(/_/g, ' '));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode quoted-printable encoded text
|
||||||
|
* @param text Quoted-printable encoded text
|
||||||
|
*/
|
||||||
|
private static decodeQuotedPrintable(text: string): string {
|
||||||
|
return text.replace(/=([0-9A-F]{2})/gi, (_, hex) => {
|
||||||
|
return String.fromCharCode(parseInt(hex, 16));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Feed } from 'feed';
|
||||||
|
import { FeedConfig, EmailData } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an RSS feed from a list of emails
|
||||||
|
*/
|
||||||
|
export function generateRssFeed(
|
||||||
|
feedConfig: FeedConfig,
|
||||||
|
emails: EmailData[],
|
||||||
|
baseUrl: string
|
||||||
|
): string {
|
||||||
|
// Create a new feed
|
||||||
|
const feed = new Feed({
|
||||||
|
title: feedConfig.title,
|
||||||
|
description: feedConfig.description || '',
|
||||||
|
id: feedConfig.feed_url,
|
||||||
|
link: feedConfig.site_url,
|
||||||
|
language: feedConfig.language,
|
||||||
|
updated: new Date(),
|
||||||
|
generator: 'Email-to-RSS',
|
||||||
|
copyright: `Copyright © ${new Date().getFullYear()} ${feedConfig.title}`,
|
||||||
|
feedLinks: {
|
||||||
|
rss: feedConfig.feed_url
|
||||||
|
},
|
||||||
|
author: feedConfig.author ? {
|
||||||
|
name: feedConfig.author,
|
||||||
|
email: `noreply@${new URL(feedConfig.site_url).hostname}`
|
||||||
|
} : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add each email as a feed item
|
||||||
|
for (const email of emails) {
|
||||||
|
const date = new Date(email.receivedAt);
|
||||||
|
const uniqueId = `${email.receivedAt}-${Buffer.from(email.subject).toString('base64').substring(0, 10)}`;
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: email.subject,
|
||||||
|
id: uniqueId,
|
||||||
|
link: `${baseUrl}/emails/${uniqueId}`,
|
||||||
|
description: email.content,
|
||||||
|
content: email.content,
|
||||||
|
author: [
|
||||||
|
{
|
||||||
|
name: email.from,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
date: date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the RSS feed as XML
|
||||||
|
return feed.rss2();
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { EmailData, FeedConfig, FeedMetadata, FeedList, EmailMetadata } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store email data in KV
|
||||||
|
*/
|
||||||
|
export async function storeEmail(
|
||||||
|
kv: KVNamespace,
|
||||||
|
feedId: string,
|
||||||
|
emailData: EmailData
|
||||||
|
): Promise<string> {
|
||||||
|
// Generate a unique key for this email
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const key = `feed:${feedId}:email:${timestamp}`;
|
||||||
|
|
||||||
|
// Store the email content
|
||||||
|
await kv.put(key, JSON.stringify(emailData));
|
||||||
|
|
||||||
|
// Update the feed's metadata (list of emails)
|
||||||
|
await updateFeedMetadata(kv, feedId, {
|
||||||
|
key,
|
||||||
|
subject: emailData.subject,
|
||||||
|
receivedAt: timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update feed metadata with a new email
|
||||||
|
*/
|
||||||
|
async function updateFeedMetadata(
|
||||||
|
kv: KVNamespace,
|
||||||
|
feedId: string,
|
||||||
|
emailMetadata: EmailMetadata
|
||||||
|
): Promise<void> {
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
const existingMetadata = await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null;
|
||||||
|
|
||||||
|
const metadata: FeedMetadata = existingMetadata || { emails: [] };
|
||||||
|
|
||||||
|
// Add new email to the beginning of the list
|
||||||
|
metadata.emails.unshift(emailMetadata);
|
||||||
|
|
||||||
|
// Keep only the last 50 emails in the metadata
|
||||||
|
if (metadata.emails.length > 50) {
|
||||||
|
metadata.emails = metadata.emails.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store updated metadata
|
||||||
|
await kv.put(feedMetadataKey, JSON.stringify(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feed metadata
|
||||||
|
*/
|
||||||
|
export async function getFeedMetadata(
|
||||||
|
kv: KVNamespace,
|
||||||
|
feedId: string
|
||||||
|
): Promise<FeedMetadata | null> {
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
return await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feed configuration
|
||||||
|
*/
|
||||||
|
export async function getFeedConfig(
|
||||||
|
kv: KVNamespace,
|
||||||
|
feedId: string
|
||||||
|
): Promise<FeedConfig | null> {
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
|
return await kv.get(feedConfigKey, { type: 'json' }) as FeedConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get email data
|
||||||
|
*/
|
||||||
|
export async function getEmailData(
|
||||||
|
kv: KVNamespace,
|
||||||
|
key: string
|
||||||
|
): Promise<EmailData | null> {
|
||||||
|
return await kv.get(key, { type: 'json' }) as EmailData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new feed
|
||||||
|
*/
|
||||||
|
export async function createFeed(
|
||||||
|
kv: KVNamespace,
|
||||||
|
feedId: string,
|
||||||
|
feedConfig: FeedConfig
|
||||||
|
): Promise<void> {
|
||||||
|
// Store feed configuration
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
|
await kv.put(feedConfigKey, JSON.stringify(feedConfig));
|
||||||
|
|
||||||
|
// Create empty metadata for the feed
|
||||||
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
|
await kv.put(feedMetadataKey, JSON.stringify({
|
||||||
|
emails: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add feed to the list of all feeds
|
||||||
|
await addFeedToList(kv, feedId, feedConfig.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a feed to the global list
|
||||||
|
*/
|
||||||
|
export async function addFeedToList(
|
||||||
|
kv: KVNamespace,
|
||||||
|
feedId: string,
|
||||||
|
title: string
|
||||||
|
): Promise<void> {
|
||||||
|
const feedListKey = 'feeds:list';
|
||||||
|
const existingList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null;
|
||||||
|
|
||||||
|
const feedList: FeedList = existingList || { feeds: [] };
|
||||||
|
|
||||||
|
feedList.feeds.push({
|
||||||
|
id: feedId,
|
||||||
|
title
|
||||||
|
});
|
||||||
|
|
||||||
|
await kv.put(feedListKey, JSON.stringify(feedList));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all feeds
|
||||||
|
*/
|
||||||
|
export async function getAllFeeds(kv: KVNamespace): Promise<FeedList> {
|
||||||
|
const feedListKey = 'feeds:list';
|
||||||
|
const feedList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null;
|
||||||
|
|
||||||
|
return feedList || { feeds: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name = "email-to-rss"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2024-09-23"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
# KV Namespace bindings
|
||||||
|
kv_namespaces = [
|
||||||
|
{ binding = "EMAIL_STORAGE", id = "721e2789af9a41eba56a77e7891fd85a", preview_id = "b741d3713dd0416ca80b34ae6539736e" }
|
||||||
|
]
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
[vars]
|
||||||
|
ADMIN_PASSWORD = "" # Set this using wrangler secret
|
||||||
|
DOMAIN = "getmynews.app" # Your custom domain for emails
|
||||||
|
|
||||||
|
# Development environment
|
||||||
|
[env.dev]
|
||||||
|
# Add any development-specific configuration here
|
||||||
|
workers_dev = true
|
||||||
|
|
||||||
|
# Production environment
|
||||||
|
[env.production]
|
||||||
|
# Add any production-specific configuration here
|
||||||
|
workers_dev = false
|
||||||
|
routes = [
|
||||||
|
"https://api.getmynews.app/api/*",
|
||||||
|
"https://api.getmynews.app/rss/*",
|
||||||
|
"https://getmynews.app/*",
|
||||||
|
"https://www.getmynews.app/*"
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user