···11+## Project Summary: Custom RSS Reader
22+33+### Goals
44+55+Build a lightweight, self-hosted RSS aggregator in Rust that serves feeds via the Fever API to external RSS readers (ReadKit on iOS). No web interface needed - the server is headless and solely provides API endpoints for feed consumption.
66+77+### Core Requirements
88+99+**Feed Aggregation:**
1010+1111+- Add RSS/Atom feeds by URL
1212+- Automatic polling and fetching (15min intervals)
1313+- Deduplication and storage in SQLite
1414+- Serve items chronologically
1515+1616+**Email-to-RSS:**
1717+1818+- Generate unique email addresses per newsletter "feed"
1919+- Accept incoming emails via SMTP or webhook
2020+- Parse newsletter content and extract links
2121+- Store as RSS items alongside regular feeds
2222+2323+**API Compatibility:**
2424+2525+- Implement Fever API (ReadKit supports this)
2626+- JSON responses with groups, feeds, items
2727+- Authentication via API key
2828+- No read/unread state tracking (client handles this)
2929+3030+**Deployment:**
3131+3232+- NixOS service on bigchungus (Minisforum UM790)
3333+- SQLite database in `/data/rss`
3434+- Exposed via Caddy reverse proxy
3535+- No web UI - purely API-driven
3636+3737+### Non-Requirements
3838+3939+- Web interface for reading feeds
4040+- User accounts or multi-user support (single user)
4141+- Read/unread state synchronization
4242+- Mobile push notifications (ReadKit handles this)
4343+- Feed discovery or search features
4444+- Complex filtering or tagging
4545+4646+### Success Criteria
4747+4848+ReadKit can connect, fetch all feeds (RSS + newsletters), and display items - all without touching the server's terminal. Client maintains its own read state locally.
4949+5050+## Architecture overview
5151+5252+Looking at ReadKit's supported services, **Fever API** is the simplest to implement - it's a straightforward JSON API that many RSS readers support.
5353+5454+## Implementation Plan
5555+5656+### Architecture
5757+5858+```
5959+┌─────────────┐
6060+│ ReadKit │──────HTTP───────┐
6161+│ (iOS) │ │
6262+└─────────────┘ ▼
6363+ ┌──────────────┐
6464+ │ Rust Server │
6565+ │ (axum) │
6666+ └──────┬───────┘
6767+ │
6868+ ┌───────────┼───────────┐
6969+ ▼ ▼ ▼
7070+ ┌─────────┐ ┌─────────┐ ┌─────────┐
7171+ │ SQLite │ │ Fetcher│ │ Email │
7272+ │ DB │ │ (cron) │ │ Handler │
7373+ └─────────┘ └─────────┘ └─────────┘
7474+```
7575+7676+### Tech Stack
7777+7878+| Component | Choice | Why |
7979+| ------------- | ------------------------ | ---------------------------------------- |
8080+| Web framework | **axum** | Ergonomic, Tower ecosystem, tokio-native |
8181+| DB | **SQLite** | Simple, NixOS-friendly, plenty for RSS |
8282+| RSS parsing | **feed-rs** | Best maintained Rust RSS/Atom parser |
8383+| HTTP client | **reqwest** | Standard choice |
8484+| Email parsing | **mail-parser** | Clean MIME parsing |
8585+| Scheduler | **tokio-cron-scheduler** | Background feed fetching |
8686+8787+### Phase 1: Core RSS Aggregator (1-2 days)
8888+8989+**DB Schema:**
9090+9191+```sql
9292+CREATE TABLE feeds (
9393+ id INTEGER PRIMARY KEY,
9494+ url TEXT UNIQUE NOT NULL,
9595+ title TEXT,
9696+ last_fetched INTEGER
9797+);
9898+9999+CREATE TABLE items (
100100+ id INTEGER PRIMARY KEY,
101101+ feed_id INTEGER,
102102+ guid TEXT UNIQUE NOT NULL,
103103+ title TEXT,
104104+ url TEXT,
105105+ content TEXT,
106106+ published INTEGER,
107107+ is_read BOOLEAN DEFAULT 0
108108+);
109109+```
110110+111111+**Fever API endpoints to implement:**
112112+113113+- `GET /fever/?api` - Returns groups, feeds, items
114114+- `POST /fever/?api&mark=item&as=read&id=X` - Mark as read
115115+- Auth via `?api_key=<hash>`
116116+117117+**Tasks:**
118118+119119+1. Set up axum server with Fever routes
120120+2. RSS fetcher that polls feeds every 15min
121121+3. Basic dedup (by GUID)
122122+4. Serve items via Fever JSON format
123123+124124+### Phase 2: Email-to-RSS (1 day)
125125+126126+**Approach:** Generate unique email addresses per "feed"
127127+128128+```
129129+newsletter-<uuid>@yourdomain.com
130130+```
131131+132132+**Flow:**
133133+134134+1. User creates a newsletter "feed" → get email address
135135+2. Forward newsletters to that address (via email forwarding rules or MX records)
136136+3. Server receives email via SMTP → parse → store as RSS item
137137+4. ReadKit sees it as another feed
138138+139139+**Email handler:**
140140+141141+```rust
142142+// Parse incoming email
143143+let parsed = mail_parser::Message::parse(&raw_email)?;
144144+145145+// Extract links from HTML body
146146+let html = parsed.html_body()?;
147147+let links = extract_links(html); // scraper crate
148148+149149+// Create RSS item
150150+let item = Item {
151151+ title: parsed.subject(),
152152+ content: format_links_as_html(links),
153153+ url: links[0], // first link as canonical
154154+ published: parsed.date(),
155155+ ...
156156+};
157157+```
158158+159159+### Phase 3: NixOS Module (half day)
160160+161161+```nix
162162+# /etc/nixos/rss-reader.nix
163163+{ config, pkgs, ... }:
164164+165165+{
166166+ systemd.services.rss-reader = {
167167+ description = "Custom RSS Reader";
168168+ after = [ "network.target" ];
169169+ wantedBy = [ "multi-user.target" ];
170170+171171+ serviceConfig = {
172172+ ExecStart = "${pkgs.rss-reader}/bin/rss-reader";
173173+ User = "rss";
174174+ WorkingDirectory = "/data/rss";
175175+ Restart = "always";
176176+ };
177177+ };
178178+179179+ users.users.rss = {
180180+ isSystemUser = true;
181181+ group = "rss";
182182+ home = "/data/rss";
183183+ };
184184+185185+ networking.firewall.allowedTCPPorts = [ 8080 ];
186186+}
187187+```
188188+189189+### Directory Structure
190190+191191+```
192192+rss-reader/
193193+├── Cargo.toml
194194+├── src/
195195+│ ├── main.rs
196196+│ ├── api/
197197+│ │ ├── fever.rs # Fever API implementation
198198+│ │ └── mod.rs
199199+│ ├── db/
200200+│ │ ├── models.rs
201201+│ │ └── mod.rs
202202+│ ├── fetcher/
203203+│ │ ├── rss.rs # RSS/Atom fetching
204204+│ │ └── email.rs # Email parsing
205205+│ └── lib.rs
206206+├── migrations/
207207+│ └── 001_initial.sql
208208+└── default.nix # Nix package definition
209209+```
210210+211211+### MVP Feature Checklist
212212+213213+**Feed Management:**
214214+215215+- [ ] Add RSS/Atom feed by URL
216216+- [ ] Auto-fetch every 15min
217217+- [ ] Dedupe by GUID
218218+219219+**Reading:**
220220+221221+- [ ] Fever API `/fever/?api` endpoint
222222+- [ ] Mark as read/unread
223223+- [ ] Basic auth (api_key in config)
224224+225225+**Newsletter:**
226226+227227+- [ ] Generate unique email per feed
228228+- [ ] SMTP server (or webhook receiver)
229229+- [ ] Parse HTML → extract links
230230+- [ ] Store as RSS item
231231+232232+**NixOS:**
233233+234234+- [ ] systemd service
235235+- [ ] SQLite in `/data/rss`
236236+- [ ] Reverse proxy via Caddy (already have this)
237237+238238+### Quick Start Commands
239239+240240+```nix
241241+# In your NixOS config
242242+virtualisation.oci-containers.containers.rss-reader = {
243243+ image = "ghcr.io/yourusername/rss-reader:latest";
244244+ ports = [ "127.0.0.1:8080:8080" ];
245245+ volumes = [ "/data/rss:/data" ];
246246+};
247247+```
248248+249249+Then point ReadKit to `https://rss.tymek.me/fever/` with your API key.
250250+251251+Want me to start with a basic `Cargo.toml` and skeleton code for phase 1?
+34
migrations/001_initial.sql
···11+CREATE TABLE IF NOT EXISTS groups (
22+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33+ name TEXT NOT NULL UNIQUE
44+);
55+66+CREATE TABLE IF NOT EXISTS favicons (
77+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88+ data TEXT NOT NULL
99+);
1010+1111+CREATE TABLE IF NOT EXISTS feeds (
1212+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1313+ url TEXT NOT NULL UNIQUE,
1414+ title TEXT NOT NULL DEFAULT '',
1515+ site_url TEXT NOT NULL DEFAULT '',
1616+ favicon_id INTEGER REFERENCES favicons(id),
1717+ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
1818+ last_fetched_at INTEGER
1919+);
2020+2121+CREATE TABLE IF NOT EXISTS items (
2222+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2323+ feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
2424+ guid TEXT NOT NULL UNIQUE,
2525+ title TEXT NOT NULL DEFAULT '',
2626+ author TEXT NOT NULL DEFAULT '',
2727+ url TEXT NOT NULL DEFAULT '',
2828+ content TEXT NOT NULL DEFAULT '',
2929+ published_at INTEGER,
3030+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
3131+);
3232+3333+CREATE INDEX IF NOT EXISTS idx_items_feed_id ON items(feed_id);
3434+CREATE INDEX IF NOT EXISTS idx_items_guid ON items(guid);