Set up initial project and files

This commit is contained in:
Young Lee
2025-02-27 14:51:38 -08:00
parent be9d1c0f61
commit 8839aac24b
18 changed files with 1754 additions and 0 deletions
@@ -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 domains 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.nets 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.nets 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
View File
@@ -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
+81
View File
@@ -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
+27
View File
@@ -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"
}
}
Executable
+29
View File
@@ -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'"
+75
View File
@@ -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;
+811
View File
@@ -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;
+81
View File
@@ -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 });
}
}
+70
View File
@@ -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 });
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Env } from './index';
// Extend Hono's types to include our custom environment
declare module 'hono' {
interface ContextVariableMap {
env: Env;
}
}
+68
View File
@@ -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;
}>;
}
}
+17
View File
@@ -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;
}
}
+51
View File
@@ -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;
}
+114
View File
@@ -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));
});
}
}
+53
View File
@@ -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();
}
+136
View File
@@ -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: [] };
}
+18
View File
@@ -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"]
}
+30
View File
@@ -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/*"
]