Guide for AI agents working on this codebase.
Framo is a single-binary Rust application for creating disposable email addresses and watching inboxes for OTP codes in real time. It has two runtime modes:
- TUI mode (
framo) — Interactive terminal dashboard built withratatui+crossterm - API server mode (
framo serve) — HTTP REST + WebSocket server built withaxum
Both modes share the same email_service layer for provider communication.
cargo build # Build (dev)
cargo build --release # Build (release)
cargo clippy # Lint
cargo fmt # Format
cargo test # Run tests
framo # Launch TUI
framo serve # Start API server (default port 3000)
framo serve --port 8080 # Start API server on custom port
framo --test # Smoke test both email providerssrc/
main.rs # Entry point, subcommand routing (TUI / serve / --test)
config.rs # Constants: domains, URLs, poll interval, home dir
models.rs # EmailAccount, EmailMessage, InboxResponse
browser.rs # HTTP session with Livewire v2/v3 protocol support
utils.rs # OTP extraction, clipboard, HTML strip, date parsing, username gen
email_service/
mod.rs # Provider enum dispatch (Imail / PostInbox)
imail.rs # Imail provider (imail.edu.vn) — Livewire v2
postinbox.rs # PostInbox provider (postinbox.org) — Livewire v3
dashboard/
mod.rs # TUI event loop, terminal setup/teardown
state.rs # DashboardState, Watcher, WatchStatus, ViewMode, InputMode
events.rs # Key → Action mapping, cursor/navigation logic
actions.rs # Async actions: add, delete, refresh, clipboard, batch
jobs.rs # Background inbox polling loop (watch_inbox)
render.rs # Full TUI rendering: header, table, inbox, sidebar, status
table.rs # Watcher table row rendering
api/
mod.rs # AppState, ApiWatcher, router setup, run_server()
routes.rs # REST handlers: CRUD, domains, config, health
ws.rs # WebSocket handler, broadcast, watch_inbox_loop
main.rs
├── framo → dashboard::Dashboard::new().run()
├── framo serve → api::run_server(port)
└── framo --test → test_providers()
Two providers, both implementing the same interface via Provider enum:
| Provider | Base URL | Protocol | Domains |
|---|---|---|---|
| Imail | imail.edu.vn |
Livewire v2 | apple.edu.pl, nik.edu.pl, mailer.edu.pl |
| PostInbox | postinbox.org |
Livewire v3 | unbox.edu.pl, blogerspace.com, pulecheese.net, expertmail.cv, goodmail.cv, dokumail.biz, mailinux.me |
Provider selection is automatic based on domain via ProviderKind::from_domain().
Both providers scrape web pages built with Laravel Livewire:
- Visit homepage → extract CSRF token + Livewire component snapshots from HTML
- Send Livewire POST requests with snapshot + updates to create email / fetch inbox
- Parse JSON responses to get messages
- Imail uses
/livewire/message/{component}(v2), PostInbox uses/livewire/update(v3)
Both TUI and API use the same pattern:
SharedWatchers = Arc<Mutex<Vec<Watcher>>>— shared watcher listTaskRegistry = Arc<Mutex<HashMap<WatcherId, JoinHandle<()>>>>— background task handles- Background
tokio::spawntasks poll inbox every 3 seconds (POLL_INTERVAL) - OTP detection stops the polling loop automatically
All endpoints bind to 127.0.0.1 (localhost only, no auth).
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Health check + version |
GET |
/api/config |
Server config: version, poll interval, providers, domains |
GET |
/api/domains |
All available domains with provider info and priority flag |
POST |
/api/emails |
Create email watcher(s) |
GET |
/api/emails |
List all active watchers |
GET |
/api/emails/:id |
Get watcher detail |
PATCH |
/api/emails/:id |
Update watcher (reset_otp, stop) |
DELETE |
/api/emails/:id |
Delete single watcher |
DELETE |
/api/emails |
Delete all watchers (bulk) |
GET |
/api/emails/:id/refresh |
Force refresh inbox |
GET |
/api/emails/:id/messages |
Get watcher + message metadata |
GET |
/ws |
WebSocket connection for real-time events |
POST /api/emails — Create email(s)
// Request body (all optional)
{ "domain": "unbox.edu.pl", "username": "myuser", "count": 3 }
// Response 201
{ "created": [{ "id": 1, "email": "myuser@unbox.edu.pl" }] }GET /api/domains — List domains
{
"domains": [
{ "domain": "unbox.edu.pl", "provider": "postinbox", "base_url": "https://postinbox.org", "priority": true },
{ "domain": "apple.edu.pl", "provider": "imail", "base_url": "https://imail.edu.vn", "priority": true }
],
"total": 10
}GET /api/config — Server config
{
"version": "2.0.0",
"poll_interval_secs": 3,
"default_domain": "unbox.edu.pl",
"providers": [
{ "name": "imail", "base_url": "https://imail.edu.vn", "domains": ["nik.edu.pl", "apple.edu.pl", "mailer.edu.pl"] },
{ "name": "postinbox", "base_url": "https://postinbox.org", "domains": ["unbox.edu.pl", "blogerspace.com", "..."] }
]
}PATCH /api/emails/:id — Update watcher
// Request body
{ "action": "reset_otp" } // Reset OTP, resume watching
{ "action": "stop" } // Stop polling, mark as done
// Response 200
{ "updated": true, "id": 1, "action": "reset_otp", "email": "abc@unbox.edu.pl" }Connect to ws://127.0.0.1:3000/ws.
Server → Client events:
{"event": "init", "data": {"watchers": [...]}}
{"event": "email_creating", "data": {"id": 1, "email": "abc@unbox.edu.pl"}}
{"event": "email_created", "data": {"id": 1, "email": "abc@unbox.edu.pl"}}
{"event": "otp_detected", "data": {"id": 1, "email": "...", "otp": "123456"}}
{"event": "new_message", "data": {"id": 1, "email": "...", "from": "...", "subject": "...", "date": "..."}}
{"event": "status_changed", "data": {"id": 1, "status": "error", "error": "..."}}
{"event": "email_deleted", "data": {"id": 1, "email": "..."}}
{"event": "email_refreshing","data": {"id": 1, "email": "..."}}Client → Server commands:
{"action": "ping"} → {"event": "pong", "data": {}}
{"action": "list"} → {"event": "watchers_list", "data": {"watchers": [...]}}{
"id": 1,
"email": "abc@unbox.edu.pl",
"otp": "123456",
"messages": 3,
"status": "watching",
"provider": "postinbox",
"updated_at": 1716835200,
"error": null
}Status values: creating, watching, done, error
| Crate | Purpose |
|---|---|
tokio |
Async runtime (full features) |
axum |
HTTP + WebSocket server (api mode) |
tower-http |
CORS middleware |
wreq |
HTTP client with cookies (provider requests) |
ratatui + crossterm |
TUI rendering |
serde + serde_json |
JSON serialization |
regex |
OTP extraction, HTML parsing, CSRF token extraction |
html-escape |
HTML entity decoding |
arboard |
Clipboard access |
tracing + tracing-subscriber |
Logging to ~/.framo/logs.log |
rand |
Username generation, random domain selection |
dirs |
Home directory resolution |
anyhow |
Error handling |
futures-util |
WebSocket stream extension (.next()) |
- Error handling:
anyhow::Resulteverywhere,bail!for early returns - Async: Tokio
spawnfor background tasks,Mutexfor shared state - Logging:
tracing::info!/warn!/error!for structured logging, env filter viaFRAMO_LOG - State sharing:
Arc<Mutex<T>>pattern for watchers and task registry - Broadcast:
tokio::sync::broadcastchannel for WebSocket event distribution - Provider abstraction: Enum dispatch (
Provider::Imail/Provider::PostInbox) with shared interface - No comments in code: Follow existing style — no comments unless explicitly requested
.github/workflows/ci.yml:
- lint:
cargo fmt --check+cargo clippy -- -D warnings - build-and-test:
cargo test+cargo build --releaseon Windows, Linux, macOS
All clippy warnings must be resolved before merge. Existing warnings in dashboard/mod.rs and utils.rs are pre-existing collapsible-if patterns.
Logs are written to ~/.framo/logs.log. Control verbosity with:
FRAMO_LOG=debug framo serve # Debug logging
FRAMO_LOG=trace framo # Trace loggingAll runtime data stored in ~/.framo/:
logs.log— application logs- Future: config files, local app data