Self-hosted RSS feed cross-poster. Route items from RSS, Atom, and JSON feeds to Mastodon accounts using configurable templates.
Built as a replacement for Echofeed, which shut down in August 2026.
- RSS/Atom/JSON feed support via feedparser
- Mastodon OAuth — connect accounts with one click, no manual token creation
- Template engine with variables:
{{ title }},{{ link }},{{ summary }},{{ content }},{{ author }},{{ date }},{{ date:iso }},{{ date:short }},{{ hashtags }} - Multiple accounts — post to multiple Mastodon instances
- Per-feed poll intervals — each feed checked on its own schedule
- Post history with success/failure tracking and error messages
- Visibility settings — public, unlisted, private, direct
- Mobile-responsive — tables convert to cards, forms stack, 44px touch targets
- Idempotent posting — failed posts are retried, duplicates are prevented
- Auto-initialization — feeds set their baseline on first fetch, no manual init needed
- Email destination — echo to email via SMTP in addition to Mastodon
- Backend: Python + FastAPI
- Database: SQLite (WAL mode)
- Frontend: Jinja2 server-rendered templates + vanilla JS
- Feed parsing: feedparser (RSS/Atom) + native JSON Feed parser
- Scheduler: APScheduler (background feed checker)
- HTTP client: httpx
Pre-built multi-arch images (amd64 + arm64) are published to GHCR on every release — no local build needed:
mkdir feedecho && cd feedecho
cat > .env <<'EOF'
FEEDCHO_AUTH_TOKEN=change-me-to-a-long-random-string
FEEDCHO_CALLBACK_URL=http://localhost:8453/oauth/callback
EOF
curl -O https://raw.githubusercontent.com/jcrabapple/feedecho/master/docker-compose.yml
# Then edit docker-compose.yml: comment out `build: .` and uncomment the `image:` line
docker compose up -dOr clone the repo and build locally:
git clone https://github.com/jcrabapple/feedecho.git
cd feedecho
# Set your access token (required) and public URL (for Mastodon OAuth)
cat > .env <<'EOF'
FEEDCHO_AUTH_TOKEN=change-me-to-a-long-random-string
FEEDCHO_CALLBACK_URL=http://localhost:8453/oauth/callback
EOF
docker compose up -dOpen http://localhost:8453 and log in with your FEEDCHO_AUTH_TOKEN.
Data lives in the feedecho-data volume (/app/data in the container) — your feeds, accounts, and history survive docker compose up -d --build rebuilds.
Plain Docker without compose:
docker build -t feedecho .
docker run -d --name feedecho \
-p 8453:8453 \
-v feedecho-data:/app/data \
-e FEEDCHO_AUTH_TOKEN=change-me-to-a-long-random-string \
-e FEEDCHO_CALLBACK_URL=http://localhost:8453/oauth/callback \
feedecho| Variable | Required | Purpose |
|---|---|---|
FEEDCHO_AUTH_TOKEN |
yes (for any real deployment) | Shared-secret login for the web UI. If unset, auth is disabled — only safe on localhost. |
FEEDCHO_CALLBACK_URL |
for Mastodon OAuth | Public callback URL, e.g. https://feedecho.example.com/oauth/callback. Must match the URL reachable by your browser. |
FEEDCHO_DB_PATH |
no | SQLite path (default /app/data/feedecho.db in Docker, ./feedecho.db otherwise) |
FEEDCHO_STATE_SECRET |
no | OAuth state signing secret (defaults to FEEDCHO_AUTH_TOKEN) |
Behind a reverse proxy (nginx, Caddy, Traefik), point the proxy at port 8453 and set FEEDCHO_CALLBACK_URL to the public HTTPS URL.
git clone https://github.com/jcrabapple/feedecho.git
cd feedecho
python -m venv .venv
source .venv/bin/activate
pip install fastapi "uvicorn[standard]" jinja2 python-multipart feedparser httpx apscheduler
FEEDCHO_AUTH_TOKEN=your-token python -m uvicorn app:app --host 0.0.0.0 --port 8453FeedEcho ships a Nix flake and a NixOS module. See nix/README.md for full instructions.
{
inputs.feedecho.url = "github:jcrabapple/feedecho";
outputs = { self, nixpkgs, feedecho, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
feedecho.nixosModules.default
{
services.feedecho = {
enable = true;
authTokenFile = "/run/secrets/feedecho-token";
callbackUrl = "https://feedecho.example.com/oauth/callback";
};
}
];
};
};
}- Add a Mastodon account — Go to
/accounts, enter your instance URL, click "Connect Account". OAuth handles the rest. - Add a feed — Go to
/feeds, paste an RSS/Atom/JSON feed URL. - Create an echo — Go to
/echoes, select a feed + account, write a template like{{ title }} {{ link }}. - Watch it run — The scheduler checks feeds every 2 minutes and posts new items.
| Variable | Description |
|---|---|
{{ title }} |
Post title |
{{ link }} |
Post URL |
{{ summary }} |
Post summary/excerpt |
{{ content }} |
Full post content (HTML stripped to plain text) |
{{ author }} |
Author name |
{{ date }} |
Publication date (raw) |
{{ date:iso }} |
ISO 8601 date (2024-01-15T09:30:00) |
{{ date:short }} |
Short date (2024-01-15) |
{{ hashtags }} |
Feed tags as #hashtags |
FeedEcho is ~1,600 lines of Python across 8 modules. No framework magic, no ORMs, no build step. Here's what each piece does:
The FastAPI application. Defines every HTTP route: dashboard, feed CRUD, account management, echo CRUD, post history, settings, and the OAuth callback endpoints. Renders Jinja2 templates server-side. Also starts/stops the background scheduler on app startup/shutdown. This is the only module that talks to the user's browser.
Creates and manages 7 tables: accounts (Mastodon connections), feeds (RSS sources), echoes (feed-to-destination mappings), email_accounts, settings (key-value config like SMTP), posted_items (post history with status tracking), and oauth_apps (cached OAuth client credentials per instance). Uses SQLite WAL mode for concurrent read/write. Includes lightweight migrations (column additions, schema resets) so existing databases upgrade in place. The unique index on posted_items(echo_id, item_id) enforces the pending-row dedup pattern.
Fetches feed URLs via httpx with a 10 MB size cap (prevents OOM from hostile feeds). Parses RSS/Atom via feedparser and JSON Feed natively. Normalizes all feed formats into a common item shape: id, title, link, summary, content, author, date, tags. Strips HTML to plain text (Mastodon statuses are plain text). Synthesizes stable item IDs from content hashes when feeds lack GUIDs. The get_new_items() function implements cursor-based new-item detection: on first run it sets a baseline (no backlog posting), and if the cursor scrolled off the feed, it posts only the newest item to avoid spam.
The core dispatch engine. Runs on APScheduler (every 2 minutes). For each due feed: fetch new items, find enabled echoes, render templates, dispatch to Mastodon or email. Uses a pending-row pattern for idempotent posting: each (echo, item) pair is claimed via INSERT OR IGNORE with status='pending' before dispatch, then UPDATEd to success or failed after. The unique index prevents duplicate claims. The cursor only advances past items where all echoes succeeded, so failed posts are retried on the next poll. All network I/O happens outside DB transactions to avoid lock contention.
Thin httpx wrapper around three Mastodon REST endpoints: POST /api/v1/statuses (post), GET /api/v1/accounts/verify_credentials (validate token), and the connection test helper. No state, no caching, no surprises.
Implements the full OAuth dance: register an app on the target instance (POST /api/v1/apps), build the authorize URL with a CSRF state token, exchange the callback code for an access token (POST /oauth/token). Caches OAuth app credentials per instance in the oauth_apps table so re-registration isn't needed. The state parameter carries a random token plus the instance URL so the callback knows which instance to exchange with.
Regex-based variable substitution. Replaces {{ variable }} placeholders with feed item data. Supports 9 variables including two date format variants. No eval, no code execution — pure string replacement. Tags are sanitized to alphanumeric for hashtag safety.
Sends rendered template content as plain-text email. Reads SMTP config (host, port, username, password, TLS mode) from the settings table. Supports both implicit TLS (port 465) and STARTTLS (port 587). Includes a connection test helper.
9 templates: base.html (layout + nav), dashboard.html (overview stats), feeds.html, accounts.html, echoes.html, history.html (post log), settings.html (SMTP config), login.html (shared-secret auth), 404.html. All use Jinja2 autoescaping.
style.css (mobile-responsive, table-to-card at 640px breakpoint) and app.js (inline echo editing, account test buttons, feed preview). Vanilla JS, no frameworks, no build step.
Four test modules covering the database layer, feed parser (item detection, HTML stripping, truncation, date parsing), template engine (variable substitution, date formatting, hashtag generation), and security features (SSRF protection, OAuth state signing).
FeedEcho handles OAuth tokens and posts to your Mastodon accounts. Here's what it does and doesn't do:
Feed URLs are validated before fetching. The SSRF filter blocks:
- Non-http(s) schemes (
file://,gopher://, etc.) - Direct IP addresses in private ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, ::1, fc00::, fe80::)
- Hostnames that resolve to private/internal IPs (DNS resolution is checked before the request is made)
This prevents a user from pointing FeedEcho at cloud metadata endpoints (169.254.169.254), internal services, or localhost.
FeedEcho supports optional shared-secret authentication via the FEEDCHO_AUTH_TOKEN environment variable:
- If set: all requests must include the token as either a cookie (set by the login page at
/login) or anX-Auth-Tokenheader (for API/programmatic access). Unauthenticated browser requests are redirected to/login; API requests get 401. - If unset: auth is disabled (original behavior). The app is open to anyone who can reach the port.
The OAuth callback endpoints (/oauth/connect, /oauth/callback) are exempt from auth so Mastodon's redirect flow works without a cookie. The HMAC-signed state token provides CSRF protection on those endpoints.
- The OAuth state parameter is HMAC-signed (
hmac.compare_digest, SHA-256). Format:<nonce>|<instance>|<signature>. The signature covers the nonce and instance, preventing CSRF and tampering with the instance field. A forged state token without the secret is rejected. - The callback URL is configurable via the
FEEDCHO_CALLBACK_URLenvironment variable. If unset, it defaults tohttps://feedecho.example.com/oauth/callback. Self-hosters should set this to their public URL.
- Mastodon OAuth tokens are stored in the SQLite database (
accounts.access_token). The database file is local to the server. There is no encryption at rest — if an attacker gains filesystem access, they can read the tokens. - SMTP passwords are stored in the
settingstable in plaintext. They are masked (********) when sent to the browser on the settings and accounts pages. The save endpoint skips password updates when the mask placeholder is submitted, so the existing password is preserved. - OAuth client secrets (per-instance app credentials) are cached in the
oauth_appstable in plaintext. - FeedEcho does not log tokens, passwords, or secrets to the application log. Log messages contain echo IDs, feed names, and error messages only.
- The
FEEDCHO_AUTH_TOKENenv var doubles as the HMAC signing key for OAuth state tokens if set, so a single secret secures both layers.
- Feed content from external RSS/Atom/JSON feeds is treated as untrusted. HTML is stripped to plain text before posting to Mastodon. Feed item titles and URLs are never rendered as HTML in the UI without Jinja2 autoescaping.
- Template variables are substituted via regex — there is no
eval()or code execution path. A malformed template produces empty or garbled output, not a security hole. - Feed fetches are capped at 10 MB to prevent memory exhaustion from hostile feeds.
- The inline echo editor in
app.jsstores original row HTML in an in-memory Map rather than serializing it into a DOM attribute, avoiding an XSS vector that was present in an earlier version.
- All outbound HTTP uses httpx with a 30-second timeout. FeedEcho makes requests to: the feed URL (user-provided), the Mastodon instance API (user-provided), and the SMTP server (admin-configured). No telemetry, no phone-home, no analytics.
- Even without
FEEDCHO_AUTH_TOKEN, FeedEcho is designed to run behind a reverse proxy or tunnel (Cloudflare Tunnel, nginx, etc.) with access control at the network layer. The built-in auth is a lightweight fallback for when a reverse proxy isn't available.
| Environment variable | Purpose | Default |
|---|---|---|
FEEDCHO_AUTH_TOKEN |
Shared-secret auth token (enables login page + API auth, also signs OAuth state) | Unset (auth disabled) |
FEEDCHO_CALLBACK_URL |
Public URL for OAuth callback | https://feedecho.example.com/oauth/callback |
FEEDCHO_DB_PATH |
Path to SQLite database | ./feedecho.db |
- Does not encrypt secrets at rest (tokens and passwords are plaintext in SQLite)
- Does not rate-limit its own feed polling (relies on APScheduler intervals)
- Does not validate SSL certificates beyond httpx defaults
- Does not sandbox feed parsing (feedparser runs in-process)
If any of these are a concern for your deployment, wrap FeedEcho behind an authenticated reverse proxy and restrict filesystem access to the database file.
[Unit]
Description=FeedEcho
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/feedecho
ExecStart=%h/feedecho/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8453
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.targetFor public access with HTTPS, use a Cloudflare Tunnel:
cloudflared tunnel create feedecho
cloudflared tunnel route dns <TUNNEL_ID> feedecho.yourdomain.com
cat > ~/.cloudflared/feedecho.yml << 'EOF'
tunnel: feedecho
credentials-file: ~/.cloudflared/<TUNNEL_ID>.json
ingress:
- hostname: feedecho.yourdomain.com
service: http://127.0.0.1:8453
- service: http_status:404
EOFIf using OAuth, set FEEDCHO_CALLBACK_URL to your public URL:
export FEEDCHO_CALLBACK_URL="https://feedecho.yourdomain.com/oauth/callback"
export FEEDCHO_AUTH_TOKEN="your-secret-token" # optional: enable web UI authsource .venv/bin/activate
python -m pytest tests/ -vfeedecho/
├── app.py # FastAPI app — routes, auth middleware, OAuth callbacks
├── database.py # SQLite layer (7 tables)
├── feed_parser.py # RSS/Atom/JSON feed fetching + SSRF protection
├── mastodon.py # Mastodon API client
├── oauth.py # Mastodon OAuth 2.0 flow (HMAC-signed state)
├── scheduler.py # APScheduler background feed checker
├── template_engine.py # Variable substitution
├── email_sender.py # SMTP email dispatch
├── templates/ # Jinja2 HTML templates (9)
├── static/ # CSS + JS
└── tests/ # 62 tests (pytest)
MIT