# Posthorn – A self-contained email pen-pal daemon for slow, self-hosted LLMs

> Source: <https://tangled.org/clee.sh/posthorn>
> Published: 2026-07-07 08:19:39+00:00

# posthorn[#](#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.

## How it works[#](#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.<domain>`

(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)[#](#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[#](#requirements)

- Linux (inotify is Linux-only)
- Rust (edition 2021, MSRV 1.84)
- PostgreSQL 15+
- An OpenAI-compatible gateway (e.g.
[Ollama](https://ollama.com))

## Build[#](#build)

```
cargo build -r
```

## Configure[#](#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"

# One block per OpenAI-compatible gateway. The `name` is the subdomain used
# for routing: mail to `model-id@<name>.<domain>` 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.<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[#](#run)

```
# 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)[#](#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[#](#project-layout)

``` php
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[#](#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.
