clee.sh/posthorn

· Tangled

7 min read Original article ↗

4

Configure Feed

Select the types of activity you want to include in your feed.

An IMAP+SMTP gateway for less urgent communications with LLMs

4

Configure Feed

Select the types of activity you want to include in your feed.

7 1 1

Clone this repository

https://tangled.org/clee.sh/posthorn https://tangled.org/did:plc:veo3rwwhdkezwk6prj746dnz

git@knot.clee.sh:clee.sh/posthorn git@knot.clee.sh:did:plc:veo3rwwhdkezwk6prj746dnz

For self-hosted knots, clone URLs may differ based on your setup.

Download tar.gz Download .zip

Commits 7

cmd_expunge calls sync_snapshot before walking the snapshot, but sync
reloaded the mailbox from disk and copied fresh.deleted onto every
survivor. load_user_messages always reports deleted=false (the maildir
carries no Deleted flag), so the sync clobbered the \Deleted flag a client
had just set via STORE — the expunge loop then found nothing to remove and
the file stayed on disk. CLOSE worked only because it skips sync_snapshot.

Extracted the survivor-refresh step into merge_survivors, which copies the
disk-backed seen/path but leaves the session-local deleted untouched (the
maildir has no Deleted state, so the session own m.deleted is
authoritative).

Detection keyed on the From header, where the posthorn-reply- marker never
appears (it lives in the Message-ID), and starts_with("mailer-daemon@")
failed on the display-name-wrapped bounce From. Net: every LLM reply was
flagged is_llm_reply=false, so the worker fed its own past replies to the
model as user turns, and bounces were treated as user mail.

Split into two distinct flags via classify_message(message_id, from_addr):
- is_llm_reply = Message-ID contains posthorn-reply-
- is_bounce = (posthorn-bounce- ID or mailer-daemon From), mutually
exclusive with reply so find(is_llm_reply) never returns a bounce whose
mailer-daemon From cannot split into provider/model.

Worker now skips bounces in the chat loop and excludes them from last-user
finds, so a delivery notice is no longer fed to the LLM as a turn and the
reply In-Reply-To/References point at a real user message.

Three intertwined address-handling bugs produced opaque autoreplies, all
rooted in feeding a Display <addr> or model:tag value to a naive addr parser:

- Opaque "Invalid input" bounces: build_reply_email set the reply From
display name to the raw model id (e.g. gemma3:27b-it-q4_K_M). A colon is
not RFC 5322 atext, so lettre Mailbox::from_str rejected the whole header
with AddressError::InvalidInput (Display: "Invalid input") — the bare
autoreply users saw. This broke every model:tag address after the LLM
reply succeeded. Fixed by building the Mailbox from parts
(Mailbox::new(name, addr.parse())) so only the addr-spec is parsed and
lettre encodes the display name on output (RFC 2047).

- Broken To: display-name routing: split_model_addr split on @, so a To:
header like gemma4-31b <gemma4-31b@ollama.clee.wtf> made the host
ollama.clee.wtf> (trailing >), failing the domain check and bouncing a
fresh thread. Fixed by extracting the angle-bracketed addr-spec first.
Hardened the SMTP envelope path symmetrically: extract_addr now uses
rfind(<) so a <Display <addr>> envelope yields the inner addr-spec.

- Thin error diagnostics: gateway HTTP/empty-stream errors now name the
URL; chat_completion context names provider and model; build_reply_email
errors name the offending From/To header and value; the bounce names the
provider; SMTP rejections (550 not local / unknown sender / no valid
model recipient) now echo the offending address. So a future failure is
diagnosable from the autoreply or SMTP dialog alone.

README.md

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

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)#

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)

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"

# 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#

# 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.