Skip to content

alexmensch/feedmail

Repository files navigation

feedmail

codecov

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.

Features

  • 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-Post headers
  • 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

Architecture

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)

Quick Start

Prerequisites

1. Clone and install

git clone https://github.com/alexmensch/feedmail.git
cd feedmail
pnpm install

2. Create the D1 database

wrangler d1 create feedmail

Copy the database_id from the output into wrangler.toml.

3. Run migrations

pnpm run db:migrate        # Remote (production)
pnpm run db:migrate:local  # Local dev

4. Configure channels

Edit 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}

5. Set secrets

wrangler secret put RESEND_API_KEY
wrangler secret put ADMIN_API_KEY

6. Deploy

pnpm run deploy

7. Set up your route pattern

Update the [[routes]] section in wrangler.toml to use your domain:

[[routes]]
pattern = "yourdomain.com/api/*"
zone_name = "yourdomain.com"

Configuration

Environment variables (wrangler.toml [vars])

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

Secrets (wrangler secret put)

Secret Description
RESEND_API_KEY Resend API key for sending emails
ADMIN_API_KEY Bearer token for admin and send endpoints

IP Rate Limits

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).

API Reference

Public endpoints

POST /api/subscribe

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.

GET /api/verify?token=<token>

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

GET /api/unsubscribe?token=<token>

Unsubscribe from the newsletter. Returns an HTML confirmation page.

POST /api/unsubscribe?token=<token>

One-click unsubscribe (RFC 8058). Returns 200 OK.

Authenticated endpoints

All authenticated endpoints require an Authorization: Bearer <ADMIN_API_KEY> header.

POST /api/send

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 /api/admin/stats?channelId=<channelId>

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"}]
}

GET /api/admin/subscribers?channelId=<channelId>&status=<status>

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
    }
  ]
}

Security

feedmail uses a layered security approach instead of CAPTCHA challenges:

  1. IP rate limiting — Per-endpoint limits via D1 rolling window counting (see IP Rate Limits)
  2. 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.
  3. Strict input validation — Subscribe endpoint rejects requests with unexpected fields, enabling invisible honeypot fields in the form
  4. Verification email limits — Per-subscriber rolling window limits on verification emails sent
  5. No information leakage — All subscribe responses are identical regardless of subscriber state

Cron Behaviour

feedmail runs on a configurable cron schedule (default: every 6 hours). On each trigger:

  1. Fetches all configured RSS/Atom feeds
  2. 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
  3. Identifies new items by comparing feed item IDs against D1 records
  4. Sends each new item as a separate email to all verified subscribers
  5. Records sent items in D1

Templates

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.

Available Handlebars helpers

  • {{formatDate date}} — Formats a date string as "27 February 2026"
  • {{currentYear}} — Returns the current year

Subscribe Form Example

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>

Multi-channel Setup

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"}
    ]
  }
]
'''

Development

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

License

AGPL-3.0

About

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.

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Contributors