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>
This commit is contained in:
Anthony Ettinger 2026-07-10 02:30:12 -07:00 committed by GitHub
parent ff9eef907d
commit b51fa5c0e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 947 additions and 0 deletions

View file

@ -73,6 +73,7 @@ import (
"github.com/profullstack/agentbbs/internal/ircpass"
"github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/mailbox"
"github.com/profullstack/agentbbs/internal/mailclients"
"github.com/profullstack/agentbbs/internal/mailu"
"github.com/profullstack/agentbbs/internal/motd"
"github.com/profullstack/agentbbs/internal/news"
@ -620,6 +621,26 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
}},
})
// Alternative mail clients: himalaya and meli, pointed at the same mailbox as
// the built-in AgentMail reader. Same lock rules as Mail, plus a lock when the
// binary isn't installed on this host so the entry explains itself.
for _, mc := range []mailclients.Client{mailclients.Himalaya, mailclients.Meli} {
mc := mc
lock := mailLock
if lock == "" && !mc.Available() {
lock = mc.Name() + " is not installed on this host"
}
apps = append(apps, hub.SessionApp{
Title: "Mail · " + mc.Title(),
Description: mc.Name() + " on your " + a.mailAddress(su.Name) + " mailbox",
Locked: lock,
Cmd: sessionExec{run: func() error {
_ = a.ensureMailbox(su)
return mc.Run(s, a.mailBackendFor(su))
}},
})
}
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
torLock := ""
switch {
@ -1668,6 +1689,51 @@ func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil
}
// mailBackendFor resolves the same IMAP/SMTP connection mailClientFor uses, but
// as a plain mailclients.Backend so third-party clients (himalaya, meli) can be
// pointed at the member's mailbox. It reads the identical env so all three
// clients behave the same; the master secret stays on the host (these clients
// run host-side, never in a pod — see internal/mailclients).
func (a *app) mailBackendFor(su store.User) mailclients.Backend {
login := a.mailAddress(su.Name)
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
login = a.mailAddress(su.Name) + "*" + master
}
imapHost, imapPort := splitHostPort(env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"), 993)
smtpHost, smtpPort := splitHostPort(env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"), 25)
return mailclients.Backend{
Name: su.Name,
Address: a.mailAddress(su.Name),
DisplayName: su.Name,
IMAPHost: imapHost,
IMAPPort: imapPort,
IMAPPlaintext: os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1",
SMTPHost: smtpHost,
SMTPPort: smtpPort,
// The default submission path is the trusted, unauthenticated loopback
// relay (127.0.0.1:25) the gateway uses — no STARTTLS. Operators pointing
// at a real submission port should set AGENTBBS_*_CONFIG_TEMPLATE.
SMTPPlaintext: os.Getenv("AGENTBBS_MAIL_SMTP_STARTTLS") != "1",
Login: login,
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
}
}
// splitHostPort splits a "host:port" into its parts, falling back to def when no
// port is present or it doesn't parse. Unlike net.SplitHostPort it tolerates a
// bare host.
func splitHostPort(addr string, def int) (string, int) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return addr, def
}
port, err := strconv.Atoi(portStr)
if err != nil {
return host, def
}
return host, port
}
// handleMail routes a Founding Lifetime member into AgentMail: an interactive
// TUI when a PTY is present and no command is given, or the JSON bot mode
// (ssh mail@host <cmd>, or no PTY) for agents.
@ -1704,6 +1770,30 @@ func (a *app) handleMail(s ssh.Session) {
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
defer func() { _ = a.st.EndSession(sessID) }()
// `ssh -t mail@host himalaya|meli` opens an alternative client instead of the
// built-in AgentMail reader. Requires a PTY (both are interactive).
if args := s.Command(); len(args) > 0 {
var mc mailclients.Client
switch strings.ToLower(args[0]) {
case "himalaya":
mc = mailclients.Himalaya
case "meli":
mc = mailclients.Meli
}
if mc.Name() != "" {
if _, _, hasPty := s.Pty(); !hasPty {
wish.Println(s, mc.Name()+" needs an interactive terminal — connect with: ssh -t "+mc.Name()+"@... (use: ssh -t mail@"+a.host+" "+mc.Name()+")")
_ = s.Exit(1)
return
}
if err := mc.Run(s, a.mailBackendFor(u)); err != nil {
wish.Println(s, "mail: "+err.Error())
_ = s.Exit(1)
}
return
}
}
c, err := a.mailClientFor(u)
if err != nil {
wish.Println(s, "mail: "+err.Error())