# posthorn A self-contained email pen-pal daemon for slow, self-hosted LLMs. You send an email to `model@provider.domain` over SMTP. posthorn queues an LLM completion, calls an OpenAI-compatible gateway, and delivers the reply to your maildir — readable via IMAP from any standard mail client (mutt, Thunderbird, etc.). Conversations thread correctly across round-trips. [![asciicast demo](https://asciinema.org/a/0tMVOZqGI6DIXJ9S.svg)](https://asciinema.org/a/0tMVOZqGI6DIXJ9S) ## How it works ``` ┌────────┐ SMTP ┌─────────┐ enqueue ┌─────────┐ HTTP ┌──────────┐ │ client │ ──────► │ smtp.rs │ ────────► │ llm_jobs│ ───────► │ gateway │ └────────┘ └─────────┘ └─────────┘ │ (ollama) │ ▲ └────┬─────┘ │ IMAP │ chat │ ┌─────▼─────┐ ┌────┴─────┐ read ┌─────────┐ write ┌─────────┐ poll │ worker.rs │ │ client │ ◄────── │ imap.rs │ ◄─────── │ maildir │ ◄───── │ + lettre │ └──────────┘ └─────────┘ └─────────┘ └───────────┘ ``` - **SMTP** (`src/smtp.rs`) — minimal receiver; no AUTH, no TLS. Accepts mail to `model-id@provider-id.` (with `+` standing in for `:` in the model-id, so `model+tag` reaches `model:tag`), parses headers/body, resolves the thread via `In-Reply-To`/`References`, and stores the message in maildir++. - **Worker** (`src/worker.rs`) - runs as a **short-lived child process**, one per job. The `serve` dispatcher claims pending `llm_jobs` and spawns `posthorn worker --job-id N`; the child builds the chat history from the thread, resolves the provider's gateway by name, calls it, and composes the reply email (correct `Message-ID`/`In-Reply-To`/`References` so clients thread it). - **IMAP** (`src/imap/`) — IMAP4rev1 server using `imap-codec` for spec-correct parsing and encoding. Supports SASL `AUTHENTICATE PLAIN`, `SELECT`/`EXAMINE`, `FETCH` (with `BODY.PEEK[HEADER.FIELDS ...]` subsetting), `STORE` (`\Seen`/ `\Deleted`), `EXPUNGE`, `CLOSE`, `SEARCH`, `IDLE`, and `LIST`. - **Storage** — maildir++ on disk for raw `.eml` files. ## IDLE push (live new-mail notifications) Instead of polling, `IDLE` uses `inotify` to watch the user's maildir and pushes `* N EXISTS` the instant a message lands — so mutt shows new replies without a manual refresh. No polling, kernel-driven. ## Requirements - Linux (inotify is Linux-only) - Rust (edition 2021, MSRV 1.84) - An OpenAI-compatible gateway (e.g. [Ollama](https://ollama.com)) ## Build ```sh cargo build -r ``` ## Configure posthorn reads a TOML config (figment; also overridable via `POSTHORN_*` env vars). Example (`test/config.toml.example`): ```toml [daemon] domain = "posthorn.localhost" data_dir = "test/data" [imap] host = "127.0.0.1" port = 11143 [smtp] host = "127.0.0.1" port = 11025 max_message_bytes = 10485760 [llm] default_system_prompt = "You are a helpful test assistant. Keep replies short." default_provider = "ollama" # One block per OpenAI-compatible gateway. The `name` is the subdomain used # for routing: mail to `model-id@.` is sent to that gateway. [[llm.providers]] name = "ollama" gateway_url = "http://127.0.0.1:11434" [[llm.providers]] name = "llama-cpp" gateway_url = "http://127.0.0.1:8080" # api_key_env = "LLAMACPP_API_KEY" # optional; read from env at request time [[users]] email = "user@example.com" password = "hunter2" ``` Users declared under `[[users]]` are seeded into the DB on startup. The mail address for a model is `model-id@provider-id.` - e.g. `glm-4.7-flash@ollama.posthorn.localhost` or `llama@llama-cpp.posthorn.localhost`. Each provider name must match a `[[llm.providers]]` entry; the gateway URL is resolved from config at runtime, so different providers can point at different machines. Models are discovered automatically from each provider's gateway on startup. A model tag (e.g. an Ollama quant) or a multi-segment provider name is written with `+` in place of `:` — the `+` is translated to `:` for the gateway call, and translated back on the reply address so the thread round-trips. For example `glm-4.7-flash+q8_0@ollama.posthorn.localhost` reaches model `glm-4.7-flash:q8_0`, and `hf+zai-org/GLM-5.2@ollama.posthorn.localhost` reaches `hf:zai-org/GLM-5.2`. Replies come back from the `+` form, which your mail client threads normally. ## Run ```sh # The server subcommand (`serve`) is the default and runs SMTP + IMAP plus a # dispatcher that spawns a worker child process per claimed LLM job. RUST_LOG=info target/release/posthorn --config test/config.toml.example ``` Then point an mail client at the SMTP/IMAP ports and send mail to your model. ## Client setup (mutt) ``` set ssl_starttls = no set ssl_force_tls = no set imap_user = "user@example.com" set imap_pass = "hunter2" set folder = "imap://127.0.0.1:11143" set spoolfile = "+INBOX" set smtp_url = "smtp://127.0.0.1:11025" set from = "user@example.com" set imap_idle = yes # live new-mail push ``` ## Project layout ``` src/ main.rs wiring: config -> DB -> SMTP -> IMAP -> dispatcher (serve) or single-job runner (worker --job-id N) cli.rs serve / worker subcommands config.rs figment TOML + env config; LlmConfig + per-provider entries smtp.rs SMTP receiver + recipient parsing (model@provider.domain) worker.rs runs one LLM job: builds history, resolves gateway, replies gateway.rs reqwest-based HTTP SSE-streaming client (OpenAI-compatible) maildir.rs maildir++ storage for raw .eml files mailparse.rs header/body extraction for inbound mail store.rs in-memory store for conversation threads imap/ mod.rs ImapServer (accept) + ImapConn (per-connection dispatch) proto.rs pure IMAP value/sequence helpers fetch.rs FETCH response construction (envelope, body, sections) messages.rs Maildir++ test/ example config + end-to-end roundtrip test + mock gateway ``` ## Status Early, single-purpose, no TLS. Designed for a trusted home network talking to a self-hosted LLM. Passwords are plaintext (v1) — use per-user tokens, not real credentials.