···11-# Code Review: Herald RSS-to-Email Project
22-33----
44-55-# Herald - Issue Groups & Priorities
66-77-## **P0 - Critical (Security & Data Integrity)** ✅ COMPLETE
88-99-- ✅ #14: No Rate Limiting (SSH, SCP, web, email)
1010-- ✅ #15: Token Generation (verify crypto/rand usage)
1111-1212-## **P1 - High (Performance & Reliability)** ✅ COMPLETE
1313-1414-- ✅ #8: N+1 Query Problem (batch operations)
1515-- ✅ #26: No Cleanup of Old seen_items (6 month cleanup job)
1616-- ✅ #23: Missing Graceful Shutdown for Scheduler (panic recovery)
1717-1818-## **P2 - Medium (Code Quality & UX)** ✅ COMPLETE
1919-2020-### Group A: Input Validation
2121-2222-- ✅ #16: No SMTP Auth Validation
2323-- ✅ #27: No Feed Validation on Upload
2424-- ✅ #37: No Cron Validation at Upload
2525-- ✅ #36: No Max File Size on SCP Upload
2626-2727-### Group B: Performance Tuning
2828-2929-- ✅ #9: No Prepared Statements
3030-- ✅ #10: Inefficient Sorting in Handlers
3131-- ✅ #11: No HTTP Caching Headers
3232-3333-## **P3 - Low (Nice to Have)** ✅ COMPLETE
3434-3535-### Group C: Observability
3636-3737-- ✅ #24: No Metrics/Observability
3838-- ✅ #35: HTTP Server Doesn't Log Requests
3939-- ✅ #22: Inconsistent Logging Levels
4040-4141-### Group D: Architecture & Scalability
4242-4343-- #30: Scheduler Interval is Fixed
4444-- #31: No Pagination on Feed Endpoints
4545-4646-### Group E: Code Hygiene
4747-4848-- ⚠️ #3: Context Timeout Duplication (not an issue)
4949-- ✅ #19: Magic Numbers
5050-- ✅ #18: Error Wrapping Inconsistency (already consistent)
5151-- ✅ #21: Unused Context Parameter
5252-- ✅ #33-34: Minor Code Cleanup
5353-5454-### Group F: Documentation
5555-5656-- ✅ #28: Inconsistent Command Help
5757-- ✅ #29: Config Example Doesn't Match Defaults
5858-5959-### Group G: Testing
6060-6161-- #20: No Tests
6262-6363----
6464-6565-## **Critical Issues - COMPLETED** ✅
6666-6767-### 1. **Database Connection Pool Not Configured** ✅
6868-6969-**Fixed:** Added WAL mode, busy timeout, and connection pool limits in `store/db.go:16-18`
7070-7171-### 2. **Code Duplication in Scheduler** ✅
7272-7373-**Fixed:** Refactored into shared `collectNewItems` and `sendDigestAndMarkSeen` helper methods
7474-7575-### 3. **No Context Timeout on HTTP Requests** 🔄
7676-7777-**Location:** `scheduler/fetch.go:41-43`
7878-7979-While you set a 30s timeout on the context, the HTTP client also has a separate 30s timeout. If the context times out first, the HTTP client won't respect it immediately.
8080-8181-**Fix:** Use context-aware client without separate timeout.
8282-8383-### 4. **Missing Index on Configs Active Status** ✅
8484-8585-**Fixed:** Added partial index in `store/db.go:93`
8686-8787-### 5. **Race Condition in seen_items** ✅
8888-8989-**Fixed:** Using transactions to mark items seen before email send
9090-9191-### 6. **Unbounded Memory Growth in Feed Fetching** ✅
9292-9393-**Fixed:** Added semaphore limiting to 10 concurrent fetches in `scheduler/fetch.go`
9494-9595-### 7. **Silent Failure on Email Send** ✅
9696-9797-**Fixed:** Items only marked seen after successful email via transaction commit
9898-9999-### 14. **No Rate Limiting** ✅
100100-101101-**Fixed:** Added comprehensive rate limiting using `golang.org/x/time/rate`:
102102-- Created reusable `ratelimit.Limiter` with token bucket algorithm (`ratelimit/limiter.go`)
103103-- Web handler middleware: 10 req/sec, burst of 20 per IP (`web/server.go:65-77`)
104104-- SSH authentication: 5 req/sec, burst of 10 per fingerprint (`ssh/server.go:96-101`)
105105-- SCP uploads: 5 req/sec, burst of 10 per user (`ssh/scp.go:107-110`)
106106-- Email sending: 1 per minute per user (`scheduler/scheduler.go:207-210`)
107107-- Added 1MB max file size limit for SCP uploads (`ssh/scp.go:112-115`)
108108-- Rate limiter automatically cleans up inactive entries every 5 minutes
109109-110110-### 15. **Token Generation Not Cryptographically Secure** ✅
111111-112112-**Already secure:** Confirmed using `crypto/rand` in `store/unsubscribe.go:14`
113113-114114----
115115-116116-## **Performance Issues**
117117-118118-### 8. **N+1 Query Problem** ✅
119119-120120-**Location:** Multiple locations
121121-122122-- `web/handlers.go:99-103` - Gets feeds for each config in a loop
123123-- `scheduler/scheduler.go` - Checks each item individually for seen status
124124-125125-**Fixed:**
126126-- Added `GetSeenGUIDs()` batch method in `store/items.go` to check multiple items at once
127127-- Added `GetFeedsByConfigs()` batch method in `store/feeds.go` to fetch feeds for multiple configs
128128-- Updated `scheduler/scheduler.go:collectNewItems()` to use batch GUID checking
129129-- Updated `web/handlers.go` dashboard handler to batch fetch all feeds
130130-131131-### 9. **No Prepared Statements** ✅
132132-133133-**Location:** All store methods
134134-135135-**Fixed:** Added prepared statements for 7 most frequent queries:
136136-- `store/db.go:11-73` - Added `preparedStmts` struct and `prepareStatements()` method
137137-- Prepared statements for: markItemSeen, isItemSeen, getSeenItems, getConfig, updateConfigRun, updateFeedMeta, cleanupSeenItems
138138-- Updated store methods in `items.go`, `configs.go`, and `feeds.go` to use prepared statements
139139-- Statements closed on database close for proper cleanup
140140-141141-### 10. **Inefficient Sorting in Handlers** ✅
142142-143143-**Location:** `web/handlers.go:231-235` and `326-330`
144144-145145-**Fixed:**
146146-- Added `rssItemWithTime` and `jsonFeedItemWithTime` wrapper structs in `web/handlers.go`
147147-- Parse timestamps once into `time.Time` field when building items
148148-- Sort by parsed `time.Time` directly (single comparison)
149149-- Convert back to original structs for encoding
150150-- Eliminates repeated `time.Parse()` calls during sorting
151151-152152-### 11. **No HTTP Caching Headers** ✅
153153-154154-**Location:** `web/handlers.go` - all feed handlers
155155-156156-**Fixed:**
157157-- Added `Cache-Control: public, max-age=300` (5 minute cache)
158158-- Added `ETag` header with format "fingerprint-timestamp"
159159-- Added `Last-Modified` header from cfg.LastRun
160160-- Added conditional request handling for `If-None-Match` (ETag)
161161-- Added conditional request handling for `If-Modified-Since`
162162-- Returns 304 Not Modified when appropriate
163163-- Applied to both RSS and JSON feed handlers in `web/handlers.go`
164164-165165-### 12. **Database Migration Runs on Every Connection** 🔄
166166-167167-**Location:** `store/db.go:26-28`
168168-169169-Migration runs inside `Open()`, which happens once at startup. However, `Migrate()` is also exposed and called separately in `main.go:160`. The schema execution uses `CREATE TABLE IF NOT EXISTS` which is fine, but it's still unnecessary work.
170170-171171----
172172-173173-## **Security Issues**
174174-175175-### 13. **Missing Input Validation on Email Addresses** ✅
176176-177177-**Already implemented:** Using `net/mail.ParseAddress()` in `config/validate.go:24-26`
178178-179179-### 16. **No SMTP Auth Validation** ✅
180180-181181-**Location:** `email/send.go:102-105`
182182-183183-**Fixed:**
184184-- Added `ValidateConfig()` method in `email/send.go:33-95`
185185-- Validates SMTP connectivity and authentication at startup
186186-- Port 465: Uses implicit TLS connection
187187-- Port 587: Connects, then calls STARTTLS before authentication
188188-- Called from `main.go:172-175` after mailer creation
189189-- Returns error on connection, TLS, or auth failure
190190-191191-### 17. **SQL Injection Potential in UPSERT** 💉
192192-193193-**Location:** `store/items.go:29-33`
194194-195195-While using parameterized queries (good!), the `ON CONFLICT` clause should explicitly name the conflict target for clarity and safety:
196196-197197-```sql
198198-ON CONFLICT(feed_id, guid) DO UPDATE SET ...
199199-```
200200-201201-(Actually you already do this correctly, but worth noting for other queries)
202202-203203----
204204-205205-## **Code Quality Issues**
206206-207207-### 18. **Error Wrapping Inconsistency** ✅
208208-209209-**Analysis:** All fmt.Errorf calls already follow consistent "verb: %w" pattern with colon. No changes needed.
210210-211211-### 19. **Magic Numbers** 🎩
212212-213213-**Location:** Multiple
214214-215215-- `scheduler/scheduler.go:84` - hardcoded 3 months
216216-- `scheduler/scheduler.go:148-150` - hardcoded 5 items threshold
217217-- `web/handlers.go:238` and `332` - hardcoded 100 items limit
218218-- `scheduler/fetch.go:41` - hardcoded 30s timeout
219219-220220-**Fix:** Extract to constants or config.
221221-222222-### 20. **No Tests** 🧪
223223-224224-**Location:** Entire codebase
225225-226226-Zero test coverage. Critical business logic (cron parsing, config parsing, email rendering) is untested.
227227-228228-### 21. **Unused Context Parameter** ✅
229229-230230-**Fixed:**
231231-- Removed unused ctx parameter from `store/db.go:Migrate()` method
232232-- Updated call site in `main.go` to match new signature
233233-234234-### 22. **Inconsistent Logging Levels** ✅
235235-236236-**Fixed:**
237237-- Established clear criteria: Error for critical failures, Warn for expected/recoverable failures
238238-- Changed 23 log calls from Error to Warn:
239239- - Database read operations in web handlers (14 calls)
240240- - Template rendering failures (6 calls)
241241- - Response encoding failures (2 calls)
242242- - Delete token operation (1 call)
243243-- All logging now follows consistent severity guidelines
244244-245245-### 23. **Missing Graceful Shutdown for Scheduler** ✅
246246-247247-**Location:** `main.go:194-197`
248248-249249-**Fixed:**
250250-- Added panic recovery with defer in `scheduler/scheduler.go:tick()`
251251-- Added panic recovery wrapper in `main.go` scheduler goroutine
252252-- Added panic recovery in cleanup job
253253-254254----
255255-256256-## **Missing Features**
257257-258258-### 24. **No Metrics/Observability** ✅
259259-260260-**Fixed:**
261261-- Added `/metrics` endpoint returning JSON with system and application metrics in `web/metrics.go`
262262-- Metrics include: uptime, goroutines, memory stats, request counts, emails sent, feeds fetched, items seen, active configs, errors, rate limit hits
263263-- Added `/health` endpoint for health checks
264264-- Metrics tracked via atomic counters for thread safety
265265-- Integrated into web server with proper HTTP headers
266266-267267-### 25. **No Email Validation on Successful Send** ✅
268268-269269-You log `"email sent"` but don't verify SMTP actually accepted it (some SMTP servers queue and fail later).
270270-271271-### 26. **No Cleanup of Old seen_items** ✅
272272-273273-**Fixed:**
274274-- Added `CleanupOldSeenItems()` method in `store/items.go` to delete items older than specified duration
275275-- Added cleanup ticker in `scheduler/scheduler.go:Start()` that runs every 24 hours
276276-- Cleanup runs on startup and then daily, removing items older than 6 months
277277-- Added logging for cleanup operations showing number of items deleted
278278-279279-### 27. **No Feed Validation on Upload** ✅
280280-281281-**Fixed:**
282282-- Added `ValidateFeedURLs()` function in `config/validate.go:1-48`
283283-- Fetches and parses each feed URL using gofeed parser
284284-- Uses 10 second context timeout, 5 second HTTP client timeout
285285-- Returns error if feed unreachable, returns non-200, or fails to parse
286286-- Called from `ssh/scp.go:136-147` during config upload
287287-- Validation happens after basic config validation, before database operations
288288-289289----
290290-291291-## **Documentation Issues**
292292-293293-### 28. **Inconsistent Command Help** ✅
294294-295295-**Fixed:**
296296-- Updated welcome message in `ssh/server.go:168-177` to include all available commands
297297-- Added missing `activate` and `deactivate` commands to help text
298298-- Welcome message now matches actual available commands in `ssh/commands.go`
299299-300300-### 29. **Config Example Doesn't Match Defaults** ✅
301301-302302-**Fixed:**
303303-- Updated `README.md:89` to show `inline` default as `false` (matching code in `config/parse.go:27`)
304304-- Documentation now consistent with actual code behavior
305305-306306----
307307-308308-## **Architectural Concerns**
309309-310310-### 30. **Scheduler Interval is Fixed** ⏲️
311311-312312-**Location:** `main.go:172`
313313-314314-60-second interval is hardcoded. This doesn't scale well—if you have thousands of users, checking every 60 seconds is wasteful. Consider event-driven scheduling with a priority queue.
315315-316316-### 31. **No Pagination on Feed Endpoints** 📄
317317-318318-**Location:** `web/handlers.go:238` and `332`
319319-320320-Hardcoded limit of 100 items. Users can't access older items.
321321-322322-### 32. **No Transaction for Config Update** ✅
323323-324324-**Fixed:** Config upload now uses transactions in `ssh/scp.go:134-161`
325325-326326----
327327-328328-## **Minor Issues**
329329-330330-33. ✅ **Unused `getCommitHash()` function** - Fixed: Inlined into `main.go:179-195`
331331-34. ✅ **Inconsistent fingerprint shortening** - Fixed: Standardized to 8 chars using `shortFingerprintLen` constant in `web/handlers.go`
332332-35. ✅ **HTTP server doesn't log requests** - Fixed: Added logging middleware in `web/server.go:90-116`
333333-36. ✅ **No max file size on SCP upload** - Fixed: 1MB limit in `ssh/scp.go:112-115`
334334-37. ✅ **No validation on cron expressions at upload time** - Already implemented via `config.Validate()` and `calculateNextRun()` in config validation
335335-336336----
337337-338338-## **Positive Notes** ✅
339339-340340-- Good use of Context for cancellation
341341-- Proper use of foreign keys and CASCADE
342342-- Clean separation of concerns (store/scheduler/ssh/web)
343343-- Good use of Charm libraries
344344-- ETag/Last-Modified support for feed fetching
345345-- Unsubscribe functionality implemented
346346-- SQL injection protection with parameterized queries
347347-- Config file validation before accepting uploads
348348-349349----
350350-351351-## **Priority Fixes**
352352-353353-### **High Priority (Fix ASAP):**
354354-355355-1. ✅ Database connection pool configuration (#1)
356356-2. ✅ Race condition in seen_items (#5)
357357-3. ✅ Silent failure on email send (#7)
358358-4. ✅ No rate limiting (#14)
359359-5. ✅ No transaction for config updates (#32)
360360-6. ✅ Token generation security (#15)
361361-7. ✅ Max file size on SCP upload (#36)
362362-363363-### **Medium Priority:**
364364-365365-6. ✅ Code duplication in scheduler (#2)
366366-7. N+1 query problems (#8)
367367-8. ✅ Unbounded feed fetching concurrency (#6)
368368-9. ✅ Missing input validation (#13)
369369-10. No cleanup of old data (#26)
370370-371371-### **Low Priority (Technical Debt):**
372372-373373-11. Add tests (#20)
374374-12. Add metrics (#24)
375375-13. Extract magic numbers (#19)
376376-14. Add HTTP caching (#11)
-434
spec.md
···11-# rss2email-ssh
22-33-A minimal, SSH-powered RSS to email service. Upload a feed config via SCP, get email digests on a schedule.
44-55-## Goals
66-77-- **Simple**: Single binary, SQLite storage, no external dependencies
88-- **SSH-native**: Auth via SSH keys, configure via SCP or interactive TUI
99-- **Pico-compatible**: Same config format as pico.sh/feeds
1010-- **Charm-powered**: Built with the Charm ecosystem
1111-1212-## Architecture
1313-1414-```
1515- ┌─────────────────────────────────────────┐
1616- │ rss2email-ssh │
1717- ├─────────────────────────────────────────┤
1818- │ │
1919- SSH key auth │ ┌─────────────┐ ┌──────────────┐ │
2020- ─────────────────┼─▶│ wish │───▶│ bubbletea │ │
2121- │ │ (SSH srv) │ │ (TUI) │ │
2222- │ └─────────────┘ └──────────────┘ │
2323- │ │ │
2424- SCP upload │ ▼ │
2525- ─────────────────┼─▶┌─────────────┐ ┌──────────────┐ │
2626- │ │ wish/scp │───▶│ SQLite │ │
2727- │ │ (files) │ │ (store) │ │
2828- │ └─────────────┘ └──────┬───────┘ │
2929- │ │ │
3030- │ ▼ │
3131- │ ┌──────────────┐ │
3232- │ │ Scheduler │ │
3333- │ │ (cron loop) │ │
3434- │ └──────┬───────┘ │
3535- │ │ │
3636- │ ▼ │
3737- │ ┌──────────────┐ │
3838- │ │ SMTP out │──────▶ Email
3939- │ └──────────────┘ │
4040- └─────────────────────────────────────────┘
4141-```
4242-4343-## Charm Libraries
4444-4545-| Library | Purpose |
4646-|---------|---------|
4747-| [wish](https://github.com/charmbracelet/wish) | SSH server, middleware, SCP handling |
4848-| [lipgloss](https://github.com/charmbracelet/lipgloss) | Styling CLI output |
4949-| [log](https://github.com/charmbracelet/log) | Structured logging |
5050-5151-## Config Format
5252-5353-Pico-compatible plaintext format. Users upload as `feeds.txt` (or any `.txt` file):
5454-5555-```text
5656-=: email you@example.com
5757-=: cron 0 8 * * *
5858-=: digest true
5959-=: inline false
6060-=> https://blog.example.com/rss
6161-=> https://news.ycombinator.com/rss
6262-=> https://lobste.rs/rss "Lobsters"
6363-```
6464-6565-### Directives
6666-6767-| Directive | Required | Description |
6868-|-----------|----------|-------------|
6969-| `=: email <addr>` | Yes | Recipient email address |
7070-| `=: cron <expr>` | Yes | Standard cron expression (5 fields) |
7171-| `=: digest <bool>` | No | Combine all items into one email (default: true) |
7272-| `=: inline <bool>` | No | Include article content in email (default: false) |
7373-| `=> <url> ["name"]` | Yes (1+) | RSS/Atom feed URL, optional display name |
7474-7575-Note: Items are filtered to only include those published within the last 3 months.
7676-7777-## Data Model
7878-7979-### SQLite Schema
8080-8181-```sql
8282--- Users identified by SSH public key fingerprint
8383-CREATE TABLE users (
8484- id INTEGER PRIMARY KEY,
8585- pubkey_fp TEXT UNIQUE NOT NULL, -- SHA256 fingerprint
8686- pubkey TEXT NOT NULL, -- Full public key
8787- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
8888-);
8989-9090--- Feed configurations (one per uploaded file)
9191-CREATE TABLE configs (
9292- id INTEGER PRIMARY KEY,
9393- user_id INTEGER NOT NULL REFERENCES users(id),
9494- filename TEXT NOT NULL,
9595- email TEXT NOT NULL,
9696- cron_expr TEXT NOT NULL,
9797- digest BOOLEAN DEFAULT TRUE,
9898- inline_content BOOLEAN DEFAULT FALSE,
9999- raw_text TEXT NOT NULL, -- Original file contents
100100- last_run DATETIME,
101101- next_run DATETIME,
102102- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
103103- UNIQUE(user_id, filename)
104104-);
105105-106106--- Individual feeds within a config
107107-CREATE TABLE feeds (
108108- id INTEGER PRIMARY KEY,
109109- config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
110110- url TEXT NOT NULL,
111111- name TEXT, -- Optional display name
112112- last_fetched DATETIME,
113113- etag TEXT, -- For conditional requests
114114- last_modified TEXT
115115-);
116116-117117--- Seen items for deduplication
118118-CREATE TABLE seen_items (
119119- id INTEGER PRIMARY KEY,
120120- feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
121121- guid TEXT NOT NULL, -- Item GUID or link hash
122122- title TEXT,
123123- link TEXT,
124124- seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
125125- UNIQUE(feed_id, guid)
126126-);
127127-```
128128-129129-## SSH Interface
130130-131131-### Authentication
132132-133133-- Public key auth only (no passwords)
134134-- First connection auto-registers user by key fingerprint
135135-- Config file for allowed keys (optional, default: allow all)
136136-137137-```yaml
138138-# config.yaml
139139-allow_all_keys: true # or false to require explicit registration
140140-allowed_keys:
141141- - "ssh-ed25519 AAAA... user@host"
142142-```
143143-144144-### SCP Upload
145145-146146-```bash
147147-# Upload a feed config
148148-scp feeds.txt user@rss.example.com:
149149-150150-# Upload with custom name
151151-scp feeds.txt user@rss.example.com:work-feeds.txt
152152-153153-# Download existing config
154154-scp user@rss.example.com:feeds.txt .
155155-```
156156-157157-### CLI Commands
158158-159159-Via SSH command execution:
160160-161161-```bash
162162-# List configs
163163-ssh rss.example.com ls
164164-165165-# Show config contents
166166-ssh rss.example.com cat feeds.txt
167167-168168-# Delete a config
169169-ssh rss.example.com rm feeds.txt
170170-171171-# Run immediately (don't wait for cron)
172172-ssh rss.example.com run feeds.txt
173173-174174-# Show recent activity
175175-ssh rss.example.com logs
176176-```
177177-178178-## Web Interface
179179-180180-Minimal brutalist web UI (à la pierre.computer). Serves two purposes:
181181-182182-1. **Public RSS feed** - Aggregated feed of all items for a user
183183-2. **Config view** - Shows the raw config file
184184-185185-### Routes
186186-187187-```
188188-GET / # Landing page
189189-GET /{fingerprint} # User's public page
190190-GET /{fingerprint}/feed.xml # Aggregated RSS feed
191191-GET /{fingerprint}/feed.json # JSON Feed format
192192-GET /{fingerprint}/{config} # Raw config file
193193-```
194194-195195-### User Page
196196-197197-```
198198-/{fingerprint}
199199-```
200200-201201-```
202202-RSS2EMAIL █
203203-204204-~~~
205205-206206-USER: a]D+3xKL...
207207-STATUS: ONLINE
208208-NEXT RUN: 2025-01-09 08:00 UTC
209209-210210-~~~
211211-212212-CONFIGS:
213213- - [feeds.txt](/abc123/feeds.txt) (3 feeds)
214214- - [work.txt](/abc123/work.txt) (5 feeds)
215215-216216-FEEDS:
217217- - [RSS](/abc123/feed.xml)
218218- - [JSON](/abc123/feed.json)
219219-```
220220-221221-### Aggregated Feed
222222-223223-`/{fingerprint}/feed.xml` returns a standard RSS 2.0 feed containing all items from all the user's subscribed feeds - essentially a "river of news" feed they can subscribe to elsewhere.
224224-225225-### Styling
226226-227227-```css
228228-* {
229229- font-family: monospace;
230230- background: #000;
231231- color: #fff;
232232-}
233233-234234-a {
235235- color: #fff;
236236- text-decoration: underline;
237237-}
238238-239239-pre {
240240- white-space: pre-wrap;
241241-}
242242-```
243243-244244-Single HTML template, no JS, ~20 lines of CSS.
245245-246246-## Scheduler
247247-248248-Background goroutine that:
249249-250250-1. Every 60 seconds, queries `configs` where `next_run <= now()`
251251-2. For each due config:
252252- - Fetch all feeds (parallel, with timeout)
253253- - Filter to unseen items (check `seen_items` table)
254254- - If new items exist, render and send email
255255- - Update `last_run`, calculate and set `next_run`
256256- - Insert new items into `seen_items`
257257-3. Handle errors gracefully (log, increment retry counter)
258258-259259-### Cron Parsing
260260-261261-Use [adhocore/gronx](https://github.com/adhocore/gronx) for cron expression parsing (same as pico).
262262-263263-## Email Rendering
264264-265265-### Digest Mode (default)
266266-267267-One email per config run containing all new items:
268268-269269-```
270270-Subject: RSS Digest: 5 new items
271271-From: rss@example.com
272272-To: you@example.com
273273-Content-Type: multipart/alternative
274274-275275-──────────────────────────────────────
276276-Lobsters (2 new)
277277-──────────────────────────────────────
278278-279279-▸ Show HN: I built a thing
280280- https://example.com/article1
281281-282282-▸ Why Rust is great
283283- https://example.com/article2
284284-285285-──────────────────────────────────────
286286-Example Blog (3 new)
287287-──────────────────────────────────────
288288-289289-▸ My latest post
290290- https://blog.example.com/post1
291291-292292-...
293293-```
294294-295295-### Individual Mode
296296-297297-One email per item (when `digest: false`).
298298-299299-### Templates
300300-301301-Use Go `html/template` and `text/template` for HTML and plaintext versions.
302302-303303-## Project Structure
304304-305305-```
306306-rss2email-ssh/
307307-├── main.go # Entry point, CLI flags
308308-├── ssh/
309309-│ ├── server.go # wish server setup, middleware
310310-│ ├── scp.go # SCP upload/download handlers
311311-│ └── commands.go # ls, rm, cat, run, logs
312312-├── web/
313313-│ ├── server.go # HTTP server
314314-│ ├── handlers.go # Route handlers
315315-│ └── templates/
316316-│ ├── index.html
317317-│ ├── user.html
318318-│ └── style.css
319319-├── config/
320320-│ ├── parse.go # Parse pico-format config files
321321-│ └── validate.go # Validation logic
322322-├── store/
323323-│ ├── db.go # SQLite connection, migrations
324324-│ ├── users.go # User CRUD
325325-│ ├── configs.go # Config CRUD
326326-│ └── items.go # Seen items tracking
327327-├── scheduler/
328328-│ ├── scheduler.go # Main loop
329329-│ └── fetch.go # RSS fetching with gofeed
330330-├── email/
331331-│ ├── render.go # Template rendering
332332-│ ├── send.go # SMTP sending
333333-│ └── templates/
334334-│ ├── digest.html
335335-│ └── digest.txt
336336-├── go.mod
337337-├── go.sum
338338-└── config.example.yaml
339339-```
340340-341341-## Configuration
342342-343343-Server configuration via YAML or environment variables:
344344-345345-```yaml
346346-# config.yaml
347347-host: 0.0.0.0
348348-port: 2222
349349-350350-# SSH host keys (generated on first run if missing)
351351-host_key_path: ./host_key
352352-353353-# Database
354354-db_path: ./rss2email.db
355355-356356-# SMTP
357357-smtp:
358358- host: smtp.example.com
359359- port: 587
360360- user: sender@example.com
361361- pass: ${SMTP_PASS} # Env var substitution
362362- from: rss@example.com
363363-364364-# Auth
365365-allow_all_keys: true
366366-```
367367-368368-## Dependencies
369369-370370-```go
371371-require (
372372- github.com/charmbracelet/wish v1.4.0
373373- github.com/charmbracelet/lipgloss v1.0.0
374374- github.com/charmbracelet/log v0.4.0
375375- github.com/mmcdole/gofeed v1.3.0
376376- github.com/adhocore/gronx v1.19.0
377377- github.com/mattn/go-sqlite3 v1.14.24
378378- gopkg.in/yaml.v3 v3.0.1
379379-)
380380-```
381381-382382-## Implementation Phases
383383-384384-### Phase 1: Core (MVP)
385385-386386-- [ ] SSH server with key auth (wish)
387387-- [ ] SCP upload/download
388388-- [ ] Config parsing (pico format)
389389-- [ ] SQLite storage
390390-- [ ] Basic scheduler
391391-- [ ] Plaintext email sending
392392-393393-### Phase 2: Polish
394394-395395-- [ ] Web UI (brutalist style)
396396-- [ ] Aggregated RSS/JSON feeds
397397-- [ ] HTML emails
398398-- [ ] Conditional fetching (ETag/Last-Modified)
399399-- [ ] `logs` command
400400-401401-### Phase 3: Nice-to-have
402402-403403-- [ ] OPML import/export
404404-- [ ] Feed discovery (find RSS from URL)
405405-- [ ] Webhook notifications
406406-- [ ] Metrics endpoint
407407-408408-## Example Session
409409-410410-```bash
411411-# First time setup - just SSH in
412412-$ ssh rss.example.com
413413-Welcome! Your account has been created.
414414-Upload a config with: scp feeds.txt rss.example.com:
415415-416416-# Upload a config
417417-$ cat feeds.txt
418418-=: email me@example.com
419419-=: cron 0 8 * * *
420420-=> https://lobste.rs/rss
421421-422422-$ scp feeds.txt rss.example.com:
423423-feeds.txt 100% 89 12.3KB/s 00:00
424424-Config saved! Next run: tomorrow at 8:00 AM
425425-426426-# Check status
427427-$ ssh rss.example.com ls
428428-feeds.txt 1 feed next: 8:00 AM
429429-430430-# Run immediately
431431-$ ssh rss.example.com run feeds.txt
432432-Fetched 25 items, 25 new
433433-Email sent to me@example.com
434434-```