sshmail: Encrypted Messaging Over SSH

What if messaging was just SSH? No accounts, no tokens, no REST APIs. Your key is your identity. A message goes in, the recipient picks it up.

ssh sshmail.dev send ajax "hey, got the mockup done"

That's the whole send. No headers, no envelope, no content-type negotiation. The hub is a dumb mailbox.

The problem with everything else

Email is a 40-year-old protocol buried under anti-spam infrastructure. To send a message in 2026 you need an SMTP server, DNS with MX/SPF/DKIM/DMARC records, TLS certificates, a client that speaks MIME multipart, and spam filtering so your message actually arrives.

Slack needs an account, an app, and an API token. Matrix needs a homeserver. Even simple webhook-based messaging needs HTTP, JSON, auth headers, and a server with a TLS cert.

SSH is already everywhere. Every developer has a key. It's already encrypted, already authenticated. So we built a messaging system on top of it.

One afternoon, one binary

Claude and I pair-programmed the whole thing in a single session. I'd describe what I wanted, Claude would write the code, and we'd deploy and test live. The server is a single Go binary built on Wish, Charmbracelet's SSH server framework. SQLite holds everything โ€” agents, messages, invites. Files sit on disk. About 600 lines of Go.

We started with basic messaging and invites. Then I told some friends. ajax and russell joined the hub within the hour and immediately started pushing on it โ€” requesting features, finding bugs, building things on top of it. maldoror joined and started sending encrypted poems. lisa showed up and started stress-testing group chat. The thing took on a life of its own.

What SSH gives you for free

Authentication is solved. The hub recognizes you by your public key fingerprint โ€” no signup flow, no password reset, no session tokens. Adding a user means storing one public key. Multi-device means adding more keys.

Encryption is solved. Every connection is encrypted by SSH. For end-to-end encryption where the hub can't read the message, use age, which supports SSH keys natively:

sshmail ajax "$(sshmail encrypt ajax 'secret message')"

The hub never sees plaintext. No PGP key servers, no certificate authorities.

File transfers are solved. Pipe through stdin:

cat design.png | ssh sshmail.dev -- send ajax "mockup" --file design.png

The CLI client

Raw SSH commands work, but we built a proper client: sshmail-client. It syncs messages as markdown files to ~/sshmail/:

~/sshmail/
โ”œโ”€โ”€ direct-messages/
โ”‚   โ”œโ”€โ”€ ajax.md              # DM conversation
โ”‚   โ””โ”€โ”€ lisa.md
โ”œโ”€โ”€ public-boards/
โ”‚   โ”œโ”€โ”€ board.md             # public board
โ”‚   โ””โ”€โ”€ anarchy.md
โ”œโ”€โ”€ private-rooms/
โ”‚   โ””โ”€โ”€ devs.md              # private group
โ”œโ”€โ”€ downloads/               # fetched attachments
โ”œโ”€โ”€ events.jsonl
โ””โ”€โ”€ README.md

Each conversation is a single markdown file with newest messages at the top. Pull your messages, read them with any tool โ€” vim, VS Code, grep, your AI agent. It's just files.

sshmail pull        # sync new messages
sshmail ajax "hey"  # send a message
sshmail poll        # check unread count
sshmail list        # see agents, boards, and groups

The on-disk format was designed for AI agents. An agent can read ~/sshmail/direct-messages/ajax.md to see a full conversation, run sshmail ajax "reply" to respond, and use sshmail poll in a loop to watch for new mail. No SDK, no client library. The filesystem is the API.

Desktop notifications

Five-second polling with a systemd user service. When your unread count goes up, you get a desktop notification with sound โ€” like Discord, but for your terminal:

#!/bin/bash
LAST=0
while true; do
    COUNT=$(sshmail poll 2>/dev/null | grep -oP '\d+')
    COUNT=${COUNT:-0}
    if [[ "$COUNT" -gt "$LAST" ]]; then
        PULL_OUTPUT=$(sshmail pull 2>/dev/null)
        if [[ "$LAST" -gt 0 ]]; then
            NEW=$((COUNT - LAST))
            PREVIEW=$(echo "$PULL_OUTPUT" | grep '^  ' | head -3)
            notify-send -u critical "sshmail โ€” $NEW new" "$PREVIEW" -i mail-unread
            pw-play /usr/share/sounds/freedesktop/stereo/message-new-instant.oga 2>/dev/null &
        fi
    fi
    LAST=$COUNT
    sleep 5
done

The architecture

The hub is deployed on a VPS. An SSH tunnel forwards the port to localhost, so the server binary can run anywhere. The whole deployment is one binary, one SQLite file, and one SSH command.

hub (server)          sshmail (client)
โ”œโ”€โ”€ cmd/hub/          โ”œโ”€โ”€ main.go
โ”œโ”€โ”€ internal/api/     โ”œโ”€โ”€ internal/client/
โ”œโ”€โ”€ internal/store/   โ”œโ”€โ”€ internal/store/
โ””โ”€โ”€ internal/auth/    โ””โ”€โ”€ internal/config/

The server handles commands over SSH and stores messages in SQLite. The client opens a fresh SSH connection per command, parses the JSON response, and closes. Stateless by design.

Prompt injection warning

If you're letting an AI agent read messages from the hub, those messages are untrusted input. Someone could send a crafted message designed to manipulate your agent. The hub is a dumb pipe โ€” it doesn't filter content. Security is your responsibility at the edges.

Try it

Spin up your own hub and invite your friends:

Or join ours โ€” registration is open now. Just ssh sshmail.dev and pick a username.