An open-source RSS-to-email microservice that runs on Cloudflare Workers. Monitor RSS/Atom feeds for new items and email them to subscribers automatically.
- RSS & Atom support — Parses both RSS 2.0 and Atom feeds
- Double opt-in — Email verification with rate limiting and 24-hour token expiry
- One-click unsubscribe — RFC 8058
List-Unsubscribe-Postheaders - Multi-channel — Single deployment can serve multiple channels with isolated subscriber lists
- Multi-feed — Each channel can monitor multiple named feeds
- Zero tracking — No open or click tracking; privacy by default
- Customizable templates — Handlebars templates for emails and confirmation pages
- Admin API — Subscriber stats and listing endpoints
- IP rate limiting — Per-endpoint rolling window rate limiting via D1
- Bot protection — Strict input validation with honeypot support, method enforcement with deliberate timeouts
- Feed bootstrapping — First run seeds existing items without sending emails
- Config validation — Validates all configuration at startup with clear error messages
feedmail runs entirely on Cloudflare's edge platform:
- Cloudflare Workers — Handles HTTP requests and cron triggers
- Cloudflare D1 — Stores subscribers, verification attempts, sent item history, and rate limits
- Resend — Sends transactional emails (verification and newsletter)
- Node.js (v18+)
- pnpm
- Wrangler CLI (
pnpm add -g wrangler) - A Resend account with an API key
- A Cloudflare account
git clone https://github.com/alexmensch/feedmail.git
cd feedmail
pnpm installwrangler d1 create feedmailCopy the database_id from the output into wrangler.toml.
pnpm run db:migrate # Remote (production)
pnpm run db:migrate:local # Local devEdit the CHANNELS variable in wrangler.toml:
[vars]
DOMAIN = "yourdomain.com"
CHANNELS = '''
[
{
"id": "my-channel",
"siteUrl": "https://example.com",
"siteName": "My Site",
"fromUser": "hello",
"fromName": "My Site Newsletter",
"corsOrigins": ["https://example.com"],
"feeds": [
{"name": "Blog", "url": "https://example.com/feed.xml"}
]
}
]
'''Each channel object requires:
| Field | Description |
|---|---|
id |
Unique identifier (sent by the subscribe form) |
siteUrl |
Site URL (used in templates) |
siteName |
Display name (used in email subjects and templates) |
fromUser |
Email local part (e.g. "hello"); combined with DOMAIN to form the from-email |
fromName |
Sender display name |
replyTo |
Reply-to email address (optional) |
companyName |
Company name displayed in email footers (optional) |
companyAddress |
Company address displayed in email footers (optional) |
corsOrigins |
Allowed origins for the subscribe endpoint |
feeds |
Array of feed objects, each with name and url |
The DOMAIN env var is used to construct:
- All URLs:
https://{DOMAIN}/api/... - From-email:
{fromUser}@{DOMAIN}
wrangler secret put RESEND_API_KEY
wrangler secret put ADMIN_API_KEYpnpm run deployUpdate the [[routes]] section in wrangler.toml to use your domain:
[[routes]]
pattern = "yourdomain.com/api/*"
zone_name = "yourdomain.com"| Variable | Default | Description |
|---|---|---|
DOMAIN |
— | Domain name (e.g. feedmail.cc). No protocol, trailing slash, or path. (required) |
CHANNELS |
— | JSON array of channel configurations (required) |
VERIFY_MAX_ATTEMPTS |
"3" |
Max verification emails per rolling window |
VERIFY_WINDOW_HOURS |
"24" |
Rolling window duration in hours |
| Secret | Description |
|---|---|
RESEND_API_KEY |
Resend API key for sending emails |
ADMIN_API_KEY |
Bearer token for admin and send endpoints |
Rate limits are configured per endpoint in src/lib/rate-limit.js:
| Endpoint | Limit | Window |
|---|---|---|
/api/subscribe |
10 requests | 1 hour |
/api/verify |
20 requests | 1 hour |
/api/unsubscribe |
20 requests | 1 hour |
/api/send |
5 requests | 1 hour |
/api/admin/* |
30 requests | 1 hour |
When rate limited, the API returns 429 Too Many Requests with a Retry-After header indicating when the next request will be accepted (with random jitter to prevent thundering herd retries).
Subscribe an email address to a channel's newsletter.
Request body:
{
"email": "user@example.com",
"channelId": "my-channel"
}Only email and channelId fields are accepted. Requests with any additional fields are rejected — this enables invisible honeypot fields in the subscribe form for bot protection.
Response: 200 OK
{
"success": true,
"message": "Check your email to confirm your subscription."
}Always returns the same response regardless of whether the email is new, already subscribed, or rate limited — no information leakage.
CORS: Enabled for configured corsOrigins.
Verify a subscriber's email address. Returns an HTML confirmation page.
- Valid token → marks subscriber as verified, returns success page
- Invalid or expired token (24hr) → returns error page
Unsubscribe from the newsletter. Returns an HTML confirmation page.
One-click unsubscribe (RFC 8058). Returns 200 OK.
All authenticated endpoints require an Authorization: Bearer <ADMIN_API_KEY> header.
Manually trigger feed checking and email sending. Optionally specify a channel:
{
"channelId": "my-channel"
}Response:
{
"sent": 3,
"items": [
{ "title": "Post Title", "recipients": 3, "channelId": "my-channel" }
],
"seeded": false
}Get subscriber and sent item statistics for a channel.
Response:
{
"channelId": "my-channel",
"subscribers": { "total": 50, "verified": 45, "pending": 3, "unsubscribed": 2 },
"sentItems": { "total": 12, "lastSentAt": "2026-02-27T10:00:00Z" },
"feeds": [{"name": "Blog", "url": "https://example.com/feed.xml"}]
}List subscribers for a channel. Optional status filter (pending, verified, unsubscribed).
Response:
{
"channelId": "my-channel",
"count": 45,
"subscribers": [
{
"email": "user@example.com",
"status": "verified",
"created_at": "2026-02-01T00:00:00Z",
"verified_at": "2026-02-01T00:05:00Z",
"unsubscribed_at": null
}
]
}feedmail uses a layered security approach instead of CAPTCHA challenges:
- IP rate limiting — Per-endpoint limits via D1 rolling window counting (see IP Rate Limits)
- HTTP method enforcement — Known routes with wrong methods receive a deliberate 10-second delay then 408 timeout, discouraging bot probing. Unknown paths get an immediate 404 with no body.
- Strict input validation — Subscribe endpoint rejects requests with unexpected fields, enabling invisible honeypot fields in the form
- Verification email limits — Per-subscriber rolling window limits on verification emails sent
- No information leakage — All subscribe responses are identical regardless of subscriber state
feedmail runs on a configurable cron schedule (default: every 6 hours). On each trigger:
- Fetches all configured RSS/Atom feeds
- Bootstrapping: If a feed has never been seen before, all current items are marked as "already sent" without emailing anyone — this prevents a flood of old content on first setup
- Identifies new items by comparing feed item IDs against D1 records
- Sends each new item as a separate email to all verified subscribers
- Records sent items in D1
Email and page templates use Handlebars and are located in src/templates/:
| Template | Purpose |
|---|---|
newsletter.hbs |
HTML email for new feed items |
newsletter.txt.hbs |
Plain text email for new feed items |
verification-email.hbs |
Verification email sent on subscribe |
verify-page.hbs |
"You're subscribed" confirmation page |
unsubscribe-page.hbs |
"You've been unsubscribed" confirmation page |
error-page.hbs |
Error page (invalid/expired tokens) |
partials/email-footer.hbs |
Shared email footer (copyright, unsubscribe, company info) |
Customize these files before deploying to match your branding.
{{formatDate date}}— Formats a date string as "27 February 2026"{{currentYear}}— Returns the current year
Add a subscribe form to your website that POSTs to the feedmail API. The form should only send email and channelId — any extra fields will be rejected (which is useful for adding an invisible honeypot field for bot detection).
<form id="subscribe-form">
<input type="email" name="email" placeholder="Your email" required />
<!-- Honeypot field: hidden from real users, bots will fill it -->
<input type="text" name="website" style="display: none" tabindex="-1" autocomplete="off" />
<button type="submit">Subscribe</button>
<p id="subscribe-message" aria-live="polite"></p>
</form>
<script>
document.getElementById('subscribe-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const msg = document.getElementById('subscribe-message');
// If honeypot field is filled, silently "succeed" without submitting
if (form.website.value) {
msg.textContent = 'Check your email to confirm your subscription.';
return;
}
const response = await fetch('https://your-feedmail-domain/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.email.value,
channelId: 'your-channel-id',
}),
});
const data = await response.json();
msg.textContent = data.message;
});
</script>feedmail supports multiple channels in a single deployment. Each channel has its own subscriber list, feeds, sender identity, and CORS origins.
Add additional channel objects to the CHANNELS array in wrangler.toml:
[vars]
DOMAIN = "yourdomain.com"
CHANNELS = '''
[
{
"id": "channel-a",
"siteUrl": "https://site-a.com",
"siteName": "Site A",
"fromUser": "newsletter",
"fromName": "Site A",
"corsOrigins": ["https://site-a.com"],
"feeds": [{"name": "Blog", "url": "https://site-a.com/feed.xml"}]
},
{
"id": "channel-b",
"siteUrl": "https://site-b.com",
"siteName": "Site B",
"fromUser": "newsletter",
"fromName": "Site B",
"corsOrigins": ["https://site-b.com"],
"feeds": [
{"name": "Blog", "url": "https://site-b.com/rss"},
{"name": "Podcast", "url": "https://site-b.com/podcast.xml"}
]
}
]
'''pnpm run dev # Start local dev server
pnpm run db:migrate:local # Apply migrations locally
pnpm run test # Run tests
pnpm run test:coverage # Run tests with coverage