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
+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: [] };
}