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

circularfoyers1 pts0 comments

clee.sh/posthorn at main · Tangled

clee.sh

posthorn

Star

Fork

Atom

Configure Feed

Issues

Pull Requests

Commits

Tags

Feed URL

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

An IMAP+SMTP gateway for less urgent communications with LLMs

Star

Fork

Atom

Configure Feed

Issues

Pull Requests

Commits

Tags

Feed URL

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

Rust

96.4%

Python

3.6%

main

v0.1.0

Code

Clone this repository

Use permalink

HTTPS

https://tangled.org/clee.sh/posthorn<br>https://tangled.org/did:plc:veo3rwwhdkezwk6prj746dnz<br>getComputedStyle(s).display !== 'none').textContent)"<br>class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"<br>title="Copy to clipboard"

SSH

git@knot.clee.sh:clee.sh/posthorn<br>git@knot.clee.sh:did:plc:veo3rwwhdkezwk6prj746dnz<br>getComputedStyle(s).display !== 'none').textContent)"<br>class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"<br>title="Copy to clipboard"

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

Download tar.gz

Download .zip

src

29 minutes ago

test

2 days ago

.gitignore

4 days ago

Cargo.lock

2 days ago

Cargo.toml

2 days ago

LICENSE

4 days ago

README.md

2 days ago

Commits

Stricter IMAP login sequence

We were accidentally sending `+ \r\n` instead of `+ \r\n` which was<br>causing some versions of mutt to choke and error out with:

imap_auth_sasl: error base64-decoding server response.<br>imap_auth_sasl: IMAP4REV1 AUTH=PLAIN IDLE failed

Removing the errant extra space appears to fix the issue.

010c46f2

clee.sh

27 minutes ago

main

Update README

- more consistent demo password<br>- add asciinema.org demo thumbnail/link

4ca4435c

clee.sh

13 hours ago

Drop postgresql dependency

The mailstore is the source of truth.

6f6874dd

clee.sh

14 hours ago

Branches

main

27 minutes ago

Default

Tags

v0.1.0

Latest

README.md

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<br>completion, calls an OpenAI-compatible gateway, and delivers the reply to your<br>maildir — readable via IMAP from any standard mail client (mutt, Thunderbird,<br>etc.). Conversations thread correctly across round-trips.

How it works#

┌────────┐ SMTP ┌─────────┐ enqueue ┌─────────┐ HTTP ┌──────────┐<br>│ client │ ──────► │ smtp.rs │ ────────► │ llm_jobs│ ───────► │ gateway │<br>└────────┘ └─────────┘ └─────────┘ │ (ollama) │<br>▲ └────┬─────┘<br>│ IMAP │ chat<br>│ ┌─────▼─────┐<br>┌────┴─────┐ read ┌─────────┐ write ┌─────────┐ poll │ worker.rs │<br>│ client │ ◄────── │ imap.rs │ ◄─────── │ maildir │ ◄───── │ + lettre │<br>└──────────┘ └─────────┘ └─────────┘ └───────────┘

SMTP (src/smtp.rs) — minimal receiver; no AUTH, no TLS. Accepts mail to<br>model-id@provider-id. (with + standing in for : in the model-id,<br>so model+tag reaches model:tag), parses headers/body, resolves the thread<br>via In-Reply-To/References, and stores the message in maildir++.

Worker (src/worker.rs) - runs as a short-lived child process , one<br>per job. The serve dispatcher claims pending llm_jobs and spawns<br>posthorn worker --job-id N; the child builds the chat history from the<br>thread, resolves the provider's gateway by name, calls it, and composes the<br>reply email (correct Message-ID/In-Reply-To/References so clients<br>thread it).

IMAP (src/imap/) — IMAP4rev1 server using imap-codec for spec-correct<br>parsing and encoding. Supports SASL AUTHENTICATE PLAIN, SELECT/EXAMINE,<br>FETCH (with BODY.PEEK[HEADER.FIELDS ...] subsetting), STORE (\Seen/<br>\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<br>* N EXISTS the instant a message lands — so mutt shows new replies without a<br>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<br>Configure#

posthorn reads a TOML config (figment; also overridable via POSTHORN_* env<br>vars). Example (test/config.toml.example):

[daemon]<br>domain = "posthorn.localhost"<br>data_dir = "test/data"

[imap]<br>host = "127.0.0.1"<br>port = 11143

[smtp]<br>host = "127.0.0.1"<br>port = 11025<br>max_message_bytes = 10485760

[llm]<br>default_system_prompt = "You are a helpful test assistant. Keep replies short."<br>default_provider = "ollama"

# One block per OpenAI-compatible gateway. The `name` is the subdomain used<br># for routing: mail to `model-id@.` is sent to that gateway.<br>[[llm.providers]]<br>name = "ollama"<br>gateway_url = "http://127.0.0.1:11434"

[[llm.providers]]<br>name = "llama-cpp"<br>gateway_url = "http://127.0.0.1:8080"<br># api_key_env = "LLAMACPP_API_KEY" # optional; read from env at request time

[[users]]<br>email = "user@example.com"<br>password = "hunter2"<br>Users declared under [[users]] are...

posthorn clee imap text gray smtp

Related Articles