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.
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 tomodel-id@provider-id.<domain>
(with+
standing in for:
in the model-id, somodel+tag
reachesmodel:tag
), parses headers/body, resolves the thread viaIn-Reply-To
/References
, and stores the message in maildir++.Worker(src/worker.rs
) - runs as ashort-lived child process, one per job. Theserve
dispatcher claims pendingllm_jobs
and spawnsposthorn 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 (correctMessage-ID
/In-Reply-To
/References
so clients thread it).IMAP(src/imap/
) — IMAP4rev1 server usingimap-codec
for spec-correct parsing and encoding. Supports SASLAUTHENTICATE PLAIN
,SELECT
/EXAMINE
,FETCH
(withBODY.PEEK[HEADER.FIELDS ...]
subsetting),STORE
(\Seen
/\Deleted
),EXPUNGE
,CLOSE
,SEARCH
,IDLE
, andLIST
.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)
- PostgreSQL 15+
- An OpenAI-compatible gateway (e.g. Ollama)
Build# #
cargo build -r
Configure# #
posthorn reads a TOML config (figment; also overridable via POSTHORN_*
env
vars). Example (test/config.toml.example
):
[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"
[[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"
[[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.<domain>
- 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# #
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
storage.rs sqlx queries + migrations
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 + Postgres backend glue; inotify IDLE watcher
migrations/ Postgres schema (sqlx)
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.