agentbbs/internal/mailclients/mailclients.go
Anthony Ettinger b51fa5c0e6
Add himalaya + meli mail clients and Shedding Snake arcade game (#88)
Mail clients (internal/mailclients): launch himalaya and meli as
alternatives to the built-in AgentMail reader, pointed at the member's
mailbox with the same master IMAP / loopback-SMTP creds. They run
host-side (secrets never enter a pod), from a throwaway per-session
config that is deleted on exit; operators can override the config
template and argv via env. Wired into the hub ("Mail · Himalaya/Meli",
locked when the binary is absent) and the mail@ route
(ssh -t mail@host himalaya|meli).

Shedding Snake (plugins/arcade): a molting twist on Snake inspired by
cha.rlie.co/shedding-snake. The snake barely grows — each apple sheds
its whole body as a permanent field of scales you must not bite. Walls
wrap, speed ramps up, scales age fresh-teal → dusty-gray, and molt
counts feed a global "shedsnake" leaderboard. Grace period lets you
slither off a fresh molt.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 02:30:12 -07:00

312 lines
10 KiB
Go

// Package mailclients launches third-party terminal email clients — himalaya
// (https://github.com/pimalaya/himalaya) and meli (https://meli-email.org) —
// against a member's AgentBBS mailbox, as alternatives to the built-in
// AgentMail reader (internal/mailbox).
//
// Like the built-in client, these run ON THE HOST, not inside the member's pod:
// AgentBBS mailboxes are opened with a single Dovecot master-user secret and
// submitted through the loopback relay, and neither of those must ever leak into
// a pod (see docs/mail.md). We generate a throwaway per-session config that
// points the client at exactly the same IMAP/SMTP paths the built-in client
// uses, attach the client to the SSH PTY, and delete the config on exit.
//
// Client config schemas drift between releases, so every field the default
// template renders is also overridable: set AGENTBBS_HIMALAYA_CONFIG_TEMPLATE /
// AGENTBBS_MELI_CONFIG_TEMPLATE to a template path to replace the built-in
// config wholesale, and AGENTBBS_HIMALAYA_ARGS / AGENTBBS_MELI_ARGS to change
// the argv. Placeholders available to a template are documented on Backend.
package mailclients
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/ssh"
"github.com/creack/pty"
)
// Backend is the resolved connection the launched client is pointed at. It
// mirrors mailbox.IMAPConfig: the login is the Dovecot master-user login
// ("<addr>*<master>"), the password is the master secret, and SMTP is the
// unauthenticated loopback relay. The caller builds this from the same env the
// built-in client reads so all three clients behave identically.
//
// Template placeholders (for AGENTBBS_*_CONFIG_TEMPLATE overrides):
//
// {{name}} bare handle, e.g. alice
// {{address}} full address, e.g. alice@bbs.profullstack.com
// {{display_name}} display name (defaults to {{name}})
// {{login}} IMAP login (master-user form)
// {{password}} IMAP/SMTP password (master secret)
// {{imap_host}} IMAP host
// {{imap_port}} IMAP port
// {{imap_encryption}} "none" when plaintext, else "tls"
// {{smtp_host}} SMTP host
// {{smtp_port}} SMTP port
// {{smtp_encryption}} "none" when the relay is loopback/plaintext, else "start-tls"
type Backend struct {
Name string // bare handle
Address string // full email address
DisplayName string // defaults to Name when empty
IMAPHost string
IMAPPort int
IMAPPlaintext bool // dial IMAP without TLS (loopback → Dovecot master login)
SMTPHost string
SMTPPort int
// SMTPPlaintext is true for the unauthenticated loopback relay (no STARTTLS).
SMTPPlaintext bool
Login string // IMAP login (may be "<addr>*<master>")
Password string // master secret
}
func (b Backend) display() string {
if b.DisplayName != "" {
return b.DisplayName
}
return b.Name
}
func (b Backend) imapEncryption() string {
if b.IMAPPlaintext {
return "none"
}
return "tls"
}
func (b Backend) smtpEncryption() string {
if b.SMTPPlaintext {
return "none"
}
return "start-tls"
}
// placeholders returns the template substitution map.
func (b Backend) placeholders() map[string]string {
return map[string]string{
"{{name}}": b.Name,
"{{address}}": b.Address,
"{{display_name}}": b.display(),
"{{login}}": b.Login,
"{{password}}": b.Password,
"{{imap_host}}": b.IMAPHost,
"{{imap_port}}": strconv.Itoa(b.IMAPPort),
"{{imap_encryption}}": b.imapEncryption(),
"{{smtp_host}}": b.SMTPHost,
"{{smtp_port}}": strconv.Itoa(b.SMTPPort),
"{{smtp_encryption}}": b.smtpEncryption(),
}
}
func render(tmpl string, ph map[string]string) string {
out := tmpl
for k, v := range ph {
out = strings.ReplaceAll(out, k, v)
}
return out
}
// Client identifies one of the supported third-party clients.
type Client struct {
name string // "himalaya" | "meli"
title string // display title, e.g. "Himalaya"
binEnv string // env var overriding the binary path
binName string // default binary name looked up on PATH
argsEnv string // env var overriding the argv (space-split)
tmplEnv string // env var overriding the config template
configRel string // config path relative to the session HOME (XDG_CONFIG_HOME)
defArgs func(cfg string) []string
template string
}
// Himalaya is the pimalaya CLI email client.
var Himalaya = Client{
name: "himalaya",
title: "Himalaya",
binEnv: "AGENTBBS_HIMALAYA_BIN",
binName: "himalaya",
argsEnv: "AGENTBBS_HIMALAYA_ARGS",
tmplEnv: "AGENTBBS_HIMALAYA_CONFIG_TEMPLATE",
configRel: ".config/himalaya/config.toml",
defArgs: func(cfg string) []string { return []string{"-c", cfg} },
template: himalayaTemplate,
}
// Meli is the meli-email.org full-screen TUI email client.
var Meli = Client{
name: "meli",
title: "Meli",
binEnv: "AGENTBBS_MELI_BIN",
binName: "meli",
argsEnv: "AGENTBBS_MELI_ARGS",
tmplEnv: "AGENTBBS_MELI_CONFIG_TEMPLATE",
configRel: ".config/meli/config.toml",
defArgs: func(cfg string) []string { return []string{"-c", cfg} },
template: meliTemplate,
}
// Available reports whether the client binary can be found, so the hub can lock
// the entry with a helpful reason instead of failing on launch.
func (c Client) Available() bool { _, err := c.binary(); return err == nil }
// Name is the client's short name ("himalaya" / "meli").
func (c Client) Name() string { return c.name }
// Title is the client's display title, e.g. "Himalaya".
func (c Client) Title() string { return c.title }
func (c Client) binary() (string, error) {
if p := os.Getenv(c.binEnv); p != "" {
if _, err := os.Stat(p); err != nil {
return "", fmt.Errorf("%s: %s not found (%s)", c.name, p, c.binEnv)
}
return p, nil
}
p, err := exec.LookPath(c.binName)
if err != nil {
return "", fmt.Errorf("%s is not installed on this host (looked for %q on PATH; set %s to override)", c.name, c.binName, c.binEnv)
}
return p, nil
}
func (c Client) configTemplate() (string, error) {
if p := os.Getenv(c.tmplEnv); p != "" {
b, err := os.ReadFile(p)
if err != nil {
return "", fmt.Errorf("%s: reading %s: %w", c.name, c.tmplEnv, err)
}
return string(b), nil
}
return c.template, nil
}
func (c Client) argv(bin, cfg string) []string {
if v := os.Getenv(c.argsEnv); strings.TrimSpace(v) != "" {
// The override is the argv AFTER the binary; render {{config}} so callers
// can place the -c flag wherever the client wants it.
fields := strings.Fields(v)
for i, f := range fields {
fields[i] = strings.ReplaceAll(f, "{{config}}", cfg)
}
return append([]string{bin}, fields...)
}
return append([]string{bin}, c.defArgs(cfg)...)
}
// Run generates a throwaway config for be, launches the client attached to the
// SSH session's PTY, and cleans up on exit. A PTY is required (ssh -t).
func (c Client) Run(s ssh.Session, be Backend) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
return fmt.Errorf("%s needs an interactive terminal — connect with: ssh -t", c.name)
}
bin, err := c.binary()
if err != nil {
return err
}
tmpl, err := c.configTemplate()
if err != nil {
return err
}
home, err := os.MkdirTemp("", "agentbbs-"+c.name+"-")
if err != nil {
return fmt.Errorf("%s: temp dir: %w", c.name, err)
}
// The config holds the master secret; make sure it is only ever mode 0600 and
// removed when the session ends, even on panic.
defer os.RemoveAll(home)
cfgPath := filepath.Join(home, filepath.FromSlash(c.configRel))
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o700); err != nil {
return fmt.Errorf("%s: config dir: %w", c.name, err)
}
if err := os.WriteFile(cfgPath, []byte(render(tmpl, be.placeholders())), 0o600); err != nil {
return fmt.Errorf("%s: write config: %w", c.name, err)
}
argv := c.argv(bin, cfgPath)
cmd := exec.Command(argv[0], argv[1:]...)
// Isolate the client entirely inside the temp HOME so it can never read or
// write the host account's real dotfiles, and so its cache/state is disposable.
cmd.Env = append(os.Environ(),
"HOME="+home,
"XDG_CONFIG_HOME="+filepath.Join(home, ".config"),
"XDG_DATA_HOME="+filepath.Join(home, ".local", "share"),
"XDG_CACHE_HOME="+filepath.Join(home, ".cache"),
"XDG_STATE_HOME="+filepath.Join(home, ".local", "state"),
"TERM="+ptyReq.Term,
)
f, err := pty.Start(cmd)
if err != nil {
return fmt.Errorf("%s: start: %w", c.name, err)
}
defer f.Close()
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(ptyReq.Window.Height), Cols: uint16(ptyReq.Window.Width)})
go func() {
for w := range winCh {
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(w.Height), Cols: uint16(w.Width)})
}
}()
go func() { _, _ = io.Copy(f, s) }() // ssh -> client
_, _ = io.Copy(s, f) // client -> ssh
return cmd.Wait()
}
// himalayaTemplate is himalaya's TOML config (schema v1.x). The SMTP relay is
// the unauthenticated loopback submission the gateway uses, so no send auth is
// configured; override AGENTBBS_HIMALAYA_CONFIG_TEMPLATE if your relay differs.
const himalayaTemplate = `# Generated per-session by AgentBBS — do not edit; changes are discarded.
[accounts.agentbbs]
default = true
email = "{{address}}"
display-name = "{{display_name}}"
backend.type = "imap"
backend.host = "{{imap_host}}"
backend.port = {{imap_port}}
backend.encryption.type = "{{imap_encryption}}"
backend.login = "{{login}}"
backend.auth.type = "password"
backend.auth.raw = "{{password}}"
message.send.backend.type = "smtp"
message.send.backend.host = "{{smtp_host}}"
message.send.backend.port = {{smtp_port}}
message.send.backend.encryption.type = "{{smtp_encryption}}"
`
// meliTemplate is meli's TOML config. IMAP uses the master-user login; sending
// goes through the loopback relay with no auth/TLS (type = "none").
const meliTemplate = `# Generated per-session by AgentBBS — do not edit; changes are discarded.
[accounts.agentbbs]
root_mailbox = "INBOX"
format = "imap"
identity = "{{address}}"
display_name = "{{display_name}}"
subscribed_mailboxes = ["INBOX", "Sent", "Drafts", "Trash", "Junk"]
server_hostname = "{{imap_host}}"
server_username = "{{login}}"
server_password = "{{password}}"
server_port = {{imap_port}}
use_starttls = false
danger_accept_invalid_certs = true
[accounts.agentbbs.send_mail]
hostname = "{{smtp_host}}"
port = {{smtp_port}}
[accounts.agentbbs.send_mail.auth]
type = "none"
[accounts.agentbbs.send_mail.security]
type = "none"
`