mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
f4e751e40b
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1281 lines
50 KiB
HTML
1281 lines
50 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>kill-the-news — Private newsletter feeds on Cloudflare Workers</title>
|
||
<meta name="description" content="Convert email newsletters into private RSS feeds using Cloudflare Workers. Self-hosted, free tier, your own domain." />
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="32x32" />
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #0a0a0a;
|
||
--surface: #111111;
|
||
--border: #222222;
|
||
--text: #f0f0f0;
|
||
--muted: #888888;
|
||
--accent: #f6821f;
|
||
--accent-dim: rgba(246,130,31,0.12);
|
||
--radius: 10px;
|
||
}
|
||
|
||
html { scroll-behavior: smooth; }
|
||
|
||
body {
|
||
font-family: 'Inter', system-ui, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
line-height: 1.6;
|
||
font-size: 16px;
|
||
}
|
||
|
||
a { color: inherit; text-decoration: none; }
|
||
|
||
/* ── Nav ── */
|
||
nav {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 2rem;
|
||
height: 60px;
|
||
background: rgba(10,10,10,0.85);
|
||
backdrop-filter: blur(12px);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.nav-logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-weight: 600;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.nav-logo svg { color: var(--accent); }
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.45rem 1rem;
|
||
border-radius: 6px;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: opacity 0.15s, background 0.15s;
|
||
border: none;
|
||
}
|
||
|
||
.btn-outline {
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
}
|
||
.btn-outline:hover { background: var(--surface); }
|
||
|
||
.btn-primary {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
}
|
||
.btn-primary:hover { opacity: 0.88; }
|
||
|
||
/* ── Hero ── */
|
||
.hero {
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 6rem 2rem 5rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.hero-glow {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(246,130,31,0.18) 0%, transparent 70%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
padding: 0.3rem 0.75rem;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(246,130,31,0.35);
|
||
background: var(--accent-dim);
|
||
color: var(--accent);
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.hero h1 {
|
||
font-size: clamp(2.2rem, 5vw, 3.5rem);
|
||
font-weight: 700;
|
||
line-height: 1.15;
|
||
letter-spacing: -0.02em;
|
||
max-width: 720px;
|
||
margin: 0 auto 1.25rem;
|
||
}
|
||
|
||
.hero h1 span { color: var(--accent); }
|
||
|
||
.hero p {
|
||
font-size: 1.1rem;
|
||
color: var(--muted);
|
||
max-width: 520px;
|
||
margin: 0 auto 2.5rem;
|
||
}
|
||
|
||
.hero-ctas {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* ── Section layout ── */
|
||
section {
|
||
padding: 5rem 2rem;
|
||
max-width: 1100px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.section-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--accent);
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: clamp(1.6rem, 3vw, 2.2rem);
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.section-sub {
|
||
color: var(--muted);
|
||
font-size: 1rem;
|
||
max-width: 520px;
|
||
}
|
||
|
||
.section-header { margin-bottom: 3rem; }
|
||
|
||
/* ── FAQ ── */
|
||
.faq-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||
.faq-item {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
transition: border-color 0.2s;
|
||
}
|
||
.faq-item[open] { border-color: rgba(246,130,31,0.4); }
|
||
.faq-item summary {
|
||
cursor: pointer;
|
||
list-style: none;
|
||
padding: 1.1rem 1.35rem;
|
||
font-weight: 600;
|
||
font-size: 0.95rem;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
.faq-item summary::-webkit-details-marker { display: none; }
|
||
.faq-item summary::after { content: "+"; color: var(--accent); font-size: 1.2rem; line-height: 1; }
|
||
.faq-item[open] summary::after { content: "−"; }
|
||
.faq-item p {
|
||
margin: 0;
|
||
padding: 0 1.35rem 1.2rem;
|
||
color: var(--muted);
|
||
font-size: 0.9rem;
|
||
line-height: 1.65;
|
||
}
|
||
.faq-item a { color: var(--accent); }
|
||
|
||
/* ── Features ── */
|
||
.features-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||
gap: 1.25rem;
|
||
}
|
||
|
||
.feature-card {
|
||
padding: 1.5rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
transition: border-color 0.2s;
|
||
}
|
||
.feature-card:hover { border-color: rgba(246,130,31,0.4); }
|
||
|
||
.feature-icon {
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 8px;
|
||
background: var(--accent-dim);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--accent);
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.feature-card h3 {
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
|
||
.feature-card p {
|
||
font-size: 0.875rem;
|
||
color: var(--muted);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* ── How it works ── */
|
||
#how-it-works { background: var(--surface); max-width: none; padding: 5rem 2rem; }
|
||
#how-it-works .inner { max-width: 1100px; margin: 0 auto; }
|
||
|
||
.steps {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 0;
|
||
margin-top: 3rem;
|
||
position: relative;
|
||
}
|
||
|
||
.step {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
padding: 2rem 2rem 2rem 0;
|
||
position: relative;
|
||
}
|
||
|
||
.step:not(:last-child)::after {
|
||
content: '';
|
||
position: absolute;
|
||
right: 0;
|
||
top: 2.75rem;
|
||
width: 1px;
|
||
height: calc(100% - 5.5rem);
|
||
background: var(--border);
|
||
}
|
||
|
||
.step-num {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-weight: 700;
|
||
font-size: 0.95rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 1rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.step h3 {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
|
||
.step p {
|
||
font-size: 0.875rem;
|
||
color: var(--muted);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* ── Quick Start ── */
|
||
#quick-start {}
|
||
|
||
.code-block {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.code-block-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.code-block-header span {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
.dot-r { background: #ff5f57; }
|
||
.dot-y { background: #febc2e; }
|
||
.dot-g { background: #28c840; }
|
||
|
||
.code-block pre {
|
||
padding: 1.25rem 1.5rem;
|
||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||
font-size: 0.85rem;
|
||
line-height: 1.75;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.code-block pre .prompt { color: var(--muted); user-select: none; }
|
||
.code-block pre .cmd { color: var(--text); }
|
||
.code-block pre .comment { color: #555; }
|
||
|
||
.ingestion-options {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 1rem;
|
||
margin-top: 2rem;
|
||
}
|
||
|
||
.ingestion-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1.25rem 1.5rem;
|
||
background: var(--surface);
|
||
}
|
||
|
||
.ingestion-card.recommended { border-color: rgba(246,130,31,0.4); }
|
||
|
||
.ingestion-card .tag {
|
||
display: inline-block;
|
||
padding: 0.2rem 0.55rem;
|
||
border-radius: 4px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.tag-recommended { background: var(--accent-dim); color: var(--accent); }
|
||
.tag-alt { background: rgba(255,255,255,0.06); color: var(--muted); }
|
||
|
||
.ingestion-card h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.4rem; }
|
||
.ingestion-card p { font-size: 0.85rem; color: var(--muted); line-height: 1.6; }
|
||
|
||
/* ── Tech stack ── */
|
||
.tech-pills {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.65rem;
|
||
margin-top: 2rem;
|
||
}
|
||
|
||
.pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.4rem 0.9rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
font-size: 0.85rem;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ── Footer ── */
|
||
footer {
|
||
border-top: 1px solid var(--border);
|
||
padding: 2.5rem 2rem;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
footer a { color: var(--muted); border-bottom: 1px solid var(--border); transition: color 0.15s; }
|
||
footer a:hover { color: var(--text); }
|
||
footer .sep { margin: 0 0.5rem; }
|
||
|
||
/* ── Demo banner ── */
|
||
.demo-banner {
|
||
background: linear-gradient(135deg, rgba(246,130,31,0.08) 0%, rgba(246,130,31,0.03) 100%);
|
||
border-top: 1px solid rgba(246,130,31,0.2);
|
||
border-bottom: 1px solid rgba(246,130,31,0.2);
|
||
padding: 3.5rem 2rem;
|
||
}
|
||
|
||
.demo-banner .inner {
|
||
max-width: 1100px;
|
||
margin: 0 auto;
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 2.5rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.demo-banner h2 {
|
||
font-size: clamp(1.3rem, 2.5vw, 1.75rem);
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
|
||
.demo-banner h2 span { color: var(--accent); }
|
||
|
||
.demo-banner p {
|
||
font-size: 0.95rem;
|
||
color: var(--muted);
|
||
max-width: 540px;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.demo-creds {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
margin-top: 1rem;
|
||
padding: 0.6rem 1rem;
|
||
background: rgba(0,0,0,0.3);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 0.82rem;
|
||
}
|
||
|
||
.demo-creds .cred-item { color: var(--muted); }
|
||
.demo-creds .cred-item strong { color: var(--text); font-weight: 500; }
|
||
.demo-creds .cred-sep { color: #333; }
|
||
|
||
.demo-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.65rem;
|
||
align-items: flex-end;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.demo-note {
|
||
font-size: 0.75rem;
|
||
color: #555;
|
||
text-align: center;
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
.demo-banner .inner { grid-template-columns: 1fr; }
|
||
.demo-actions { align-items: flex-start; flex-direction: row; flex-wrap: wrap; }
|
||
.demo-note { text-align: left; }
|
||
}
|
||
|
||
/* ── Live stats ── */
|
||
.stats-section { padding: 4rem 2rem; }
|
||
.stats-inner { max-width: 1100px; margin: 0 auto; text-align: center; }
|
||
.stats-live {
|
||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||
font-size: 0.78rem; color: var(--muted);
|
||
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 2rem;
|
||
}
|
||
.stats-dot {
|
||
width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
|
||
animation: stats-pulse 2s infinite;
|
||
}
|
||
@keyframes stats-pulse {
|
||
0% { box-shadow: 0 0 0 0 rgba(246,130,31,0.5); }
|
||
70% { box-shadow: 0 0 0 8px rgba(246,130,31,0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(246,130,31,0); }
|
||
}
|
||
.stats-grid {
|
||
display: grid; grid-template-columns: repeat(2, 1fr);
|
||
gap: 2rem; max-width: 600px; margin: 0 auto;
|
||
}
|
||
.stat-num {
|
||
font-size: clamp(2.5rem, 6vw, 4rem); font-weight: 700;
|
||
color: var(--accent); letter-spacing: -0.03em;
|
||
font-variant-numeric: tabular-nums; line-height: 1;
|
||
}
|
||
.stat-label { font-size: 0.9rem; color: var(--muted); margin-top: 0.6rem; }
|
||
@media (max-width: 600px) { .stats-grid { gap: 1.5rem; } }
|
||
|
||
/* ── Nav links ── */
|
||
nav-links {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
.nav-link {
|
||
padding: 0.35rem 0.75rem;
|
||
border-radius: 6px;
|
||
font-size: 0.875rem;
|
||
color: var(--muted);
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
.nav-link:hover { color: var(--text); background: var(--surface); }
|
||
.nav-link-sponsor { color: #db61a2; display: inline-flex; align-items: center; gap: 0.3rem; }
|
||
.nav-link-sponsor:hover { color: #e879b8; background: rgba(219,97,162,0.08); }
|
||
@media (max-width: 600px) { nav-links { display: none; } }
|
||
|
||
/* ── Install section ── */
|
||
.install-steps {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
counter-reset: install-step;
|
||
}
|
||
|
||
.install-step {
|
||
display: grid;
|
||
grid-template-columns: 48px minmax(0, 1fr);
|
||
gap: 1.25rem;
|
||
align-items: start;
|
||
}
|
||
|
||
.install-step-num {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: var(--accent-dim);
|
||
border: 1px solid rgba(246,130,31,0.35);
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
font-size: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.install-step-body h3 {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.install-step-body p {
|
||
font-size: 0.875rem;
|
||
color: var(--muted);
|
||
margin-bottom: 0.75rem;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.install-step-body p:last-child { margin-bottom: 0; }
|
||
|
||
.install-step-body .code-block { margin-top: 0.75rem; }
|
||
|
||
.install-step-body ul {
|
||
margin: 0.5rem 0 0.75rem 1.1rem;
|
||
font-size: 0.875rem;
|
||
color: var(--muted);
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.install-step-connector {
|
||
width: 1px;
|
||
background: var(--border);
|
||
height: 1.5rem;
|
||
margin-left: 19px;
|
||
}
|
||
|
||
.install-split {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
margin-top: 0.75rem;
|
||
}
|
||
@media (max-width: 700px) { .install-split { grid-template-columns: 1fr; } }
|
||
|
||
.install-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1.25rem 1.5rem;
|
||
background: var(--surface);
|
||
}
|
||
.install-card.highlight { border-color: rgba(246,130,31,0.4); }
|
||
|
||
.install-card .tag {
|
||
display: inline-block;
|
||
padding: 0.2rem 0.55rem;
|
||
border-radius: 4px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
.tag-opt { background: rgba(255,255,255,0.06); color: var(--muted); }
|
||
.install-card h4 { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.35rem; }
|
||
.install-card p { font-size: 0.825rem; color: var(--muted); line-height: 1.6; }
|
||
|
||
.waf-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.85rem;
|
||
margin-top: 0.75rem;
|
||
table-layout: fixed;
|
||
}
|
||
.waf-table th,
|
||
.waf-table td {
|
||
text-align: left;
|
||
padding: 0.5rem 0.6rem;
|
||
vertical-align: top;
|
||
}
|
||
.waf-table thead th:first-child { width: 38%; }
|
||
.waf-table thead th {
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
font-size: 0.8rem;
|
||
}
|
||
.waf-table tbody th {
|
||
color: var(--muted);
|
||
font-weight: 500;
|
||
font-size: 0.8rem;
|
||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
}
|
||
.waf-table tbody td {
|
||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
color: var(--text);
|
||
}
|
||
.waf-table code {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 0.8em;
|
||
color: var(--accent);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.waf-table tbody tr:last-child th,
|
||
.waf-table tbody tr:last-child td { border-bottom: none; }
|
||
|
||
@media (max-width: 600px) {
|
||
.step:not(:last-child)::after { display: none; }
|
||
.step { padding-right: 0; }
|
||
section { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||
#how-it-works { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||
.install-step { grid-template-columns: 34px minmax(0, 1fr); gap: 0.85rem; }
|
||
.install-step-num { width: 34px; height: 34px; font-size: 0.8rem; }
|
||
.install-step-connector { margin-left: 16px; }
|
||
.code-block pre { padding: 1rem; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Nav -->
|
||
<nav>
|
||
<div class="nav-logo">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||
<polyline points="22,6 12,13 2,6"/>
|
||
</svg>
|
||
kill-the-news
|
||
</div>
|
||
<nav-links>
|
||
<a href="#features" class="nav-link">Features</a>
|
||
<a href="#how-it-works" class="nav-link">How it works</a>
|
||
<a href="#install" class="nav-link">Install</a>
|
||
<a href="#faq" class="nav-link">FAQ</a>
|
||
<a href="https://github.com/sponsors/juherr" class="nav-link nav-link-sponsor" target="_blank" rel="noopener">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21.593c-.524-.505-3.655-3.536-5.905-5.8C3.39 13.078 2 10.538 2 8a6 6 0 0 1 10-4.472A6 6 0 0 1 22 8c0 2.538-1.39 5.078-4.095 7.793-2.25 2.264-5.381 5.295-5.905 5.8z"/></svg>
|
||
Sponsor
|
||
</a>
|
||
</nav-links>
|
||
<a href="https://github.com/juherr/kill-the-news" class="btn btn-outline" target="_blank" rel="noopener">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.54-1.38-1.33-1.75-1.33-1.75-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.8 1.3 3.49 1 .11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.17 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 3-.4c1.02.005 2.04.14 3 .4 2.28-1.55 3.29-1.23 3.29-1.23.66 1.65.24 2.87.12 3.17.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.83.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||
GitHub
|
||
</a>
|
||
</nav>
|
||
|
||
<!-- Hero -->
|
||
<div class="hero">
|
||
<div class="hero-glow"></div>
|
||
<div class="badge">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>
|
||
Open Source · MIT · Free Tier
|
||
</div>
|
||
<h1>Turn email newsletters into <span>private RSS feeds</span></h1>
|
||
<p>Self-hosted on Cloudflare Workers. Your data stays in your own account, served from your own domain.</p>
|
||
<div class="hero-ctas">
|
||
<a href="https://demo.kill-the.news" class="btn btn-primary" target="_blank" rel="noopener">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
Try the demo
|
||
</a>
|
||
<a href="#install" class="btn btn-outline">Self-host ↓</a>
|
||
<a href="https://github.com/juherr/kill-the-news" class="btn btn-outline" target="_blank" rel="noopener">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.54-1.38-1.33-1.75-1.33-1.75-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.8 1.3 3.49 1 .11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.17 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 3-.4c1.02.005 2.04.14 3 .4 2.28-1.55 3.29-1.23 3.29-1.23.66 1.65.24 2.87.12 3.17.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.83.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||
GitHub
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live stats (demo instance) -->
|
||
<section id="stats" class="stats-section" hidden>
|
||
<div class="stats-inner">
|
||
<div class="stats-live"><span class="stats-dot"></span> Live from the demo instance</div>
|
||
<div class="stats-grid">
|
||
<div class="stat">
|
||
<div class="stat-num" data-stat="feeds_created">0</div>
|
||
<div class="stat-label">Feeds created</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-num" data-stat="emails_received">0</div>
|
||
<div class="stat-label">Emails received</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Demo banner -->
|
||
<div class="demo-banner">
|
||
<div class="inner">
|
||
<div>
|
||
<h2>Try it <span>live</span> — no setup required</h2>
|
||
<p>
|
||
A hosted instance runs at <strong style="color:var(--text)">demo.kill-the.news</strong>, pre-loaded with sample feeds.
|
||
Create a feed, grab the RSS URL, and add it to your reader — all without deploying anything.
|
||
</p>
|
||
<div class="demo-creds">
|
||
<span class="cred-item">URL <strong>demo.kill-the.news</strong></span>
|
||
<span class="cred-sep">·</span>
|
||
<span class="cred-item">Password <strong>password</strong></span>
|
||
</div>
|
||
</div>
|
||
<div class="demo-actions">
|
||
<a href="https://demo.kill-the.news" class="btn btn-primary" target="_blank" rel="noopener" style="font-size:0.95rem;padding:0.6rem 1.35rem;">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
Open demo
|
||
</a>
|
||
<span class="demo-note">Resets periodically · for testing only</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Features -->
|
||
<section id="features">
|
||
<div class="section-header">
|
||
<div class="section-label">Features</div>
|
||
<h2 class="section-title">Everything you need, nothing you don't</h2>
|
||
<p class="section-sub">Built on serverless infrastructure — zero servers to maintain, no subscription fees.</p>
|
||
</div>
|
||
<div class="features-grid">
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||
</div>
|
||
<h3>Self-Hosted & Private</h3>
|
||
<p>Your emails and feeds live exclusively in your own Cloudflare account. No shared infrastructure, no data mining.</p>
|
||
</div>
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||
</div>
|
||
<h3>Free Cloudflare Tier</h3>
|
||
<p>Cloudflare Workers, KV, and Email Routing all fall within the generous free tier. Deploy at zero cost.</p>
|
||
</div>
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||
</div>
|
||
<h3>Your Own Domain</h3>
|
||
<p>Subscribe to newsletters using addresses on your own domain (e.g. <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">apple@yourdomain.com</code>). No lock-in.</p>
|
||
</div>
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||
</div>
|
||
<h3>Two Ingestion Methods</h3>
|
||
<p>Use Cloudflare Email Routing (no third-party) or ForwardEmail webhooks — whichever fits your setup.</p>
|
||
</div>
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||
</div>
|
||
<h3>Attachment Enclosures</h3>
|
||
<p>Email attachments are stored in Cloudflare R2 and exposed as RSS enclosures — no extra hosting needed.</p>
|
||
</div>
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||
</div>
|
||
<h3>Per-Feed Icons</h3>
|
||
<p>Each feed picks up the favicon of its newsletter's sender domain, so feeds are easy to tell apart in your reader and the admin UI.</p>
|
||
</div>
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
|
||
</div>
|
||
<h3>Auto-Unsubscribe on Delete</h3>
|
||
<p>Deleting a feed fires RFC 8058 one-click unsubscribe requests to its newsletters, so the messages stop arriving at the now-dead address.</p>
|
||
</div>
|
||
|
||
<div class="feature-card">
|
||
<div class="feature-icon">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||
</div>
|
||
<h3>External Auth Support</h3>
|
||
<p>Optionally delegate admin authentication to Authelia, Authentik, or any reverse proxy that sets <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">Remote-User</code>.</p>
|
||
</div>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<!-- How it works -->
|
||
<div id="how-it-works">
|
||
<div class="inner">
|
||
<div class="section-header">
|
||
<div class="section-label">How it works</div>
|
||
<h2 class="section-title">Three steps, done</h2>
|
||
<p class="section-sub">From email delivery to your RSS reader in milliseconds, with no moving parts.</p>
|
||
</div>
|
||
<div class="steps">
|
||
<div class="step">
|
||
<div class="step-num">1</div>
|
||
<h3>Subscribe with your address</h3>
|
||
<p>Create a feed in the admin UI and get a unique address like <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">newsletter.42@yourdomain.com</code>. Subscribe to any newsletter with it.</p>
|
||
</div>
|
||
<div class="step">
|
||
<div class="step-num">2</div>
|
||
<h3>Worker ingests the email</h3>
|
||
<p>When a newsletter arrives, Cloudflare routes it to your Worker. It parses the content and stores it in KV — attachments go to R2.</p>
|
||
</div>
|
||
<div class="step">
|
||
<div class="step-num">3</div>
|
||
<h3>Read in your RSS reader</h3>
|
||
<p>Your feed is live at <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">/rss/:feedId</code>. Add it to any RSS client and never miss an issue.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Start -->
|
||
<section id="quick-start">
|
||
<div class="section-header">
|
||
<div class="section-label">Quick Start</div>
|
||
<h2 class="section-title">Up and running in minutes</h2>
|
||
<p class="section-sub">A single setup script handles KV namespaces, secrets, and <code style="font-family:monospace;font-size:0.9em">wrangler.toml</code> generation.</p>
|
||
</div>
|
||
|
||
<div class="code-block">
|
||
<div class="code-block-header">
|
||
<span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span>
|
||
Terminal
|
||
</div>
|
||
<pre><span class="comment"># 1. Clone the repo</span>
|
||
<span class="prompt">$ </span><span class="cmd">git clone https://github.com/juherr/kill-the-news.git && cd kill-the-news</span>
|
||
|
||
<span class="comment"># 2. Log in to Cloudflare</span>
|
||
<span class="prompt">$ </span><span class="cmd">npx wrangler login</span>
|
||
|
||
<span class="comment"># 3. Run the interactive setup (creates KV, sets secrets, writes wrangler.toml)</span>
|
||
<span class="prompt">$ </span><span class="cmd">bash setup.sh</span>
|
||
|
||
<span class="comment"># 4. Deploy to the edge</span>
|
||
<span class="prompt">$ </span><span class="cmd">npm run deploy</span></pre>
|
||
</div>
|
||
|
||
<div style="margin-top:2rem;">
|
||
<p style="font-size:0.9rem;color:var(--muted);margin-bottom:1rem;">Then choose how emails reach your Worker:</p>
|
||
<div class="ingestion-options">
|
||
<div class="ingestion-card recommended">
|
||
<div class="tag tag-recommended">Recommended</div>
|
||
<h3>Cloudflare Email Routing</h3>
|
||
<p>Enable Email Routing on your domain and add a catch-all rule that forwards to the Worker. No third-party service required.</p>
|
||
</div>
|
||
<div class="ingestion-card">
|
||
<div class="tag tag-alt">Alternative</div>
|
||
<h3>ForwardEmail Webhook</h3>
|
||
<p>Point ForwardEmail MX records at your domain. ForwardEmail parses incoming mail and POSTs a JSON payload to <code style="font-family:monospace;font-size:0.8em">/api/inbound</code>.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Installation -->
|
||
<div style="background:var(--surface);padding:5rem 0 0;border-top:1px solid var(--border);">
|
||
<section id="install" style="padding-top:0;padding-bottom:0;">
|
||
<div class="section-header">
|
||
<div class="section-label">Installation</div>
|
||
<h2 class="section-title">Deploy to Cloudflare</h2>
|
||
<p class="section-sub">Everything you need to self-host kill-the-news on your own Cloudflare account, step by step.</p>
|
||
</div>
|
||
|
||
<div class="install-steps">
|
||
|
||
<!-- Step 1 -->
|
||
<div class="install-step">
|
||
<div class="install-step-num">1</div>
|
||
<div class="install-step-body">
|
||
<h3>Prerequisites</h3>
|
||
<p>You need a free <a href="https://dash.cloudflare.com/sign-up" target="_blank" rel="noopener" style="color:var(--accent);border-bottom:1px solid rgba(246,130,31,0.3)">Cloudflare account</a> with a domain managed by Cloudflare DNS, and Node.js ≥ 18.</p>
|
||
<ul>
|
||
<li>Cloudflare account (free tier is enough)</li>
|
||
<li>A domain with DNS managed by Cloudflare</li>
|
||
<li>Node.js ≥ 18 & npm</li>
|
||
<li>Git</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="install-step-connector"></div>
|
||
|
||
<!-- Step 2 -->
|
||
<div class="install-step">
|
||
<div class="install-step-num">2</div>
|
||
<div class="install-step-body">
|
||
<h3>Clone & authenticate</h3>
|
||
<p>Clone the repo and log in to Cloudflare via Wrangler. This opens a browser window to authorize the CLI.</p>
|
||
<div class="code-block">
|
||
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> Terminal</div>
|
||
<pre><span class="prompt">$ </span><span class="cmd">git clone https://github.com/juherr/kill-the-news.git</span>
|
||
<span class="prompt">$ </span><span class="cmd">cd kill-the-news</span>
|
||
<span class="prompt">$ </span><span class="cmd">npm install</span>
|
||
<span class="prompt">$ </span><span class="cmd">npx wrangler login</span></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="install-step-connector"></div>
|
||
|
||
<!-- Step 3 -->
|
||
<div class="install-step">
|
||
<div class="install-step-num">3</div>
|
||
<div class="install-step-body">
|
||
<h3>Run the setup script</h3>
|
||
<p>The interactive setup script creates the KV namespace, sets the admin password secret, and writes <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">wrangler.toml</code> for you.</p>
|
||
<div class="code-block">
|
||
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> Terminal</div>
|
||
<pre><span class="prompt">$ </span><span class="cmd">bash setup.sh</span>
|
||
<span class="comment">
|
||
# The script will ask for:
|
||
# - Your domain (e.g. newsletters.example.com)
|
||
# - An admin password (stored as a Wrangler secret)
|
||
# - Whether to enable R2 for attachments (optional)</span></pre>
|
||
</div>
|
||
<p style="margin-top:0.75rem;">This generates <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">wrangler.toml</code> from the example template. Do not commit it — it is gitignored.</p>
|
||
</div>
|
||
</div>
|
||
<div class="install-step-connector"></div>
|
||
|
||
<!-- Step 4 -->
|
||
<div class="install-step">
|
||
<div class="install-step-num">4</div>
|
||
<div class="install-step-body">
|
||
<h3>Deploy the Worker</h3>
|
||
<div class="code-block">
|
||
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> Terminal</div>
|
||
<pre><span class="prompt">$ </span><span class="cmd">npm run deploy</span>
|
||
<span class="comment">
|
||
# Wrangler compiles the Worker, uploads it to Cloudflare,
|
||
# and prints the Worker URL. Your admin UI will be live at:
|
||
# https://<your-domain>/admin</span></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="install-step-connector"></div>
|
||
|
||
<!-- Step 5 -->
|
||
<div class="install-step">
|
||
<div class="install-step-num">5</div>
|
||
<div class="install-step-body">
|
||
<h3>Configure email ingestion</h3>
|
||
<p>Choose how incoming emails reach your Worker:</p>
|
||
<div class="install-split">
|
||
<div class="install-card highlight">
|
||
<div class="tag tag-recommended">Recommended</div>
|
||
<h4>Cloudflare Email Routing</h4>
|
||
<p>In the Cloudflare dashboard, go to <strong>Email → Email Routing</strong> and enable it on your domain. Add a catch-all rule with action <em>Send to Worker</em> pointing to your deployed Worker. No third-party service required.</p>
|
||
</div>
|
||
<div class="install-card">
|
||
<div class="tag tag-alt">Alternative</div>
|
||
<h4>ForwardEmail Webhook</h4>
|
||
<p>Point ForwardEmail MX records at your domain. ForwardEmail parses incoming mail and POSTs a JSON payload to <code style="font-family:monospace;font-size:0.8em">/api/inbound</code>. Useful if you already use ForwardEmail.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="install-step-connector"></div>
|
||
|
||
<!-- Step 6 — Optional R2 -->
|
||
<div class="install-step">
|
||
<div class="install-step-num">6</div>
|
||
<div class="install-step-body">
|
||
<h3>Optional: enable attachment storage</h3>
|
||
<p>To store email attachments and expose them as RSS enclosures, create an R2 bucket and bind it in <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">wrangler.toml</code>:</p>
|
||
<div class="code-block">
|
||
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> wrangler.toml</div>
|
||
<pre><span class="comment">[[r2_buckets]]</span>
|
||
<span class="cmd">binding = "ATTACHMENT_BUCKET"
|
||
bucket_name = "kill-the-news-attachments"</span></pre>
|
||
</div>
|
||
<p style="margin-top:0.75rem;">Attachments will be served at <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">/files/:id/:filename</code> and linked as <code style="font-family:monospace;font-size:0.85em;color:var(--accent)"><enclosure></code> elements in the RSS feed.</p>
|
||
</div>
|
||
</div>
|
||
<div class="install-step-connector"></div>
|
||
|
||
<!-- Step 7 — WAF -->
|
||
<div class="install-step">
|
||
<div class="install-step-num">7</div>
|
||
<div class="install-step-body">
|
||
<h3>Harden with WAF rate limiting</h3>
|
||
<p>Protect your endpoints against abuse using Cloudflare WAF custom rate-limiting rules — no code changes required, pure infrastructure.</p>
|
||
|
||
<div class="install-split" style="margin-top:0.75rem;margin-bottom:1rem;">
|
||
<div class="install-card">
|
||
<div class="tag tag-recommended" style="background:var(--accent-dim);color:var(--accent);">Dashboard</div>
|
||
<h4>Via Cloudflare Dashboard</h4>
|
||
<p>Go to <strong>Security → Security rules</strong>, click <strong>Create rule</strong>, choose <strong>Rate limiting rule</strong>, and create one rule per endpoint below.</p>
|
||
<p style="margin-top:0.5rem;font-size:0.82em;color:#666;">⚠️ Free tier limitations: only <strong>1 rate limiting rule</strong> allowed; period and block duration capped at <strong>10 seconds</strong>. Prioritise the <code>/api/inbound</code> rule — it's the public-facing attack surface. Upgrade to a paid plan for full coverage.</p>
|
||
</div>
|
||
<div class="install-card">
|
||
<div class="tag tag-opt">Terraform</div>
|
||
<h4>Via Terraform</h4>
|
||
<p>Use <code style="font-family:monospace;font-size:0.8em">cloudflare_ruleset</code> with <code style="font-family:monospace;font-size:0.8em">phase = "http_ratelimit"</code> — see the snippet below.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p><strong style="color:var(--text)">Recommended limits:</strong></p>
|
||
<div class="code-block" style="margin-top:0.5rem;">
|
||
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> WAF rules</div>
|
||
<table class="waf-table">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Setting</th>
|
||
<th scope="col"><code>/api/inbound</code></th>
|
||
<th scope="col"><code>/admin*</code></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<th scope="row">Condition (URI Path)</th>
|
||
<td>wildcard <code>/api/inbound/*</code></td>
|
||
<td>wildcard <code>/admin/*</code></td>
|
||
</tr>
|
||
<tr>
|
||
<th scope="row">Limit (recommended)</th>
|
||
<td>60 req / min / IP</td>
|
||
<td>20 req / min / IP</td>
|
||
</tr>
|
||
<tr>
|
||
<th scope="row">Limit (free tier)</th>
|
||
<td>10 req / 10 s / IP</td>
|
||
<td>20 req / 10 s / IP</td>
|
||
</tr>
|
||
<tr>
|
||
<th scope="row">Action (recommended)</th>
|
||
<td>Block (1 min)</td>
|
||
<td>Managed Challenge (5 min)</td>
|
||
</tr>
|
||
<tr>
|
||
<th scope="row">Action (free tier)</th>
|
||
<td>Block (10 s)</td>
|
||
<td>Managed Challenge (10 s)</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<p style="margin-top:0.75rem;">Terraform equivalent <em style="font-size:0.82em;color:#666;">(supports method filtering and longer periods — requires a paid Cloudflare plan)</em>:</p>
|
||
<div class="code-block" style="margin-top:0.5rem;">
|
||
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> main.tf</div>
|
||
<pre><span class="cmd">resource "cloudflare_ruleset" "rate_limiting" {</span>
|
||
<span class="cmd"> zone_id = var.cloudflare_zone_id</span>
|
||
<span class="cmd"> name = "kill-the-news rate limiting"</span>
|
||
<span class="cmd"> kind = "zone"</span>
|
||
<span class="cmd"> phase = "http_ratelimit"</span>
|
||
<span class="cmd">
|
||
rules {</span>
|
||
<span class="cmd"> description = "Rate limit /api/inbound"</span>
|
||
<span class="cmd"> expression = "(http.request.uri.path eq \"/api/inbound\" and http.request.method eq \"POST\")"</span>
|
||
<span class="cmd"> action = "block"</span>
|
||
<span class="cmd"> ratelimit {</span>
|
||
<span class="cmd"> characteristics = ["ip.src"]</span>
|
||
<span class="cmd"> period = 60</span>
|
||
<span class="cmd"> requests_per_period = 60</span>
|
||
<span class="cmd"> mitigation_timeout = 60</span>
|
||
<span class="cmd"> }</span>
|
||
<span class="cmd"> }
|
||
</span>
|
||
<span class="cmd"> rules {</span>
|
||
<span class="cmd"> description = "Rate limit /admin"</span>
|
||
<span class="cmd"> expression = "starts_with(http.request.uri.path, \"/admin\")"</span>
|
||
<span class="cmd"> action = "managed_challenge"</span>
|
||
<span class="cmd"> ratelimit {</span>
|
||
<span class="cmd"> characteristics = ["ip.src"]</span>
|
||
<span class="cmd"> period = 60</span>
|
||
<span class="cmd"> requests_per_period = 20</span>
|
||
<span class="cmd"> mitigation_timeout = 300</span>
|
||
<span class="cmd"> }</span>
|
||
<span class="cmd"> }
|
||
}</span></pre>
|
||
</div>
|
||
|
||
<p style="margin-top:0.75rem;font-size:0.8rem;color:#555;">
|
||
These rules run at the Cloudflare edge before the Worker is invoked — zero latency impact on normal traffic.
|
||
If ForwardEmail's delivery IPs ever trigger the <code style="font-family:monospace;color:var(--muted)">/api/inbound</code> limit, add them as an IP Access Rule with action <em>Allow</em> under Security → WAF → Tools.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- FAQ -->
|
||
<section id="faq">
|
||
<div class="section-header">
|
||
<div class="section-label">FAQ</div>
|
||
<h2 class="section-title">Questions & answers</h2>
|
||
<p class="section-sub">The practical stuff — subscribing, privacy, troubleshooting, and how kill-the-news differs.</p>
|
||
</div>
|
||
<div class="faq-list">
|
||
<details class="faq-item">
|
||
<summary>How does kill-the-news work?</summary>
|
||
<p>Create a feed in the admin UI and you get a unique address on your domain (e.g. <code style="font-family:monospace;color:var(--accent)">newsletter.42@yourdomain.com</code>) plus an RSS and an Atom feed. Any email sent to that address is turned into entries in those feeds.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>How do I confirm a newsletter subscription?</summary>
|
||
<p>Confirmation emails arrive as feed entries — open the entry in your reader and click the confirmation link. If a publisher requires a reply, subscribe with your normal inbox instead and set up a filter that auto-forwards its mail to your feed address.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>Are my feeds private?</summary>
|
||
<p>Yes. Each feed URL carries an unguessable ID, it is served from your own domain on your own Cloudflare account, and the admin UI is password-protected. Treat the feed URL like a password — anyone who has it can read your newsletters.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>Why are old entries disappearing?</summary>
|
||
<p>Feeds honor an optional size and time-to-live cap so RSS readers stay happy — some readers choke on feeds that grow too large. When a limit is reached, the oldest entries (and their R2 attachments) are purged automatically.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>Can I share a feed with someone?</summary>
|
||
<p>Don't. Anyone with the URL can read your newsletters and even unsubscribe you. Share the project instead, so others can self-host and create their own feeds.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>Why isn't my feed updating?</summary>
|
||
<p>Send a test email to the feed address. If it shows up within a minute, the delay is on the newsletter publisher's side, not kill-the-news. Readers that support WebSub get near-instant push updates instead of waiting for the next poll.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>How is this different from kill-the-newsletter.com?</summary>
|
||
<p>kill-the-news is self-hosted on your own Cloudflare account: your data, your domain, RSS <em>and</em> Atom output, attachments served as enclosures, WebSub push updates — all running on the free tier.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>How much does it cost?</summary>
|
||
<p>It runs on Cloudflare's free tier (Workers + KV + R2) plus the cost of your domain. With Cloudflare Email Routing, no third-party service is required at all.</p>
|
||
</details>
|
||
<details class="faq-item">
|
||
<summary>How do I delete a feed?</summary>
|
||
<p>From the password-protected admin UI — open the Feeds tab and delete it there. Its entries and attachments are removed along with it.</p>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Tech Stack -->
|
||
<section id="tech-stack" style="padding-top:0;">
|
||
<div class="section-header">
|
||
<div class="section-label">Tech Stack</div>
|
||
<h2 class="section-title">Built on reliable primitives</h2>
|
||
<p class="section-sub">Minimal dependencies, maximum portability — runs entirely on Cloudflare's global network.</p>
|
||
</div>
|
||
<div class="tech-pills">
|
||
<span class="pill">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent)"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||
Cloudflare Workers
|
||
</span>
|
||
<span class="pill">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)"><rect x="2" y="2" width="9" height="9"/><rect x="13" y="2" width="9" height="9"/><rect x="13" y="13" width="9" height="9"/><rect x="2" y="13" width="9" height="9"/></svg>
|
||
Hono
|
||
</span>
|
||
<span class="pill">KV Storage</span>
|
||
<span class="pill">R2 Object Storage</span>
|
||
<span class="pill">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="color:#3178c6"><path d="M0 12v12h24V0H0zm19.341-.956c.61.152 1.074.423 1.501.865.221.236.549.666.575.769.008.03-1.036.73-1.668 1.123-.023.015-.115-.084-.217-.236-.31-.45-.633-.644-1.128-.678-.728-.05-1.196.331-1.192.967a.88.88 0 0 0 .102.45c.16.331.458.53 1.39.933 1.719.74 2.454 1.227 2.911 1.92.51.773.625 2.008.278 2.926-.38.998-1.325 1.676-2.655 1.9-.411.073-1.386.062-1.828-.018-.964-.172-1.878-.648-2.442-1.273-.221-.243-.652-.88-.625-.925.011-.016.11-.077.22-.141.108-.061.511-.294.892-.515l.69-.4.145.214c.202.308.643.731.91.872.766.404 1.817.347 2.335-.118a.883.883 0 0 0 .313-.72c0-.278-.035-.4-.18-.61-.186-.266-.567-.49-1.649-.96-1.238-.533-1.771-.864-2.259-1.39a3.165 3.165 0 0 1-.659-1.2c-.091-.339-.114-1.189-.042-1.531.255-1.197 1.158-2.03 2.461-2.278.423-.08 1.406-.05 1.821.053z"/></svg>
|
||
TypeScript
|
||
</span>
|
||
<span class="pill">RSS 2.0</span>
|
||
<span class="pill">Atom</span>
|
||
<span class="pill">postal-mime</span>
|
||
<span class="pill">Zod</span>
|
||
<span class="pill">Vitest</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Sponsor -->
|
||
<div style="border-top:1px solid var(--border);padding:3rem 2rem;text-align:center;">
|
||
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:1rem;">
|
||
kill-the-news is free and open source. If it saves you time, consider supporting its development.
|
||
</p>
|
||
<a href="https://github.com/sponsors/juherr" class="btn" target="_blank" rel="noopener"
|
||
style="background:rgba(219,97,162,0.12);border:1px solid rgba(219,97,162,0.35);color:#e879b8;font-size:0.875rem;padding:0.5rem 1.25rem;">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21.593c-.524-.505-3.655-3.536-5.905-5.8C3.39 13.078 2 10.538 2 8a6 6 0 0 1 10-4.472A6 6 0 0 1 22 8c0 2.538-1.39 5.078-4.095 7.793-2.25 2.264-5.381 5.295-5.905 5.8z"/></svg>
|
||
Sponsor on GitHub
|
||
</a>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<footer>
|
||
<p>
|
||
<a href="https://github.com/juherr/kill-the-news" target="_blank" rel="noopener">kill-the-news</a>
|
||
<span class="sep">·</span>
|
||
MIT License
|
||
<span class="sep">·</span>
|
||
Built on <a href="https://workers.cloudflare.com/" target="_blank" rel="noopener">Cloudflare Workers</a>
|
||
<span class="sep">·</span>
|
||
Inspired by <a href="https://github.com/leafac/kill-the-newsletter" target="_blank" rel="noopener">kill-the-newsletter</a>
|
||
<span class="sep">·</span>
|
||
<a href="https://github.com/sponsors/juherr" target="_blank" rel="noopener">♥ Sponsor</a>
|
||
</p>
|
||
</footer>
|
||
|
||
<script>
|
||
(async function () {
|
||
const section = document.getElementById('stats');
|
||
let data;
|
||
try {
|
||
const res = await fetch('https://demo.kill-the.news/api/stats', { cache: 'no-store' });
|
||
if (!res.ok) return; // section stays hidden
|
||
data = await res.json();
|
||
} catch { return; }
|
||
|
||
const nums = section.querySelectorAll('.stat-num');
|
||
nums.forEach(el => { el.dataset.value = data[el.dataset.stat] ?? 0; });
|
||
section.hidden = false;
|
||
|
||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
const fmt = n => n.toLocaleString('en-US');
|
||
const ease = t => 1 - Math.pow(1 - t, 3);
|
||
|
||
function animate(el) {
|
||
const target = Number(el.dataset.value) || 0;
|
||
if (reduce || target === 0) { el.textContent = fmt(target); return; }
|
||
const dur = 1400, start = performance.now();
|
||
(function step(now) {
|
||
const t = Math.min((now - start) / dur, 1);
|
||
el.textContent = fmt(Math.round(ease(t) * target));
|
||
if (t < 1) requestAnimationFrame(step);
|
||
})(performance.now());
|
||
}
|
||
|
||
const io = new IntersectionObserver((entries) => {
|
||
entries.forEach(e => { if (e.isIntersecting) { animate(e.target); io.unobserve(e.target); } });
|
||
}, { threshold: 0.4 });
|
||
nums.forEach(el => io.observe(el));
|
||
})();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|