mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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>
312 lines
10 KiB
Go
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"
|
|
`
|