diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a6967ea..bf2b90b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,18 @@ name: deploy # Fully autonomous, idempotent deploy. On every push to main (or manual -# dispatch) this SSHes to the bbs.profullstack.com droplet and re-runs the -# idempotent provisioner (setup.sh), which pulls origin, rebuilds the Go -# binaries, and restarts the agentbbs service that answers -# `ssh join@bbs.profullstack.com`. Re-running is always safe. +# dispatch) this builds the Go binaries ON THE RUNNER (which has plenty of +# RAM), ships them to the bbs.profullstack.com droplet, and re-runs the +# idempotent provisioner (setup.sh) with SKIP_BUILD=1 so the tiny droplet +# never has to compile. setup.sh still pulls origin, refreshes config/assets, +# and restarts the agentbbs service that answers `ssh join@bbs.profullstack.com`. +# Re-running is always safe. +# +# Why build on the runner: the droplet is a ~458MB box also running ergo, +# forgejo, tor, podman and the live agentbbs. The Go linker's peak memory was +# OOM-killing the build — and with it the sshd serving the deploy session, +# surfacing as "Connection closed by remote host" (exit 255). Compiling on the +# 16GB runner removes that failure mode entirely. # # Required repo secrets (Settings -> Secrets and variables -> Actions): # DEPLOY_SSH_KEY private key whose public half is in the droplet admin @@ -30,6 +38,8 @@ jobs: deploy: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Configure SSH env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} @@ -43,7 +53,55 @@ jobs: chmod 600 ~/.ssh/id_deploy ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null - - name: Provision / redeploy (idempotent) + - name: Detect droplet architecture + id: arch + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} + run: | + uname_m="$(ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + "${DEPLOY_USER}@${DEPLOY_HOST}" 'uname -m')" + case "$uname_m" in + x86_64|amd64) goarch=amd64 ;; + aarch64|arm64) goarch=arm64 ;; + *) echo "::error::unsupported droplet arch '$uname_m'"; exit 1 ;; + esac + echo "goarch=$goarch" >> "$GITHUB_OUTPUT" + echo "::notice::droplet arch $uname_m -> GOARCH=$goarch" + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build binaries (on the runner, not the droplet) + env: + GOOS: linux + GOARCH: ${{ steps.arch.outputs.goarch }} + CGO_ENABLED: '0' # pure-Go (modernc sqlite) — static, portable binary + run: | + mkdir -p dist + go build -trimpath -o dist/agentbbs ./cmd/agentbbs + go build -trimpath -o dist/ascii-live ./cmd/ascii-live + file dist/* || true + + - name: Ship binaries to the droplet + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} + run: | + # scp can only name one remote target; copy each binary explicitly. + scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + dist/agentbbs "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-agentbbs" + scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + dist/ascii-live "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-ascii-live" + + - name: Provision / redeploy (idempotent, SKIP_BUILD=1) env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} @@ -76,7 +134,11 @@ jobs: fi git -C "$SRC" fetch --depth 1 origin "$BRANCH" git -C "$SRC" reset --hard "origin/$BRANCH" - exec env BRANCH="$BRANCH" \ + # Install the runner-built binaries, then tell setup.sh not to compile. + install -m 0755 /tmp/agentbbs-deploy-agentbbs /usr/local/bin/agentbbs + install -m 0755 /tmp/agentbbs-deploy-ascii-live /usr/local/bin/ascii-live + rm -f /tmp/agentbbs-deploy-agentbbs /tmp/agentbbs-deploy-ascii-live + exec env BRANCH="$BRANCH" SKIP_BUILD=1 \ COINPAY_API_KEY="${COINPAY_API_KEY:-}" \ COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \ AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \ diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 7eeaf50..583f033 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -8,6 +8,8 @@ // emailed code, then offers $99 Founding Lifetime (CoinPay) // ssh pod@host your personal Linux pod — free for verified members // ssh domain@host point your own domain at your homepage (Premium; add/rm/list) +// ssh @host (from another account) prints a finger card for that member +// ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin // ssh admin@host the operator admin console ($AGENTBBS_ADMINS only; // sysop@/root@ are aliases) // ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated, @@ -53,6 +55,7 @@ import ( gossh "golang.org/x/crypto/ssh" "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/brand" "github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/forgejo" @@ -60,6 +63,7 @@ import ( "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/mail" + "github.com/profullstack/agentbbs/internal/mailbox" "github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" @@ -71,6 +75,7 @@ import ( "github.com/profullstack/agentbbs/plugins/about" "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" + "github.com/profullstack/agentbbs/plugins/members" qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite" ) @@ -170,7 +175,7 @@ func main() { a.mm = games.NewMatchmaker(a.gamesReg, a.st, time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second, time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second) - a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}} + a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}} // Custom domains: maintain the symlink farm Caddy serves and answer its // on-demand-TLS "ask" query so certs are only issued for mapped domains. @@ -315,6 +320,10 @@ func (a *app) router() wish.Middleware { a.handleTorCmd(s) case auth.IsNewsName(user): a.handleNews(s) + case auth.IsMailName(user): + a.handleMail(s) + case auth.IsMsgName(user): + a.handleMsg(s) case isVideo: a.handleVideo(s, code) case user == "agent": @@ -329,12 +338,9 @@ func (a *app) router() wish.Middleware { } // bbsBanner is the ASCII brand mark shown atop the hub menu and the join@ flow. -const bbsBanner = "" + - "┌─┐┬─┐┌─┐┌─┐┬ ┬┬ ┬ ┌─┐┌┬┐┌─┐┌─┐┬┌─\n" + - "├─┘├┬┘│ │├┤ │ ││ │ └─┐ │ ├─┤│ ├┴┐\n" + - "┴ ┴└─└─┘└ └─┘┴─┘┴─┘└─┘ ┴ ┴ ┴└─┘┴ ┴ .com" +var bbsBanner = brand.Logo() -var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#38bdf8")) +var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a")) // hubMOTD is the welcome message shown in a box on the hub menu. The body is // operator-overridable via AGENTBBS_MOTD; it is tailored for guests vs members. @@ -345,7 +351,11 @@ func (a *app) hubMOTD(u auth.User) string { return "You're browsing as a guest.\n" + body + "\nssh join@" + a.host + " to claim a username, a pod & a homepage." } - return "Welcome back, " + u.Name + ".\n" + body + welcome := "Welcome back, " + u.Name + "." + if n, err := a.st.UnreadCount(u.Name); err == nil && n > 0 { + welcome += fmt.Sprintf(" 📬 %d unread — open Members ▸ inbox (i).", n) + } + return welcome + "\n" + body } // teaHandler builds the hub model for guests, members, and agents. @@ -394,7 +404,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub") go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }() - ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets} + ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host} if u.Kind != auth.Guest { ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name) _ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755) @@ -464,6 +474,28 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }}, }) + // Mail — a Founding Lifetime Member perk: the AgentMail TUI. + mailLock := "" + switch { + case guest: + mailLock = membersOnly + case !su.Premium: + mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host + } + apps = append(apps, hub.SessionApp{ + Title: "Mail", + Description: "your " + a.fe.Domain + " mailbox", + Locked: mailLock, + Cmd: sessionExec{run: func() error { + c, err := a.mailClientFor(su) + if err != nil { + return err + } + defer c.Close() + return mailbox.RunReader(s, c) + }}, + }) + // Tor — a Founding Lifetime Member perk: a torsocks shell in the pod. torLock := "" switch { @@ -1239,6 +1271,83 @@ func (a *app) runNews(s ssh.Session, name string) error { return news.RunReader(s, addr, name) } +// mailClientFor builds a paid-gated AgentMail client for a member, connecting to +// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login +// "*") so the BBS gateway can open any member's mailbox with one +// secret; SMTP defaults to the co-located relay (no auth). Returns an error if +// the IMAP connection/login fails. +func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) { + domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") + login := su.Name + if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" { + login = su.Name + "*" + master + } + cfg := mailbox.IMAPConfig{ + IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"), + SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"), + Username: login, + Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"), + // SMTPUser/SMTPPass left empty: submit via the trusted local relay. + } + tr, err := mailbox.NewIMAPTransport(cfg) + if err != nil { + return nil, err + } + return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil +} + +// 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 , or no PTY) for agents. +func (a *app) handleMail(s ssh.Session) { + fp := auth.Fingerprint(s.PublicKey()) + if fp == "" { + wish.Println(s, "mail@ needs your registered SSH key. New here? ssh join@"+a.host) + _ = s.Exit(1) + return + } + u, found, err := a.st.UserByFingerprint(fp) + if err != nil || !found { + wish.Println(s, "key not registered — run: ssh join@"+a.host) + _ = s.Exit(1) + return + } + if u.Banned { + wish.Println(s, "this account is suspended.") + _ = s.Exit(1) + return + } + if !a.ensurePremium(&u) { + wish.Println(s, " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host) + _ = s.Exit(1) + return + } + sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail") + defer func() { _ = a.st.EndSession(sessID) }() + + c, err := a.mailClientFor(u) + if err != nil { + wish.Println(s, "mail: "+err.Error()) + _ = s.Exit(1) + return + } + defer c.Close() + + args := s.Command() + _, _, hasPty := s.Pty() + if len(args) > 0 || !hasPty { + // Agent/bot mode: JSON in, JSON out. + if err := mailbox.RunBot(s.Context(), c, args, s, s); err != nil { + _ = s.Exit(1) + } + return + } + if err := mailbox.RunReader(s, c); err != nil { + wish.Println(s, "mail: "+err.Error()) + _ = s.Exit(1) + } +} + // handleTorCmd runs an arbitrary command through Tor (torsocks) inside the // member's pod, never on the host. Premium; requires a PTY. func (a *app) handleTorCmd(s ssh.Session) { @@ -1322,6 +1431,62 @@ func (a *app) handleChat(s ssh.Session) { } } +// handleMsg is the member-to-member messaging route: `ssh msg@host [text]` +// leaves a note in 's BBS inbox. The body is the remaining args, or stdin +// when none are given (so `echo hi | ssh msg@host bob` works). Members only; +// the recipient reads it in the hub's Members ▸ inbox. +func (a *app) handleMsg(s ssh.Session) { + fp := auth.Fingerprint(s.PublicKey()) + if fp == "" { + wish.Println(s, "msg@ needs your registered SSH key. New here? ssh join@"+a.host) + _ = s.Exit(1) + return + } + from, found, err := a.st.UserByFingerprint(fp) + if err != nil || !found { + wish.Println(s, "key not registered — run: ssh join@"+a.host) + _ = s.Exit(1) + return + } + args := s.Command() + if len(args) == 0 { + wish.Println(s, "usage: ssh msg@"+a.host+" [message] (or pipe the message on stdin)") + _ = s.Exit(1) + return + } + to := strings.ToLower(args[0]) + recipient, ok, err := a.st.UserByName(to) + if err != nil || !ok { + wish.Println(s, "no member named "+to+" — check the spelling (ssh "+to+"@"+a.host+" to finger).") + _ = s.Exit(1) + return + } + if recipient.Name == from.Name { + wish.Println(s, "you can't message yourself.") + _ = s.Exit(1) + return + } + body := strings.TrimSpace(strings.Join(args[1:], " ")) + if body == "" { + // No inline text — read the message from stdin (piped, or typed then ^D). + b, _ := io.ReadAll(io.LimitReader(s, 64*1024)) + body = strings.TrimSpace(string(b)) + } + if body == "" { + wish.Println(s, "empty message — nothing sent.") + _ = s.Exit(1) + return + } + if err := a.st.SendMessage(from.Name, recipient.Name, body); err != nil { + wish.Println(s, "could not send: "+err.Error()) + _ = s.Exit(1) + return + } + _, _ = a.st.RecordSession(from.ID, s.User(), remoteIP(s), "msg") + wish.Println(s, "✓ message left for "+recipient.Name+" — they'll see it in Members ▸ inbox.") + _ = s.Exit(0) +} + // handleFinger prints a classic finger card when someone ssh's to an // existing account name that isn't their own (e.g. ssh anthony@host). // Returns false when the route should fall through to the hub. diff --git a/deploy/mailu/.gitignore b/deploy/mailu/.gitignore new file mode 100644 index 0000000..32c4c30 --- /dev/null +++ b/deploy/mailu/.gitignore @@ -0,0 +1,3 @@ +mailu.env +certs/ +data/ diff --git a/deploy/mailu/README.md b/deploy/mailu/README.md new file mode 100644 index 0000000..e4e19f6 --- /dev/null +++ b/deploy/mailu/README.md @@ -0,0 +1,53 @@ +# deploy/mailu — self-hosted mail for `mail.profullstack.com` + +Mailu (Postfix + Dovecot + Roundcube + rspamd) as a Docker Compose stack, +fronted by the host Caddy. Full setup, DNS, and architecture: [`docs/mail.md`](../../docs/mail.md). + +## Files + +| File | Purpose | +|---|---| +| `docker-compose.yml` | the Mailu services (mail ports on host, HTTP on loopback) | +| `mailu.env.example` | config template → copy to `mailu.env` and fill secrets | +| `refresh-certs.sh` | copy Caddy's `mail.$DOMAIN` cert into Mailu, reload (timer) | +| `provision-mailbox.sh` | create a member mailbox / the gateway master user | + +`mailu.env`, `certs/`, and `data/` are gitignored (secrets + state). + +## Gateway master user + +The agentbbs gateway opens any member's mailbox over IMAP with a single secret, +using Dovecot's **master user** feature (login `*`). Enable it with +a Dovecot override so Mailu accepts the `*` separator: + +`data/overrides/dovecot/auth-master.conf`: + +``` +auth_master_user_separator = * +passdb { + driver = static + args = nopassword=y + master = yes + result_success = continue +} +``` + +Then create the master account and point agentbbs at it: + +```bash +./provision-mailbox.sh --master "$(openssl rand -hex 16)" +# AGENTBBS_MAIL_MASTER_USER=gateway, AGENTBBS_MAIL_MASTER_PASS= +``` + +> The exact master-passdb wiring varies by Mailu version; verify against your +> pinned image before relying on it in production. SMTP submission from the +> gateway uses the trusted local relay (`127.0.0.1:25`), not the master user. + +## Ops + +```bash +docker compose up -d # start +docker compose logs -f smtp # tail Postfix +docker compose exec admin flask mailu config-export # DKIM keys, etc. +docker compose down # stop +``` diff --git a/deploy/mailu/docker-compose.yml b/deploy/mailu/docker-compose.yml new file mode 100644 index 0000000..8cd286f --- /dev/null +++ b/deploy/mailu/docker-compose.yml @@ -0,0 +1,83 @@ +# Mailu stack for mail.profullstack.com — self-hosted Postfix + Dovecot + +# Roundcube + rspamd. Coexists with the host Caddy: Mailu owns the mail ports +# (25/465/587/993/995) and serves HTTP on loopback only; Caddy fronts the +# webmail at https://mail.profullstack.com and supplies the TLS cert +# (TLS_FLAVOR=mail, certs copied by refresh-certs.sh). +# +# Pinned to a Mailu release; bump deliberately. See docs/mail.md. +x-environment: &default-environment + env_file: mailu.env + +services: + redis: + image: redis:alpine + restart: always + volumes: + - "./data/redis:/data" + + front: + image: ghcr.io/mailu/nginx:2024.06 + restart: always + env_file: mailu.env + ports: + # Mail ports bound on the host; HTTP only on loopback for Caddy. + - "25:25" + - "465:465" + - "587:587" + - "993:993" + - "995:995" + - "127.0.0.1:8080:80" + volumes: + - "./certs:/certs" + - "./data/overrides/nginx:/overrides:ro" + depends_on: + - redis + + admin: + image: ghcr.io/mailu/admin:2024.06 + restart: always + env_file: mailu.env + volumes: + - "./data/data:/data" + - "./data/dkim:/dkim" + depends_on: + - redis + + imap: + image: ghcr.io/mailu/dovecot:2024.06 + restart: always + env_file: mailu.env + volumes: + - "./data/mail:/mail" + - "./data/overrides/dovecot:/overrides:ro" + depends_on: + - front + + smtp: + image: ghcr.io/mailu/postfix:2024.06 + restart: always + env_file: mailu.env + volumes: + - "./data/mailqueue:/queue" + - "./data/overrides/postfix:/overrides:ro" + depends_on: + - front + + antispam: + image: ghcr.io/mailu/rspamd:2024.06 + restart: always + env_file: mailu.env + volumes: + - "./data/filter:/var/lib/rspamd" + - "./data/overrides/rspamd:/overrides:ro" + depends_on: + - front + + webmail: + image: ghcr.io/mailu/roundcube:2024.06 + restart: always + env_file: mailu.env + volumes: + - "./data/webmail:/data" + depends_on: + - front diff --git a/deploy/mailu/mailu.env.example b/deploy/mailu/mailu.env.example new file mode 100644 index 0000000..273de92 --- /dev/null +++ b/deploy/mailu/mailu.env.example @@ -0,0 +1,44 @@ +# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env +# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway). +# +# Generate secrets with: openssl rand -hex 16 + +# --- General ----------------------------------------------------------------- +SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16 +DOMAIN=mail.profullstack.com # member addresses are @mail.profullstack.com +HOSTNAMES=mail.profullstack.com,smtp.profullstack.com +POSTMASTER=postmaster +# Apex profullstack.com is reserved for corporate mail and is NOT served here. + +# TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed +# it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh). +TLS_FLAVOR=mail + +# --- Features ---------------------------------------------------------------- +ADMIN=true # the admin UI (fronted at /admin via Caddy, internal only) +WEBMAIL=roundcube # the only member-facing surface (https://mail.profullstack.com) +WEBDAV=none +ANTIVIRUS=none # set to clamav on a 4GB+ host +ANTISPAM=true + +# --- Networking -------------------------------------------------------------- +# Mailu's front binds the mail ports on the host and HTTP on loopback only; +# Caddy reverse-proxies https://mail.profullstack.com to BIND_ADDRESS4:80. +BIND_ADDRESS4=127.0.0.1 +SUBNET=192.168.203.0/24 +MESSAGE_SIZE_LIMIT=52428800 # 50 MB + +# --- Gateway (the BBS reads/sends on behalf of members) ---------------------- +# A Dovecot master user lets the agentbbs gateway open any member's mailbox with +# one secret (login "*"). Created by deploy/mailu/provision-mailbox.sh. +# Mirror these into the agentbbs service env: +# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com +# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993 +# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25 +# AGENTBBS_MAIL_MASTER_USER=gateway +# AGENTBBS_MAIL_MASTER_PASS= + +# --- Admin bootstrap --------------------------------------------------------- +INITIAL_ADMIN_ACCOUNT=admin +INITIAL_ADMIN_DOMAIN=mail.profullstack.com +INITIAL_ADMIN_PW=CHANGEME_admin_password diff --git a/deploy/mailu/provision-mailbox.sh b/deploy/mailu/provision-mailbox.sh new file mode 100755 index 0000000..106e3f5 --- /dev/null +++ b/deploy/mailu/provision-mailbox.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# provision-mailbox.sh — create or update a member mailbox on the Mailu stack. +# Run on the mail host. The agentbbs gateway opens any member's mailbox via the +# Dovecot master user, so members never need an individual IMAP password — but +# the mailbox must exist, which is what this creates. +# +# Usage: +# provision-mailbox.sh # create @$DOMAIN (random pw) +# provision-mailbox.sh --master # (re)create the gateway master user +# +# Idempotent: re-running for an existing user is a no-op (or a password reset +# with --password). Wraps Mailu's admin CLI (flask mailu ...). +set -euo pipefail + +MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}" +DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}" +MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}" +QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB + +cli() { ( cd "$MAILU_DIR" && docker compose exec -T admin flask mailu "$@" ); } + +if [ "${1:-}" = "--master" ]; then + pass="${2:?usage: provision-mailbox.sh --master }" + # A Dovecot master user can authenticate as any mailbox: login "*gateway". + # Implemented in Mailu as a normal user flagged for master access via an + # override (see docs/mail.md); here we ensure the account + password exist. + cli user "$MASTER_USER" "$DOMAIN" "$pass" 2>/dev/null \ + || cli password "$MASTER_USER" "$DOMAIN" "$pass" + echo "gateway master user ${MASTER_USER}@${DOMAIN} set" + exit 0 +fi + +name="${1:?usage: provision-mailbox.sh }" +pass="${2:-$(openssl rand -hex 16)}" + +if cli user-import "$name" "$DOMAIN" "$(openssl passwd -6 "$pass")" 2>/dev/null; then + : +else + # already exists or older CLI: fall back to `user` (no-op if present) + cli user "$name" "$DOMAIN" "$pass" 2>/dev/null || true +fi +# Enforce a per-mailbox quota. +cli config-update </dev/null || true +users: + - email: ${name}@${DOMAIN} + quota_bytes: ${QUOTA_BYTES} +EOF + +echo "mailbox ${name}@${DOMAIN} provisioned" diff --git a/deploy/mailu/refresh-certs.sh b/deploy/mailu/refresh-certs.sh new file mode 100755 index 0000000..37a598f --- /dev/null +++ b/deploy/mailu/refresh-certs.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# refresh-certs.sh — copy Caddy's Let's Encrypt cert for mail.$DOMAIN into the +# Mailu certs dir (TLS_FLAVOR=mail), so Postfix/Dovecot TLS on 465/587/993 track +# Caddy's auto-renewals. Mirrors deploy/news-refresh-certs.sh: Caddy is the only +# ACME client on the box (it serves the mail.$DOMAIN site block), and we reuse +# that cert rather than running a second ACME client inside Mailu. +# +# Install to /usr/local/bin/agentbbs-mailu-certs and run from a timer. Reloads +# the Mailu front/smtp/imap so the new cert is picked up. Exits non-zero +# (touching nothing) until Caddy has issued the cert. +set -euo pipefail + +DOMAIN="${DOMAIN:?set DOMAIN}" +MAIL_HOST="${MAIL_HOST:-mail.${DOMAIN}}" +MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}" +CERT_DIR="${CERT_DIR:-$MAILU_DIR/certs}" +CADDY_DATA="${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}" + +# Caddy stores certs under certificates///.{crt,key}; +# the ACME directory segment varies (prod vs staging), so glob for it. +crt="$(ls "$CADDY_DATA"/certificates/*/"$MAIL_HOST"/"$MAIL_HOST".crt 2>/dev/null | head -1 || true)" +key="$(ls "$CADDY_DATA"/certificates/*/"$MAIL_HOST"/"$MAIL_HOST".key 2>/dev/null | head -1 || true)" +if [ -z "$crt" ] || [ -z "$key" ]; then + echo "no Caddy cert for $MAIL_HOST yet (looked under $CADDY_DATA/certificates)" + exit 1 +fi + +install -d -m 0750 "$CERT_DIR" + +changed=0 +# Mailu (TLS_FLAVOR=mail) reads cert.pem / key.pem from its /certs mount. +if ! cmp -s "$crt" "$CERT_DIR/cert.pem"; then install -m 0644 "$crt" "$CERT_DIR/cert.pem"; changed=1; fi +if ! cmp -s "$key" "$CERT_DIR/key.pem"; then install -m 0640 "$key" "$CERT_DIR/key.pem"; changed=1; fi + +if [ "$changed" = 1 ]; then + echo "updated Mailu TLS cert for $MAIL_HOST; reloading Mailu" + ( cd "$MAILU_DIR" && docker compose restart front smtp imap >/dev/null 2>&1 || true ) +else + echo "Mailu TLS cert for $MAIL_HOST already current" +fi diff --git a/docs/mail.md b/docs/mail.md new file mode 100644 index 0000000..5920fd6 --- /dev/null +++ b/docs/mail.md @@ -0,0 +1,118 @@ +# Mail — self-hosted Mailu at `mail.profullstack.com` + +AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at +`@mail.profullstack.com`, reached two ways: + +- **Webmail** — `https://mail.profullstack.com` (Roundcube), the only + member-facing mail surface. +- **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry + or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for + agents). It connects to this stack. + +The apex `profullstack.com` is **reserved for corporate mail** and is not served +here — member mail lives only on the `mail.` subdomain. + +## Architecture + +The host already runs **Caddy** (owns `:80`/`:443`) and the **agentbbs** process. +Mailu (Postfix + Dovecot + Roundcube + rspamd) runs as a Docker Compose stack: + +- Mailu owns the **mail ports** on the host: `25, 465, 587, 993, 995`. +- Mailu's HTTP front is bound to **loopback** (`127.0.0.1:8080`); **Caddy** + reverse-proxies `https://mail.profullstack.com` to it (webmail + admin). +- **TLS:** `TLS_FLAVOR=mail` — Mailu does *not* run its own ACME (Caddy is the + only ACME client). Caddy obtains the `mail.profullstack.com` cert from its site + block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh) + copies it into Mailu and reloads it on renewal — the same pattern as the + Ergo/IRC and NNTP cert refreshers. +- The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot + **master user** (one secret opens any mailbox), SMTP via the co-located relay + on `127.0.0.1:25`. Members therefore never manage an IMAP/SMTP password. + +``` + ┌─────────── Caddy (:443) ───────────┐ + webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP) + └───────────────┬─────────────────────┘ + │ copies LE cert (refresh-certs.sh) + clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd + ▲ + agentbbs ──IMAP 993 (master user)──┘ ──SMTP 127.0.0.1:25 (local relay)──▶ +``` + +## DNS + +`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set: + +| Type | Host | Value | +|---|---|---| +| A | `mail.profullstack.com` | host IP | +| A | `smtp.profullstack.com` | host IP | +| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` | +| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` | +| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` | +| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot | +| PTR | host IP | `mail.profullstack.com` (set at your VPS provider) | + +> **Port 25 / deliverability:** many cloud providers block outbound `:25` by +> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay +> outbound through a smarthost. Inbound MX and the gateway's local submission +> work regardless. + +## Install + +```bash +cd /opt/agentbbs/deploy/mailu +cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, etc. +docker compose up -d +# seed the gateway master user + (optionally) backfill member mailboxes: +AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)" +``` + +Add the Caddy site (setup.sh writes this when `MAIL=1`): + +``` +mail.profullstack.com { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 +} +``` + +Then install the cert refresher on a timer (setup.sh does this too): + +```bash +install -m 0755 deploy/mailu/refresh-certs.sh /usr/local/bin/agentbbs-mailu-certs +# systemd timer runs it every ~12h; first run swaps in the real cert once Caddy issues it. +``` + +## agentbbs gateway env + +Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work: + +| Var | Value | +|---|---| +| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` | +| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` | +| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` | +| `AGENTBBS_MAIL_MASTER_USER` | `gateway` | +| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above | + +## Provisioning member mailboxes + +A mailbox must exist before the gateway can open it. Provision when a member +becomes paid (or backfill): + +```bash +deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com +``` + +The Dovecot **master user** (`gateway`) then authenticates as any member with +the login form `alice*gateway` + the master password — which is exactly what +`internal/mailbox`'s IMAP adapter sends. See +[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user +override and operational details. + +## Webmail only for members + +Members are pointed at `https://mail.profullstack.com` (Roundcube) and the BBS +`Mail` client — they are not given the Mailu admin UI or alias management. Admin +is operator-only. diff --git a/go.mod b/go.mod index 9fadebd..70691b4 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/charmbracelet/wish v1.4.7 github.com/creack/pty v1.1.24 github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/emersion/go-message v0.18.2 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/livekit/protocol v1.46.0 github.com/livekit/server-sdk-go/v2 v2.16.6 @@ -46,6 +48,7 @@ require ( github.com/dennwc/iters v1.2.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/frostbyte73/core v0.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/go.sum b/go.sum index 12f22d1..41d9e03 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,12 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 h1:R90ND7acg9HKYj3oJBKKefk73DULdC7IlcnS7MV0X1s= github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y= github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f1f287e..1679036 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -66,6 +66,10 @@ var IRCNames = map[string]bool{"irc": true} // via an in-process newsreader. Free for any registered member, like irc@. var NewsNames = map[string]bool{"news": true} +// MailNames route a Founding Lifetime (paid) member into the AgentMail client — +// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY. +var MailNames = map[string]bool{"mail": true} + // GameNames are usernames that route to AgentGames: the line-delimited-JSON // agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias. var GameNames = map[string]bool{"game": true, "games": true} @@ -97,6 +101,16 @@ func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] } // IsNewsName reports whether the SSH username requests the in-BBS newsreader. func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] } +// IsMailName reports whether the SSH username requests the AgentMail client. +func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] } + +// MsgNames route a member-to-member message: `ssh msg@host ` leaves a +// note in the recipient's BBS inbox (store-and-forward, see the Members plugin). +var MsgNames = map[string]bool{"msg": true, "message": true} + +// IsMsgName reports whether the SSH username requests the messaging route. +func IsMsgName(u string) bool { return MsgNames[strings.ToLower(u)] } + // systemReserved are names that don't drive an SSH route but would still // collide with a per-user subdomain (.), the agent route, or common // infra hostnames — so members may not claim them as account names. @@ -113,7 +127,8 @@ var systemReserved = map[string]bool{ func IsReservedName(name string) bool { n := strings.ToLower(name) if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || - TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || systemReserved[n] { + TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || + MsgNames[n] || systemReserved[n] { return true } return strings.HasPrefix(n, "video-") // video- call routes diff --git a/internal/brand/banner.go b/internal/brand/banner.go new file mode 100644 index 0000000..fd6c3e6 --- /dev/null +++ b/internal/brand/banner.go @@ -0,0 +1,26 @@ +// Package brand holds AgentBBS's terminal branding — the ASCII logo shown +// at the top of the join@ onboarding and the ssh @ hub prompts. +// Scaled down from the Profullstack mark. +package brand + +import "strings" + +// logo is the ASCII rendition of the brand mark (UTF-8 block glyphs). +const logo = ` + ▓████▓▒ + ▒█████▓ + +▒▓ +▓████▓+ ▒+ + :+▒▓▓▓▓ :▓██▓█▓+ ▒▓▓▓▒+ + +▒▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓+ ▒▓▓▓▓▓▒▒+ +:+▒▓▓▓▓▓▓▓▒+ ▒▓▓▓▓▓▒ +▒▓▓▓▓▓▓▒▒: +▒▓▓▒▓▒▒▒: +▓▓▓▓▓▓ :▒▒▒▒▒▒▒▒ +▒▒▒▒++ ▒▓▓▓▓▓+ ++▒▒▒▒ +▒▒+ +▓▓▓▓▓▒ ++▒ +▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒ +▒▒▒▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒▒▒▒▒ ++▒▒▒▒▒▒▒▒▒+ +▓▓▓▓▓: +▒▒▒▒▒▒▒▒++ +` + +// Logo returns the plain (unstyled) multi-line ASCII brand banner, without +// leading or trailing blank lines. The hub/join@ flows apply their own color. +func Logo() string { return strings.Trim(logo, "\n") } diff --git a/internal/hub/hub.go b/internal/hub/hub.go index d809534..cbe4d73 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -24,7 +24,7 @@ import ( var ( theme = ui.New(ui.Green) - bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Cyan) + bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a")) ) // SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC diff --git a/internal/mailbox/bot.go b/internal/mailbox/bot.go new file mode 100644 index 0000000..db12c52 --- /dev/null +++ b/internal/mailbox/bot.go @@ -0,0 +1,173 @@ +package mailbox + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" +) + +// BotUsage is printed when a bot/agent invokes mail with no/unknown command. +const BotUsage = `agentmail (non-interactive mode) — JSON in, JSON out: + + mailboxes list folders + list [mailbox] [limit] message summaries (default INBOX) + read full message (marks seen; "peek" 4th arg to keep unseen) + search [mailbox] search summaries + send read a JSON Draft on stdin, send it + reply read {"text":...,"replyAll":bool} on stdin + flag [on|off] set/clear the flagged flag + seen [on|off] set/clear the seen flag + delete delete a message + +Example: ssh mail@bbs.profullstack.com list INBOX 20` + +// RunBot executes one non-interactive command for agents/bots, writing JSON to +// out. It returns an error (also emitted as {"error":...}) on failure. +func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io.Writer) error { + if len(args) == 0 { + fmt.Fprintln(out, BotUsage) + return nil + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + fail := func(err error) error { + _ = enc.Encode(map[string]string{"error": err.Error()}) + return err + } + + switch strings.ToLower(args[0]) { + case "mailboxes": + v, err := c.Mailboxes(ctx) + if err != nil { + return fail(err) + } + return enc.Encode(v) + + case "list", "ls": + mailbox := Inbox + if len(args) > 1 { + mailbox = args[1] + } + limit := 0 + if len(args) > 2 { + limit, _ = strconv.Atoi(args[2]) + } + v, err := c.List(ctx, mailbox, limit) + if err != nil { + return fail(err) + } + return enc.Encode(v) + + case "read", "show": + mailbox, uid, err := mailboxUID(args) + if err != nil { + return fail(err) + } + peek := len(args) > 3 && strings.EqualFold(args[3], "peek") + msg, ok, err := c.Read(ctx, mailbox, uid, peek) + if err != nil { + return fail(err) + } + if !ok { + return fail(notFound(mailbox, uid)) + } + return enc.Encode(msg) + + case "search": + if len(args) < 2 { + return fail(fmt.Errorf("usage: search [mailbox]")) + } + mailbox := "" + if len(args) > 2 { + mailbox = args[2] + } + v, err := c.Search(ctx, args[1], mailbox, 0) + if err != nil { + return fail(err) + } + return enc.Encode(v) + + case "send": + var d Draft + if err := json.NewDecoder(in).Decode(&d); err != nil { + return fail(fmt.Errorf("send expects a JSON Draft on stdin: %w", err)) + } + res, err := c.Send(ctx, d) + if err != nil { + return fail(err) + } + return enc.Encode(res) + + case "reply": + mailbox, uid, err := mailboxUID(args) + if err != nil { + return fail(err) + } + var body struct { + Text string `json:"text"` + ReplyAll bool `json:"replyAll"` + } + if err := json.NewDecoder(in).Decode(&body); err != nil { + return fail(fmt.Errorf("reply expects {\"text\":...} on stdin: %w", err)) + } + orig, ok, err := c.Read(ctx, mailbox, uid, true) + if err != nil { + return fail(err) + } + if !ok { + return fail(notFound(mailbox, uid)) + } + res, err := c.Reply(ctx, orig, body.Text, body.ReplyAll) + if err != nil { + return fail(err) + } + return enc.Encode(res) + + case "flag", "seen": + mailbox, uid, err := mailboxUID(args) + if err != nil { + return fail(err) + } + on := true + if len(args) > 3 { + on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0" + } + if args[0] == "flag" { + err = c.Flag(ctx, mailbox, uid, on) + } else { + err = c.MarkSeen(ctx, mailbox, uid, on) + } + if err != nil { + return fail(err) + } + return enc.Encode(map[string]any{"ok": true, "mailbox": mailbox, "uid": uid, args[0]: on}) + + case "delete", "rm": + mailbox, uid, err := mailboxUID(args) + if err != nil { + return fail(err) + } + if err := c.Delete(ctx, mailbox, uid); err != nil { + return fail(err) + } + return enc.Encode(map[string]any{"ok": true, "deleted": map[string]any{"mailbox": mailbox, "uid": uid}}) + + default: + fmt.Fprintln(out, BotUsage) + return fmt.Errorf("unknown command: %s", args[0]) + } +} + +func mailboxUID(args []string) (string, uint32, error) { + if len(args) < 3 { + return "", 0, fmt.Errorf("usage: %s ", args[0]) + } + uid, err := strconv.ParseUint(args[2], 10, 32) + if err != nil { + return "", 0, fmt.Errorf("invalid uid %q", args[2]) + } + return args[1], uint32(uid), nil +} diff --git a/internal/mailbox/client.go b/internal/mailbox/client.go new file mode 100644 index 0000000..aba1928 --- /dev/null +++ b/internal/mailbox/client.go @@ -0,0 +1,172 @@ +package mailbox + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// Identity is the acting member and whether they hold the paid membership. +type Identity struct { + Name string // local-part / handle, e.g. "alice" + Paid bool // Founding Lifetime Member; mail is gated on this +} + +// ErrNotPaid is returned to a non-paid member attempting a mail action. +var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com") + +// Client is the ergonomic, paid-gated facade the TUI and bot mode use. Every +// method returns plain structs, so the same calls serve humans and agents. +type Client struct { + t Transport + id Identity + domain string + pageSize int +} + +// NewClient builds a paid-gated client. domain is the mail domain (e.g. +// mail.profullstack.com); pageSize defaults to 50 when <= 0. +func NewClient(t Transport, id Identity, domain string, pageSize int) *Client { + if pageSize <= 0 { + pageSize = 50 + } + return &Client{t: t, id: id, domain: domain, pageSize: pageSize} +} + +// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com. +func (c *Client) Address() string { return c.id.Name + "@" + c.domain } + +func (c *Client) gate() error { + if c.id.Name == "" || !c.id.Paid { + return ErrNotPaid + } + return nil +} + +// Mailboxes lists folders with unread/total counts. +func (c *Client) Mailboxes(ctx context.Context) ([]Mailbox, error) { + if err := c.gate(); err != nil { + return nil, err + } + return c.t.ListMailboxes(ctx) +} + +// List returns newest-first summaries for a mailbox (INBOX when empty). +func (c *Client) List(ctx context.Context, mailbox string, limit int) ([]MessageSummary, error) { + if err := c.gate(); err != nil { + return nil, err + } + if mailbox == "" { + mailbox = Inbox + } + if limit <= 0 { + limit = c.pageSize + } + return c.t.ListMessages(ctx, ListOptions{Mailbox: mailbox, Limit: limit}) +} + +// Read fetches a full message, marking it seen unless peek is true. +func (c *Client) Read(ctx context.Context, mailbox string, uid uint32, peek bool) (Message, bool, error) { + if err := c.gate(); err != nil { + return Message{}, false, err + } + msg, ok, err := c.t.ReadMessage(ctx, mailbox, uid) + if err != nil || !ok { + return msg, ok, err + } + if !peek && !msg.Seen { + seen := true + if err := c.t.SetFlags(ctx, mailbox, uid, FlagChange{Seen: &seen}); err == nil { + msg.Seen = true + } + } + return msg, true, nil +} + +// Search runs a free-text search across a mailbox (or all when empty). +func (c *Client) Search(ctx context.Context, query, mailbox string, limit int) ([]MessageSummary, error) { + if err := c.gate(); err != nil { + return nil, err + } + if limit <= 0 { + limit = c.pageSize + } + return c.t.Search(ctx, SearchOptions{Query: query, Mailbox: mailbox, Limit: limit}) +} + +// Send validates, stamps From, and sends a draft. +func (c *Client) Send(ctx context.Context, d Draft) (SendResult, error) { + if err := c.gate(); err != nil { + return SendResult{}, err + } + norm, err := NormalizeDraft(d) + if err != nil { + return SendResult{}, err + } + return c.t.Send(ctx, c.Address(), norm) +} + +// Reply addresses the original sender (and, when replyAll, the other recipients +// minus the member), prefixes "Re:", threads via In-Reply-To, and sends. +func (c *Client) Reply(ctx context.Context, orig Message, text string, replyAll bool) (SendResult, error) { + if err := c.gate(); err != nil { + return SendResult{}, err + } + self := strings.ToLower(c.Address()) + to := orig.From + if orig.ReplyTo != nil { + to = *orig.ReplyTo + } + var cc []Address + if replyAll { + for _, a := range append(append([]Address{}, orig.To...), orig.CC...) { + la := strings.ToLower(a.Address) + if la != self && la != strings.ToLower(to.Address) { + cc = append(cc, a) + } + } + } + subject := orig.Subject + if !strings.HasPrefix(strings.ToLower(subject), "re:") { + subject = "Re: " + subject + } + return c.Send(ctx, Draft{To: []Address{to}, CC: cc, Subject: subject, Text: text, InReplyTo: orig.MessageID}) +} + +// Flag sets or clears the \Flagged flag. +func (c *Client) Flag(ctx context.Context, mailbox string, uid uint32, flagged bool) error { + if err := c.gate(); err != nil { + return err + } + return c.t.SetFlags(ctx, mailbox, uid, FlagChange{Flagged: &flagged}) +} + +// MarkSeen sets or clears the \Seen flag. +func (c *Client) MarkSeen(ctx context.Context, mailbox string, uid uint32, seen bool) error { + if err := c.gate(); err != nil { + return err + } + return c.t.SetFlags(ctx, mailbox, uid, FlagChange{Seen: &seen}) +} + +// Delete removes a message. +func (c *Client) Delete(ctx context.Context, mailbox string, uid uint32) error { + if err := c.gate(); err != nil { + return err + } + return c.t.DeleteMessage(ctx, mailbox, uid) +} + +// Close releases the underlying transport. +func (c *Client) Close() error { + if c.t == nil { + return nil + } + return c.t.Close() +} + +// helper used by bot mode to surface a friendly "not found" message. +func notFound(mailbox string, uid uint32) error { + return fmt.Errorf("%w: %s/%d", ErrNotFound, mailbox, uid) +} diff --git a/internal/mailbox/imap.go b/internal/mailbox/imap.go new file mode 100644 index 0000000..44ab7f3 --- /dev/null +++ b/internal/mailbox/imap.go @@ -0,0 +1,336 @@ +package mailbox + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "sync" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + gomail "github.com/emersion/go-message/mail" +) + +// IMAPConfig connects the adapter to the mail backend (Mailu: Dovecot IMAP + +// Postfix submission). Username/Password are the resolved login (which may be a +// Dovecot master-user login like "alice*gateway"). +type IMAPConfig struct { + IMAPAddr string // host:port, e.g. mail.profullstack.com:993 (implicit TLS) + SMTPAddr string // host:port, e.g. smtp.profullstack.com:587 (STARTTLS) + Username string + Password string + // SMTPUser/SMTPPass default to Username/Password when empty. + SMTPUser string + SMTPPass string +} + +// imapTransport is a Transport backed by a single authenticated IMAP connection +// plus SMTP submission. Commands are serialized (the IMAP client is not safe for +// concurrent in-flight commands). +type imapTransport struct { + cfg IMAPConfig + mu sync.Mutex + c *imapclient.Client + selected string +} + +// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport. +func NewIMAPTransport(cfg IMAPConfig) (Transport, error) { + c, err := imapclient.DialTLS(cfg.IMAPAddr, nil) + if err != nil { + return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err) + } + if err := c.Login(cfg.Username, cfg.Password).Wait(); err != nil { + _ = c.Close() + return nil, fmt.Errorf("imap login: %w", err) + } + return &imapTransport{cfg: cfg, c: c}, nil +} + +func (t *imapTransport) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.c == nil { + return nil + } + _ = t.c.Logout().Wait() + return t.c.Close() +} + +func (t *imapTransport) selectMailbox(name string, readOnly bool) (*imap.SelectData, error) { + data, err := t.c.Select(name, &imap.SelectOptions{ReadOnly: readOnly}).Wait() + if err != nil { + return nil, fmt.Errorf("select %s: %w", name, err) + } + t.selected = name + return data, nil +} + +func (t *imapTransport) ListMailboxes(_ context.Context) ([]Mailbox, error) { + t.mu.Lock() + defer t.mu.Unlock() + entries, err := t.c.List("", "*", nil).Collect() + if err != nil { + return nil, fmt.Errorf("list: %w", err) + } + out := make([]Mailbox, 0, len(entries)) + for _, e := range entries { + mb := Mailbox{Name: e.Mailbox, Path: e.Mailbox} + st, err := t.c.Status(e.Mailbox, &imap.StatusOptions{NumMessages: true, NumUnseen: true}).Wait() + if err == nil { + if st.NumMessages != nil { + mb.Total = int(*st.NumMessages) + } + if st.NumUnseen != nil { + mb.Unseen = int(*st.NumUnseen) + } + } + out = append(out, mb) + } + return out, nil +} + +func (t *imapTransport) ListMessages(_ context.Context, opts ListOptions) ([]MessageSummary, error) { + t.mu.Lock() + defer t.mu.Unlock() + sel, err := t.selectMailbox(opts.Mailbox, true) + if err != nil { + return nil, err + } + n := sel.NumMessages + if n == 0 { + return nil, nil + } + start := uint32(1) + if opts.Limit > 0 && n > uint32(opts.Limit) { + start = n - uint32(opts.Limit) + 1 + } + seq := imap.SeqSet(nil) + seq.AddRange(start, n) + bufs, err := t.c.Fetch(seq, &imap.FetchOptions{Envelope: true, Flags: true, UID: true}).Collect() + if err != nil { + return nil, fmt.Errorf("fetch: %w", err) + } + rows := make([]MessageSummary, 0, len(bufs)) + for _, b := range bufs { + rows = append(rows, summaryFromBuf(opts.Mailbox, b)) + } + // newest first + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + return rows, nil +} + +func (t *imapTransport) ReadMessage(_ context.Context, mailbox string, uid uint32) (Message, bool, error) { + t.mu.Lock() + defer t.mu.Unlock() + if _, err := t.selectMailbox(mailbox, false); err != nil { + return Message{}, false, err + } + set := imap.UIDSetNum(imap.UID(uid)) + section := &imap.FetchItemBodySection{} + bufs, err := t.c.Fetch(set, &imap.FetchOptions{ + Envelope: true, + Flags: true, + UID: true, + BodySection: []*imap.FetchItemBodySection{section}, + }).Collect() + if err != nil { + return Message{}, false, fmt.Errorf("fetch uid %d: %w", uid, err) + } + if len(bufs) == 0 { + return Message{}, false, nil + } + b := bufs[0] + msg := Message{MessageSummary: summaryFromBuf(mailbox, b)} + raw := b.FindBodySection(section) + if len(raw) > 0 { + fillBody(&msg, raw) + } + msg.Snippet = Snippet(msg.Text, 140) + return msg, true, nil +} + +func (t *imapTransport) Search(_ context.Context, opts SearchOptions) ([]MessageSummary, error) { + t.mu.Lock() + defer t.mu.Unlock() + mailbox := opts.Mailbox + if mailbox == "" { + mailbox = Inbox + } + if _, err := t.selectMailbox(mailbox, true); err != nil { + return nil, err + } + data, err := t.c.UIDSearch(&imap.SearchCriteria{Text: []string{opts.Query}}, nil).Wait() + if err != nil { + return nil, fmt.Errorf("search: %w", err) + } + uids := data.AllUIDs() + if len(uids) == 0 { + return nil, nil + } + if opts.Limit > 0 && len(uids) > opts.Limit { + uids = uids[len(uids)-opts.Limit:] + } + bufs, err := t.c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{Envelope: true, Flags: true, UID: true}).Collect() + if err != nil { + return nil, fmt.Errorf("search fetch: %w", err) + } + rows := make([]MessageSummary, 0, len(bufs)) + for _, b := range bufs { + rows = append(rows, summaryFromBuf(mailbox, b)) + } + return rows, nil +} + +func (t *imapTransport) Send(_ context.Context, from string, d Draft) (SendResult, error) { + msg, msgID := buildRFC822(from, d) + // SMTPUser may be empty for a trusted local relay (no AUTH). + if err := smtpSend(t.cfg.SMTPAddr, t.cfg.SMTPUser, t.cfg.SMTPPass, from, recipients(d), msg); err != nil { + return SendResult{}, fmt.Errorf("smtp send: %w", err) + } + // Best-effort copy to Sent so the message shows in the member's mailbox. + t.mu.Lock() + _, _ = t.c.Append(Sent, int64(len(msg)), nil).Wait() // ignore APPEND errors + t.mu.Unlock() + return SendResult{MessageID: msgID}, nil +} + +func (t *imapTransport) SetFlags(_ context.Context, mailbox string, uid uint32, fc FlagChange) error { + t.mu.Lock() + defer t.mu.Unlock() + if _, err := t.selectMailbox(mailbox, false); err != nil { + return err + } + set := imap.UIDSetNum(imap.UID(uid)) + apply := func(flag imap.Flag, on bool) error { + op := imap.StoreFlagsDel + if on { + op = imap.StoreFlagsAdd + } + return t.c.Store(set, &imap.StoreFlags{Op: op, Flags: []imap.Flag{flag}, Silent: true}, nil).Close() + } + if fc.Seen != nil { + if err := apply(imap.FlagSeen, *fc.Seen); err != nil { + return fmt.Errorf("store seen: %w", err) + } + } + if fc.Flagged != nil { + if err := apply(imap.FlagFlagged, *fc.Flagged); err != nil { + return fmt.Errorf("store flagged: %w", err) + } + } + return nil +} + +func (t *imapTransport) DeleteMessage(_ context.Context, mailbox string, uid uint32) error { + t.mu.Lock() + defer t.mu.Unlock() + if _, err := t.selectMailbox(mailbox, false); err != nil { + return err + } + set := imap.UIDSetNum(imap.UID(uid)) + if err := t.c.Store(set, &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true}, nil).Close(); err != nil { + return fmt.Errorf("store deleted: %w", err) + } + if err := t.c.Expunge().Close(); err != nil { + return fmt.Errorf("expunge: %w", err) + } + return nil +} + +func summaryFromBuf(mailbox string, b *imapclient.FetchMessageBuffer) MessageSummary { + s := MessageSummary{Mailbox: mailbox, UID: uint32(b.UID)} + if b.Envelope != nil { + env := b.Envelope + s.Subject = env.Subject + s.Date = env.Date + s.From = firstAddr(env.From) + s.To = addrs(env.To) + } + for _, f := range b.Flags { + switch f { + case imap.FlagSeen: + s.Seen = true + case imap.FlagFlagged: + s.Flagged = true + } + } + return s +} + +func firstAddr(list []imap.Address) Address { + if len(list) == 0 { + return Address{} + } + return imapAddr(list[0]) +} + +func addrs(list []imap.Address) []Address { + out := make([]Address, 0, len(list)) + for _, a := range list { + out = append(out, imapAddr(a)) + } + return out +} + +func imapAddr(a imap.Address) Address { + addr := a.Mailbox + if a.Host != "" { + addr = a.Mailbox + "@" + a.Host + } + return Address{Name: a.Name, Address: addr} +} + +// fillBody parses the raw RFC822 message into the text/html body + attachment +// metadata using go-message's mail reader. +func fillBody(msg *Message, raw []byte) { + mr, err := gomail.CreateReader(bytes.NewReader(raw)) + if err != nil { + // Not MIME we can parse — keep the raw text after the header break. + msg.Text = rawBody(raw) + return + } + if env := mr.Header; env.Get("Message-Id") != "" { + msg.MessageID = env.Get("Message-Id") + } + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + break + } + switch h := p.Header.(type) { + case *gomail.InlineHeader: + body, _ := io.ReadAll(p.Body) + ct, _, _ := h.ContentType() + if strings.EqualFold(ct, "text/html") { + msg.HTML = string(body) + } else if msg.Text == "" { + msg.Text = string(body) + } + case *gomail.AttachmentHeader: + name, _ := h.Filename() + ct, _, _ := h.ContentType() + body, _ := io.ReadAll(p.Body) + msg.Attachments = append(msg.Attachments, Attachment{Filename: name, ContentType: ct, Size: len(body)}) + } + } + msg.HasAttachments = len(msg.Attachments) > 0 +} + +// rawBody returns the body after the first blank line of a raw RFC822 message. +func rawBody(raw []byte) string { + if i := bytes.Index(raw, []byte("\r\n\r\n")); i >= 0 { + return string(raw[i+4:]) + } + if i := bytes.Index(raw, []byte("\n\n")); i >= 0 { + return string(raw[i+2:]) + } + return string(raw) +} diff --git a/internal/mailbox/mailbox_test.go b/internal/mailbox/mailbox_test.go new file mode 100644 index 0000000..09da9f1 --- /dev/null +++ b/internal/mailbox/mailbox_test.go @@ -0,0 +1,144 @@ +package mailbox + +import ( + "context" + "errors" + "testing" + "time" +) + +func seeded() *MemoryTransport { + m := NewMemoryTransport() + m.Add(Message{MessageSummary: MessageSummary{UID: 1, Mailbox: Inbox, From: Address{Name: "Carol", Address: "carol@example.com"}, Subject: "Welcome", Date: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)}, Text: "hi alice"}) + m.Add(Message{MessageSummary: MessageSummary{UID: 2, Mailbox: Inbox, From: Address{Address: "deploy@ci.example.com"}, Subject: "Build passed", Date: time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)}, Text: "all green"}) + return m +} + +func paidClient(t Transport) *Client { + return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50) +} + +func TestParseFormatAddress(t *testing.T) { + a := ParseAddress("Ada Lovelace ") + if a.Name != "Ada Lovelace" || a.Address != "ada@x.com" { + t.Fatalf("parse named: %+v", a) + } + if got := ParseAddress("ada@x.com"); got.Address != "ada@x.com" || got.Name != "" { + t.Fatalf("parse bare: %+v", got) + } + if got := FormatAddress(Address{Name: "Doe, John", Address: "j@x.com"}); got != `"Doe, John" ` { + t.Fatalf("format specials: %q", got) + } +} + +func TestValidEmailAndDraft(t *testing.T) { + if !ValidEmail("a@b.com") || ValidEmail("nope") { + t.Fatal("ValidEmail") + } + if _, err := NormalizeDraft(Draft{Subject: "x", Text: "y"}); err == nil { + t.Fatal("expected error for no recipients") + } + d, err := NormalizeDraft(Draft{To: []Address{{Address: " a@b.com "}}, Subject: " hi ", Text: "z"}) + if err != nil || len(d.To) != 1 || d.To[0].Address != "a@b.com" || d.Subject != "hi" { + t.Fatalf("normalize: %+v err=%v", d, err) + } +} + +func TestGate(t *testing.T) { + c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0) + if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) { + t.Fatalf("expected ErrNotPaid, got %v", err) + } +} + +// Inbox is a thin helper used in tests and bot mode. +func (c *Client) Inbox(ctx context.Context, limit int) ([]MessageSummary, error) { + return c.List(ctx, Inbox, limit) +} + +func TestListNewestFirst(t *testing.T) { + c := paidClient(seeded()) + got, err := c.List(context.Background(), Inbox, 0) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].UID != 2 { + t.Fatalf("expected newest first [2,1], got %+v", got) + } +} + +func TestReadMarksSeenAndPeek(t *testing.T) { + tr := seeded() + c := paidClient(tr) + if _, ok, _ := c.Read(context.Background(), Inbox, 1, false); !ok { + t.Fatal("read 1") + } + if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Seen { + t.Fatal("uid 1 should be seen") + } + if _, _, _ = c.Read(context.Background(), Inbox, 2, true); true { + if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 2); m.Seen { + t.Fatal("peek must not mark seen") + } + } + if _, ok, _ := c.Read(context.Background(), Inbox, 999, false); ok { + t.Fatal("unknown uid should be ok=false") + } +} + +func TestSearch(t *testing.T) { + c := paidClient(seeded()) + hits, _ := c.Search(context.Background(), "green", "", 0) + if len(hits) != 1 || hits[0].UID != 2 { + t.Fatalf("search green: %+v", hits) + } + hits, _ = c.Search(context.Background(), "carol@example.com", "", 0) + if len(hits) != 1 || hits[0].UID != 1 { + t.Fatalf("search sender: %+v", hits) + } +} + +func TestSendAndReply(t *testing.T) { + tr := seeded() + c := paidClient(tr) + res, err := c.Send(context.Background(), Draft{To: []Address{{Address: "carol@example.com"}}, Subject: " Hi ", Text: "yo"}) + if err != nil || res.MessageID == "" { + t.Fatalf("send: %v", err) + } + sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent}) + if len(sent) != 1 || sent[0].From.Address != "alice@mail.profullstack.com" || sent[0].Subject != "Hi" { + t.Fatalf("sent: %+v", sent) + } + + orig, _, _ := c.Read(context.Background(), Inbox, 1, true) + if _, err := c.Reply(context.Background(), orig, "thanks", false); err != nil { + t.Fatal(err) + } + sent, _ = tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent}) + var re MessageSummary + for _, s := range sent { + if s.Subject == "Re: Welcome" { + re = s + } + } + if re.Subject != "Re: Welcome" || re.To[0].Address != "carol@example.com" { + t.Fatalf("reply: %+v", sent) + } +} + +func TestFlagAndDelete(t *testing.T) { + tr := seeded() + c := paidClient(tr) + if err := c.Flag(context.Background(), Inbox, 1, true); err != nil { + t.Fatal(err) + } + if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Flagged { + t.Fatal("uid 1 should be flagged") + } + if err := c.Delete(context.Background(), Inbox, 1); err != nil { + t.Fatal(err) + } + if _, ok, _ := tr.ReadMessage(context.Background(), Inbox, 1); ok { + t.Fatal("uid 1 should be deleted") + } +} diff --git a/internal/mailbox/memory.go b/internal/mailbox/memory.go new file mode 100644 index 0000000..8ce108b --- /dev/null +++ b/internal/mailbox/memory.go @@ -0,0 +1,186 @@ +package mailbox + +import ( + "context" + "fmt" + "math/rand" + "sort" + "strings" + "sync" + "time" +) + +// MemoryTransport is a complete, dependency-free Transport for tests, local +// development, and as the reference for what the IMAP/SMTP adapter must do. +type MemoryTransport struct { + mu sync.Mutex + byMailbox map[string][]Message + nextUID uint32 +} + +// NewMemoryTransport returns an empty in-memory transport. +func NewMemoryTransport() *MemoryTransport { + return &MemoryTransport{byMailbox: map[string][]Message{}, nextUID: 1} +} + +// Add inserts a message, filling defaults, and returns the stored copy. +func (m *MemoryTransport) Add(msg Message) Message { + m.mu.Lock() + defer m.mu.Unlock() + if msg.UID == 0 { + msg.UID = m.nextUID + } + if msg.UID >= m.nextUID { + m.nextUID = msg.UID + 1 + } + if msg.Date.IsZero() { + msg.Date = time.Now().UTC() + } + if msg.MessageID == "" { + msg.MessageID = fmt.Sprintf("<%s@memory.local>", randToken()) + } + if msg.Snippet == "" { + msg.Snippet = Snippet(msg.Text, 140) + } + msg.HasAttachments = len(msg.Attachments) > 0 + m.byMailbox[msg.Mailbox] = append(m.byMailbox[msg.Mailbox], msg) + return msg +} + +func (m *MemoryTransport) ListMailboxes(_ context.Context) ([]Mailbox, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Mailbox, 0, len(m.byMailbox)) + for path, list := range m.byMailbox { + unseen := 0 + for _, msg := range list { + if !msg.Seen { + unseen++ + } + } + out = append(out, Mailbox{Name: path, Path: path, Total: len(list), Unseen: unseen}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out, nil +} + +func (m *MemoryTransport) ListMessages(_ context.Context, opts ListOptions) ([]MessageSummary, error) { + m.mu.Lock() + defer m.mu.Unlock() + list := append([]Message{}, m.byMailbox[opts.Mailbox]...) + sort.Slice(list, func(i, j int) bool { return list[i].Date.After(list[j].Date) }) + if opts.Limit > 0 && len(list) > opts.Limit { + list = list[:opts.Limit] + } + return summaries(list), nil +} + +func (m *MemoryTransport) ReadMessage(_ context.Context, mailbox string, uid uint32) (Message, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, msg := range m.byMailbox[mailbox] { + if msg.UID == uid { + return msg, true, nil + } + } + return Message{}, false, nil +} + +func (m *MemoryTransport) Search(_ context.Context, opts SearchOptions) ([]MessageSummary, error) { + m.mu.Lock() + defer m.mu.Unlock() + q := strings.ToLower(opts.Query) + var hits []Message + boxes := []string{opts.Mailbox} + if opts.Mailbox == "" { + boxes = boxes[:0] + for b := range m.byMailbox { + boxes = append(boxes, b) + } + } + for _, b := range boxes { + for _, msg := range m.byMailbox[b] { + hay := strings.ToLower(msg.Subject + " " + msg.From.Address + " " + msg.From.Name + " " + msg.Text) + if strings.Contains(hay, q) { + hits = append(hits, msg) + } + } + } + sort.Slice(hits, func(i, j int) bool { return hits[i].Date.After(hits[j].Date) }) + if opts.Limit > 0 && len(hits) > opts.Limit { + hits = hits[:opts.Limit] + } + return summaries(hits), nil +} + +func (m *MemoryTransport) Send(_ context.Context, from string, d Draft) (SendResult, error) { + domain := "memory.local" + if at := strings.LastIndexByte(from, '@'); at >= 0 { + domain = from[at+1:] + } + id := fmt.Sprintf("<%s@%s>", randToken(), domain) + var refs []string + if d.InReplyTo != "" { + refs = []string{d.InReplyTo} + } + m.Add(Message{ + MessageSummary: MessageSummary{Mailbox: Sent, From: Address{Address: from}, To: d.To, Subject: d.Subject, Seen: true}, + CC: d.CC, + Text: d.Text, + MessageID: id, + References: refs, + }) + return SendResult{MessageID: id}, nil +} + +func (m *MemoryTransport) SetFlags(_ context.Context, mailbox string, uid uint32, fc FlagChange) error { + m.mu.Lock() + defer m.mu.Unlock() + list := m.byMailbox[mailbox] + for i := range list { + if list[i].UID == uid { + if fc.Seen != nil { + list[i].Seen = *fc.Seen + } + if fc.Flagged != nil { + list[i].Flagged = *fc.Flagged + } + return nil + } + } + return ErrNotFound +} + +func (m *MemoryTransport) DeleteMessage(_ context.Context, mailbox string, uid uint32) error { + m.mu.Lock() + defer m.mu.Unlock() + list := m.byMailbox[mailbox] + for i := range list { + if list[i].UID == uid { + m.byMailbox[mailbox] = append(list[:i], list[i+1:]...) + return nil + } + } + return ErrNotFound +} + +func (m *MemoryTransport) Close() error { return nil } + +func summaries(list []Message) []MessageSummary { + out := make([]MessageSummary, len(list)) + for i, msg := range list { + s := msg.MessageSummary + s.HasAttachments = len(msg.Attachments) > 0 + out[i] = s + } + return out +} + +func randToken() string { + const alpha = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 10) + for i := range b { + b[i] = alpha[rand.Intn(len(alpha))] + } + return string(b) +} diff --git a/internal/mailbox/reader_tui.go b/internal/mailbox/reader_tui.go new file mode 100644 index 0000000..489440a --- /dev/null +++ b/internal/mailbox/reader_tui.go @@ -0,0 +1,251 @@ +package mailbox + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/ssh" +) + +var ( + mhTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + mhDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + mhCursor = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + mhUnseen = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0")) + mhFlag = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + mhErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + mhFrame = lipgloss.NewStyle().Padding(1, 2) +) + +// RunReader runs the interactive mail TUI for a member on the session. Used by +// the hub "Mail" entry and the ssh mail@ route (with a PTY). +func RunReader(s ssh.Session, c *Client) error { + m := readerModel{c: c, ctx: s.Context(), mailbox: Inbox, status: "loading…"} + p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen()) + _, err := p.Run() + return err +} + +type readerMode int + +const ( + modeList readerMode = iota + modeMessage +) + +type readerModel struct { + c *Client + ctx context.Context + mailbox string + + mode readerMode + rows []MessageSummary + cursor int + current Message + status string + errText string + width int + height int +} + +type rowsMsg struct{ rows []MessageSummary } +type openedMsg struct{ msg Message } +type actionDoneMsg struct{ status string } +type errMsg struct{ err error } + +func (m readerModel) Init() tea.Cmd { return m.loadInbox() } + +func (m readerModel) loadInbox() tea.Cmd { + return func() tea.Msg { + rows, err := m.c.List(m.ctx, m.mailbox, 0) + if err != nil { + return errMsg{err} + } + return rowsMsg{rows} + } +} + +func (m readerModel) open(uid uint32) tea.Cmd { + return func() tea.Msg { + msg, ok, err := m.c.Read(m.ctx, m.mailbox, uid, false) + if err != nil { + return errMsg{err} + } + if !ok { + return errMsg{notFound(m.mailbox, uid)} + } + return openedMsg{msg} + } +} + +func (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case rowsMsg: + m.rows = msg.rows + if m.cursor >= len(m.rows) { + m.cursor = max(0, len(m.rows)-1) + } + m.status = fmt.Sprintf("%s — %d message(s)", m.mailbox, len(m.rows)) + case openedMsg: + m.current = msg.msg + m.mode = modeMessage + case actionDoneMsg: + m.status = msg.status + return m, m.loadInbox() + case errMsg: + m.errText = msg.err.Error() + case tea.KeyMsg: + return m.onKey(msg) + } + return m, nil +} + +func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + m.errText = "" + if m.mode == modeMessage { + switch k.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "b", "esc", "left", "h": + m.mode = modeList + return m, m.loadInbox() + case "f": + uid := m.current.UID + flagged := !m.current.Flagged + return m, func() tea.Msg { + if err := m.c.Flag(m.ctx, m.mailbox, uid, flagged); err != nil { + return errMsg{err} + } + return actionDoneMsg{status: flagState(flagged)} + } + case "x", "d": + uid := m.current.UID + m.mode = modeList + return m, func() tea.Msg { + if err := m.c.Delete(m.ctx, m.mailbox, uid); err != nil { + return errMsg{err} + } + return actionDoneMsg{status: "deleted"} + } + } + return m, nil + } + + switch k.String() { + case "q", "ctrl+c", "esc": + return m, tea.Quit + case "j", "down": + if m.cursor < len(m.rows)-1 { + m.cursor++ + } + case "k", "up": + if m.cursor > 0 { + m.cursor-- + } + case "r": + m.status = "refreshing…" + return m, m.loadInbox() + case "enter", "l", "right": + if len(m.rows) > 0 { + return m, m.open(m.rows[m.cursor].UID) + } + } + return m, nil +} + +func (m readerModel) View() string { + var b strings.Builder + b.WriteString(mhTitle.Render("AgentMail") + mhDim.Render(" · "+m.c.Address()) + "\n\n") + if m.mode == modeMessage { + b.WriteString(m.viewMessage()) + } else { + b.WriteString(m.viewList()) + } + if m.errText != "" { + b.WriteString("\n" + mhErr.Render(m.errText)) + } + return mhFrame.Render(b.String()) +} + +func (m readerModel) viewList() string { + var b strings.Builder + if len(m.rows) == 0 { + b.WriteString(mhDim.Render("(no messages)") + "\n") + } + for i, r := range m.rows { + cur := " " + if i == m.cursor { + cur = mhCursor.Render("❯ ") + } + marker := " " + if !r.Seen { + marker = "●" + } + flag := " " + if r.Flagged { + flag = mhFlag.Render("⚑") + } + from := r.From.Name + if from == "" { + from = r.From.Address + } + line := fmt.Sprintf("%s%s%s %-22.22s %s", cur, marker, flag, from, r.Subject) + if !r.Seen { + line = mhUnseen.Render(line) + } + b.WriteString(line + "\n") + b.WriteString(" " + mhDim.Render(r.Date.Format("2006-01-02 15:04")+" · "+r.Snippet) + "\n") + } + b.WriteString("\n" + mhDim.Render(m.status)) + b.WriteString("\n" + mhDim.Render("↑/↓ move · enter open · r refresh · q quit")) + return b.String() +} + +func (m readerModel) viewMessage() string { + msg := m.current + var b strings.Builder + b.WriteString(mhDim.Render("From: ") + FormatAddress(msg.From) + "\n") + b.WriteString(mhDim.Render("To: ") + joinAddrs(msg.To) + "\n") + if len(msg.CC) > 0 { + b.WriteString(mhDim.Render("Cc: ") + joinAddrs(msg.CC) + "\n") + } + b.WriteString(mhDim.Render("Date: ") + msg.Date.Format("2006-01-02 15:04") + "\n") + b.WriteString(mhDim.Render("Subject: ") + mhUnseen.Render(msg.Subject) + "\n") + if len(msg.Attachments) > 0 { + names := make([]string, len(msg.Attachments)) + for i, a := range msg.Attachments { + names[i] = a.Filename + } + b.WriteString(mhDim.Render("Attach: ") + strings.Join(names, ", ") + "\n") + } + b.WriteString("\n" + msg.Text + "\n") + b.WriteString("\n" + mhDim.Render("b back · f flag · x delete · q quit")) + return b.String() +} + +func joinAddrs(list []Address) string { + parts := make([]string, len(list)) + for i, a := range list { + parts[i] = FormatAddress(a) + } + return strings.Join(parts, ", ") +} + +func flagState(on bool) string { + if on { + return "flagged" + } + return "unflagged" +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/mailbox/smtp.go b/internal/mailbox/smtp.go new file mode 100644 index 0000000..19b5619 --- /dev/null +++ b/internal/mailbox/smtp.go @@ -0,0 +1,81 @@ +package mailbox + +import ( + "bytes" + "fmt" + "mime" + "net" + "net/smtp" + "strings" + "time" +) + +// buildRFC822 renders a draft as an RFC 5322 message (CRLF line endings, UTF-8 +// text body). It returns the bytes and the generated Message-ID. +func buildRFC822(from string, d Draft) ([]byte, string) { + domain := "localhost" + if at := strings.LastIndexByte(from, '@'); at >= 0 { + domain = from[at+1:] + } + msgID := fmt.Sprintf("<%s@%s>", randToken(), domain) + + var b bytes.Buffer + wh := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) } + wh("From", from) + wh("To", headerAddrs(d.To)) + if len(d.CC) > 0 { + wh("Cc", headerAddrs(d.CC)) + } + wh("Subject", mime.QEncoding.Encode("utf-8", d.Subject)) + wh("Date", time.Now().Format(time.RFC1123Z)) + wh("Message-Id", msgID) + if d.InReplyTo != "" { + wh("In-Reply-To", d.InReplyTo) + wh("References", d.InReplyTo) + } + wh("MIME-Version", "1.0") + wh("Content-Type", "text/plain; charset=utf-8") + wh("Content-Transfer-Encoding", "8bit") + b.WriteString("\r\n") + b.WriteString(strings.ReplaceAll(d.Text, "\n", "\r\n")) + return b.Bytes(), msgID +} + +// headerAddrs renders an address list for a header, encoding display names. +func headerAddrs(list []Address) string { + parts := make([]string, len(list)) + for i, a := range list { + if a.Name == "" { + parts[i] = a.Address + } else { + parts[i] = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", a.Name), a.Address) + } + } + return strings.Join(parts, ", ") +} + +// recipients flattens To+Cc+Bcc into envelope recipient addresses. +func recipients(d Draft) []string { + var out []string + for _, l := range [][]Address{d.To, d.CC, d.BCC} { + for _, a := range l { + out = append(out, a.Address) + } + } + return out +} + +// smtpSend submits a built message via SMTP. With a non-empty user it does +// STARTTLS + AUTH (e.g. smtp.profullstack.com:587); with an empty user it sends +// unauthenticated, for a trusted local relay (e.g. the co-located Postfix). +func smtpSend(addr, user, pass, from string, rcpts []string, msg []byte) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("smtp addr %q: %w", addr, err) + } + var auth smtp.Auth + if user != "" { + auth = smtp.PlainAuth("", user, pass, host) + } + return smtp.SendMail(addr, auth, from, rcpts, msg) +} diff --git a/internal/mailbox/transport.go b/internal/mailbox/transport.go new file mode 100644 index 0000000..b891117 --- /dev/null +++ b/internal/mailbox/transport.go @@ -0,0 +1,100 @@ +package mailbox + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" +) + +// Transport is the low-level mailbox backend. The TUI, the bot mode, and the +// Client all talk to this interface, so an in-memory fake (tests/dev) and the +// real IMAP/SMTP adapter are interchangeable. +type Transport interface { + ListMailboxes(ctx context.Context) ([]Mailbox, error) + ListMessages(ctx context.Context, opts ListOptions) ([]MessageSummary, error) + // ReadMessage returns (Message, false, nil) when the uid is unknown. + ReadMessage(ctx context.Context, mailbox string, uid uint32) (Message, bool, error) + Search(ctx context.Context, opts SearchOptions) ([]MessageSummary, error) + // Send delivers an already-validated draft, stamped from `from`. + Send(ctx context.Context, from string, d Draft) (SendResult, error) + SetFlags(ctx context.Context, mailbox string, uid uint32, fc FlagChange) error + DeleteMessage(ctx context.Context, mailbox string, uid uint32) error + Close() error +} + +// ErrNotFound is returned when a uid/mailbox cannot be resolved. +var ErrNotFound = errors.New("mailbox: message not found") + +var addrRe = regexp.MustCompile(`^(.*)<([^>]+)>\s*$`) + +// ParseAddress parses "Name " or a bare "a@b". +func ParseAddress(raw string) Address { + s := strings.TrimSpace(raw) + if m := addrRe.FindStringSubmatch(s); m != nil { + name := strings.Trim(strings.TrimSpace(m[1]), `"`) + return Address{Name: strings.TrimSpace(name), Address: strings.TrimSpace(m[2])} + } + return Address{Address: strings.Trim(s, "<>")} +} + +// FormatAddress renders an Address back to "Name " (or just the address). +func FormatAddress(a Address) string { + if a.Name == "" { + return a.Address + } + if strings.ContainsAny(a.Name, `",<>@`) { + return fmt.Sprintf("%q <%s>", a.Name, a.Address) + } + return fmt.Sprintf("%s <%s>", a.Name, a.Address) +} + +// Snippet collapses whitespace and truncates to at most max runes. +func Snippet(body string, max int) string { + flat := strings.Join(strings.Fields(body), " ") + if max <= 0 || len([]rune(flat)) <= max { + return flat + } + r := []rune(flat) + return string(r[:max-1]) + "…" +} + +// ValidEmail is a loose RFC5322-ish check: one @, a dotted domain, no spaces. +func ValidEmail(v string) bool { + v = strings.TrimSpace(v) + if len(v) < 3 || len(v) > 254 || strings.ContainsAny(v, " \t\r\n") { + return false + } + at := strings.LastIndexByte(v, '@') + if at <= 0 || at == len(v)-1 { + return false + } + return strings.Contains(v[at+1:], ".") +} + +// NormalizeDraft trims the subject, drops empty recipient lists, and rejects a +// draft with no valid recipient. +func NormalizeDraft(d Draft) (Draft, error) { + clean := func(in []Address) []Address { + out := make([]Address, 0, len(in)) + for _, a := range in { + a.Address = strings.TrimSpace(a.Address) + if a.Address != "" { + out = append(out, a) + } + } + return out + } + to, cc, bcc := clean(d.To), clean(d.CC), clean(d.BCC) + all := append(append(append([]Address{}, to...), cc...), bcc...) + if len(all) == 0 { + return Draft{}, errors.New("a draft needs at least one recipient") + } + for _, a := range all { + if !ValidEmail(a.Address) { + return Draft{}, fmt.Errorf("invalid recipient address: %s", a.Address) + } + } + return Draft{To: to, CC: cc, BCC: bcc, Subject: strings.TrimSpace(d.Subject), Text: d.Text, InReplyTo: d.InReplyTo}, nil +} diff --git a/internal/mailbox/types.go b/internal/mailbox/types.go new file mode 100644 index 0000000..a952772 --- /dev/null +++ b/internal/mailbox/types.go @@ -0,0 +1,99 @@ +// Package mailbox is the BBS-side mail client for Founding Lifetime members: a +// transport-agnostic core (read, search, compose, send, flag, delete) with a +// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It +// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at +// mail.profullstack.com / smtp.profullstack.com. +// +// The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are +// deliberately the same so tooling can move between them. +package mailbox + +import "time" + +// Address is a parsed mail address. +type Address struct { + Name string `json:"name,omitempty"` + Address string `json:"address"` +} + +// Mailbox is an IMAP folder with unread/total counts. +type Mailbox struct { + Name string `json:"name"` + Path string `json:"path"` + Unseen int `json:"unseen"` + Total int `json:"total"` +} + +// MessageSummary is a lightweight row for list/search views. +type MessageSummary struct { + UID uint32 `json:"uid"` + Mailbox string `json:"mailbox"` + From Address `json:"from"` + To []Address `json:"to"` + Subject string `json:"subject"` + Date time.Time `json:"date"` + Seen bool `json:"seen"` + Flagged bool `json:"flagged"` + HasAttachments bool `json:"hasAttachments"` + Snippet string `json:"snippet"` +} + +// Attachment is attachment metadata (bytes fetched separately). +type Attachment struct { + Filename string `json:"filename"` + ContentType string `json:"contentType"` + Size int `json:"size"` +} + +// Message is a fully fetched message. +type Message struct { + MessageSummary + CC []Address `json:"cc,omitempty"` + ReplyTo *Address `json:"replyTo,omitempty"` + MessageID string `json:"messageId"` + References []string `json:"references,omitempty"` + Text string `json:"text"` + HTML string `json:"html,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +// Draft is an outgoing message. +type Draft struct { + To []Address `json:"to"` + CC []Address `json:"cc,omitempty"` + BCC []Address `json:"bcc,omitempty"` + Subject string `json:"subject"` + Text string `json:"text"` + InReplyTo string `json:"inReplyTo,omitempty"` +} + +// ListOptions controls a mailbox listing. +type ListOptions struct { + Mailbox string + Limit int +} + +// SearchOptions controls a search. +type SearchOptions struct { + Mailbox string // empty = all mailboxes + Query string + Limit int +} + +// FlagChange is a partial flag update; nil fields are left unchanged. +type FlagChange struct { + Seen *bool + Flagged *bool +} + +// SendResult reports a sent message's id. +type SendResult struct { + MessageID string `json:"messageId"` +} + +const ( + // Inbox is the default mailbox. + Inbox = "INBOX" + // Sent is where sent mail is stored. + Sent = "Sent" +) diff --git a/internal/news/nntpd/range_test.go b/internal/news/nntpd/range_test.go new file mode 100644 index 0000000..7cd35bf --- /dev/null +++ b/internal/news/nntpd/range_test.go @@ -0,0 +1,10 @@ +package nntpd + +import "testing" + +func TestParseRangeSingleArticle(t *testing.T) { + low, high := parseRange("5") + if low != 5 || high != 5 { + t.Fatalf(`parseRange("5") = %d, %d; want 5, 5`, low, high) + } +} diff --git a/internal/news/nntpd/server.go b/internal/news/nntpd/server.go index 2c58d6a..4665862 100644 --- a/internal/news/nntpd/server.go +++ b/internal/news/nntpd/server.go @@ -142,12 +142,16 @@ func (s *Server) Process(nc net.Conn) { if err != nil { return } - cmd := strings.Split(l, " ") - args := []string{} - if len(cmd) > 1 { - args = cmd[1:] + fields := strings.Fields(l) + if len(fields) == 0 { + err = handleDefault(nil, sess, c) + } else { + args := []string{} + if len(fields) > 1 { + args = fields[1:] + } + err = sess.dispatchCommand(fields[0], args, c) } - err = sess.dispatchCommand(cmd[0], args, c) if err != nil { if _, isNNTPError := err.(*NNTPError); err == io.EOF { return @@ -170,8 +174,9 @@ func parseRange(spec string) (low, high int64) { h, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { h = math.MaxInt64 + return 0, h } - return 0, h + return h, h } l, _ := strconv.ParseInt(parts[0], 10, 64) h, err := strconv.ParseInt(parts[1], 10, 64) @@ -357,6 +362,9 @@ func handleIHave(args []string, s *session, c *textproto.Conn) error { if !s.backend.AllowPost() { return ErrNotWanted } + if len(args) < 1 { + return ErrSyntax + } article, err := s.backend.GetArticle(nil, args[0]) if article != nil { return ErrNotWanted diff --git a/internal/news/nntpd/server_test.go b/internal/news/nntpd/server_test.go new file mode 100644 index 0000000..d54ca07 --- /dev/null +++ b/internal/news/nntpd/server_test.go @@ -0,0 +1,129 @@ +package nntpd + +import ( + "errors" + "net" + "net/textproto" + "strings" + "testing" + "time" + + "github.com/dustin/go-nntp" +) + +type whitespaceBackend struct { + group *nntp.Group + allowPost bool +} + +func (b *whitespaceBackend) ListGroups(max int) ([]*nntp.Group, error) { + return []*nntp.Group{b.group}, nil +} + +func (b *whitespaceBackend) GetGroup(name string) (*nntp.Group, error) { + if name == b.group.Name { + return b.group, nil + } + return nil, ErrNoSuchGroup +} + +func (b *whitespaceBackend) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) { + return nil, ErrInvalidArticleNumber +} + +func (b *whitespaceBackend) GetArticles(group *nntp.Group, from, to int64) ([]NumberedArticle, error) { + return nil, nil +} + +func (b *whitespaceBackend) Authorized() bool { return true } + +func (b *whitespaceBackend) Authenticate(user, pass string) (Backend, error) { + return b, nil +} + +func (b *whitespaceBackend) AllowPost() bool { return b.allowPost } + +func (b *whitespaceBackend) Post(article *nntp.Article) error { + return errors.New("posting disabled") +} + +func TestProcessCollapsesRepeatedCommandWhitespace(t *testing.T) { + backend := &whitespaceBackend{ + group: &nntp.Group{Name: "pfs.general", Count: 1, Low: 1, High: 1, Posting: nntp.PostingPermitted}, + } + server := NewServer(backend) + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + + done := make(chan struct{}) + go func() { + server.Process(serverConn) + close(done) + }() + + client := textproto.NewConn(clientConn) + defer client.Close() + + if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "200 ") { + t.Fatalf("greeting = %q, %v", line, err) + } + if err := client.PrintfLine("GROUP pfs.general"); err != nil { + t.Fatalf("send GROUP: %v", err) + } + if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "211 ") { + t.Fatalf("GROUP with repeated spaces = %q, %v; want 211", line, err) + } + if err := client.PrintfLine("QUIT"); err != nil { + t.Fatalf("send QUIT: %v", err) + } + if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "205 ") { + t.Fatalf("QUIT = %q, %v; want 205", line, err) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("server did not close after QUIT") + } +} + +func TestIHaveWithoutMessageIDReturnsSyntaxError(t *testing.T) { + backend := &whitespaceBackend{ + group: &nntp.Group{Name: "pfs.general", Posting: nntp.PostingPermitted}, + allowPost: true, + } + server := NewServer(backend) + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + + done := make(chan struct{}) + go func() { + server.Process(serverConn) + close(done) + }() + + client := textproto.NewConn(clientConn) + defer client.Close() + + if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "200 ") { + t.Fatalf("greeting = %q, %v", line, err) + } + if err := client.PrintfLine("IHAVE"); err != nil { + t.Fatalf("send IHAVE: %v", err) + } + if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "501 ") { + t.Fatalf("IHAVE without message-id = %q, %v; want 501", line, err) + } + if err := client.PrintfLine("QUIT"); err != nil { + t.Fatalf("send QUIT: %v", err) + } + if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "205 ") { + t.Fatalf("QUIT = %q, %v; want 205", line, err) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("server did not close after QUIT") + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 4f32b24..7bbe300 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -22,6 +22,9 @@ type Context struct { DataDir string // AssetsDir is the read-only platform assets tree (wads, binaries). AssetsDir string + // Host is the BBS hostname (e.g. bbs.profullstack.com), for building + // member homepage URLs (https://Host/~name) and similar links. + Host string } // Plugin is the only integration point between a feature and the hub. diff --git a/internal/pods/pods.go b/internal/pods/pods.go index 5c0d827..0b10acb 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -70,6 +70,23 @@ func (m *Manager) publicHTMLMount(user string) (host, spec string) { return host, host + ":/home/dev/public_html" } +// hasMount reports whether the named container already has a mount at the given +// destination path. Used to detect pods created before a mount was introduced so +// ensure can recreate them. A failed inspect reports false (treat as missing). +func (m *Manager) hasMount(name, dest string) bool { + out, err := exec.Command(m.engine, "container", "inspect", + "-f", "{{range .Mounts}}{{println .Destination}}{{end}}", name).Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == dest { + return true + } + } + return false +} + // Engine reports the active container engine. func (m *Manager) Engine() string { return m.engine } @@ -106,8 +123,21 @@ func (m *Manager) ensure(user string) (string, error) { } // Already exists? if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil { - _ = exec.Command(m.engine, "start", name).Run() // no-op if running - return name, nil + // Self-heal pods created before the homepage bind existed: recreate so + // ~/public_html maps to the served dir. The named home volume persists + // across rm, so the member's files are kept. Only heal when the pod is + // idle (no live session) — never pull a running pod out from under an + // active session; a still-unbound pod heals on its next idle attach. + m.mu.Lock() + idle := m.attached[name] == 0 + m.mu.Unlock() + if pubSpec != "" && idle && !m.hasMount(name, "/home/dev/public_html") { + _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind + } else { + _ = exec.Command(m.engine, "start", name).Run() // no-op if running + m.tuneApt(name) + return name, nil + } } args := []string{ "run", "-d", @@ -148,9 +178,28 @@ func (m *Manager) ensure(user string) (string, error) { if err != nil { return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out))) } + m.tuneApt(name) return name, nil } +// tuneApt makes apt usable inside the hardened pod. apt drops privileges to +// the _apt user for downloads (setgroups/setegid/seteuid), which needs +// CAP_SETUID/CAP_SETGID/CAP_CHOWN — caps we intentionally drop (cap-drop ALL). +// Rather than re-grant those to the whole container, disable apt's download +// sandbox so package management runs as the pod's (rootless-mapped) root. +// +// Only applies to the podman/container-root path; under docker the pod runs as +// uid 1000 and can't write /etc/apt (apt isn't usable there by design). Failure +// is non-fatal: a missing config just means the user sees the old apt errors. +func (m *Manager) tuneApt(name string) { + if m.engine == "docker" { + return + } + _ = exec.Command(m.engine, "exec", "--user", "root", name, + "sh", "-c", `printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/00no-sandbox`, + ).Run() +} + // Attach provisions the pod and wires the SSH session to a shell inside it. // Blocks until the shell exits or the session closes. func (m *Manager) Attach(s ssh.Session, user string) error { diff --git a/internal/qryptinvite/qryptinvite_test.go b/internal/qryptinvite/qryptinvite_test.go index 9348549..c7c9618 100644 --- a/internal/qryptinvite/qryptinvite_test.go +++ b/internal/qryptinvite/qryptinvite_test.go @@ -148,8 +148,16 @@ func TestTamperedTokenFails(t *testing.T) { t.Fatal("original token failed to verify") } - // Tampering with the signature segment must also fail. - badSig := parts[0] + "." + parts[1] + "." + flipLastChar(parts[2]) + // Tampering with the signature segment must also fail. Corrupt a DECODED + // signature byte (not a base64 char): flipping the last base64 char can land + // on the unused trailing bits of a 64-byte signature, which decode back to + // the same bytes and still verify — that made this test flaky. + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(sigBytes) == 0 { + t.Fatalf("decode signature segment: %v", err) + } + sigBytes[0] ^= 0xFF + badSig := parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(sigBytes) if verifySig(badSig, pub) { t.Fatal("token with corrupted signature verified but should have failed") } @@ -168,20 +176,6 @@ func verifySig(token string, pub ed25519.PublicKey) bool { return ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) } -func flipLastChar(s string) string { - if s == "" { - return s - } - b := []byte(s) - last := b[len(b)-1] - if last == 'A' { - b[len(b)-1] = 'B' - } else { - b[len(b)-1] = 'A' - } - return string(b) -} - func TestParsePrivateKeyAcceptsSeedAndFull(t *testing.T) { _, priv, err := ed25519.GenerateKey(nil) if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 5cb797b..bf6b482 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" "errors" + "strings" "time" _ "modernc.org/sqlite" @@ -45,6 +46,16 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) { return u, nil } +// Message is one member-to-member note in the store-and-forward inbox. +type Message struct { + ID int64 + From string + To string + Body string + Read bool + At time.Time +} + // Score is one leaderboard entry. type Score struct { User string @@ -100,6 +111,21 @@ type Store interface { AddChat(userID int64, username, role, text string) error RecentChats(username string, n int) ([]ChatMessage, error) + // Member-to-member messaging (store-and-forward inbox). + + // SendMessage leaves a note from→to in the recipient's inbox. + SendMessage(from, to, body string) error + // Inbox returns up to n messages addressed to username, newest first. + Inbox(username string, n int) ([]Message, error) + // UnreadCount reports how many unread messages username has waiting. + UnreadCount(username string) (int, error) + // MarkRead marks the given message ids read (scoped to username so a member + // can only clear their own mail). Empty ids is a no-op. + MarkRead(username string, ids []int64) error + // OnlineUsers reports the set of usernames with an open session (no + // ended_at), for the members directory presence dots. + OnlineUsers() (map[string]bool, error) + // Custom domains mapped to a member's homepage (public_html). // MapDomain binds domain→username, returning ErrDomainTaken if it is // already claimed by someone else (re-binding to the same owner is a no-op). @@ -471,6 +497,15 @@ CREATE TABLE IF NOT EXISTS news_articles ( ); CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num); CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id); +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + from_user TEXT NOT NULL, + to_user TEXT NOT NULL, + body TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC); ` func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { @@ -686,6 +721,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) return out, rows.Err() } +func (s *sqliteStore) SendMessage(from, to, body string) error { + _, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`, + from, to, body) + return err +} + +func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) { + if n <= 0 { + n = 50 + } + rows, err := s.db.Query(` + SELECT id, from_user, to_user, body, read, created_at + FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Message + for rows.Next() { + var m Message + var read int + var at string + if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil { + return nil, err + } + m.Read = read != 0 + m.At, _ = time.Parse(time.RFC3339, at) + out = append(out, m) + } + return out, rows.Err() +} + +func (s *sqliteStore) UnreadCount(username string) (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n) + return n, err +} + +func (s *sqliteStore) MarkRead(username string, ids []int64) error { + if len(ids) == 0 { + return nil + } + q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)` + args := make([]any, 0, len(ids)+1) + args = append(args, username) + for _, id := range ids { + args = append(args, id) + } + _, err := s.db.Exec(q, args...) + return err +} + +func (s *sqliteStore) OnlineUsers() (map[string]bool, error) { + rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + online := map[string]bool{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + online[strings.ToLower(name)] = true + } + return online, rows.Err() +} + func (s *sqliteStore) MapDomain(domain, username string) error { var owner string err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner) diff --git a/internal/store/store_messages_test.go b/internal/store/store_messages_test.go new file mode 100644 index 0000000..b5acac6 --- /dev/null +++ b/internal/store/store_messages_test.go @@ -0,0 +1,82 @@ +package store + +import "testing" + +func TestMessagingRoundtrip(t *testing.T) { + st := openTest(t) + + _, _ = st.EnsureUser("alice", "member", "SHA256:aaa") + _, _ = st.EnsureUser("bob", "member", "SHA256:bbb") + + if n, err := st.UnreadCount("bob"); err != nil || n != 0 { + t.Fatalf("fresh unread: n=%d err=%v", n, err) + } + + if err := st.SendMessage("alice", "bob", "hey, c4 tonight?"); err != nil { + t.Fatalf("send: %v", err) + } + if err := st.SendMessage("alice", "bob", "second note"); err != nil { + t.Fatalf("send2: %v", err) + } + + n, err := st.UnreadCount("bob") + if err != nil || n != 2 { + t.Fatalf("unread after send: n=%d err=%v", n, err) + } + + inbox, err := st.Inbox("bob", 10) + if err != nil { + t.Fatalf("inbox: %v", err) + } + if len(inbox) != 2 { + t.Fatalf("want 2 messages, got %d", len(inbox)) + } + // Newest first. + if inbox[0].Body != "second note" || inbox[0].From != "alice" || inbox[0].To != "bob" { + t.Fatalf("unexpected newest message: %+v", inbox[0]) + } + + // Mark only the first read; the other stays unread. + if err := st.MarkRead("bob", []int64{inbox[0].ID}); err != nil { + t.Fatalf("markread: %v", err) + } + if n, _ := st.UnreadCount("bob"); n != 1 { + t.Fatalf("want 1 unread after partial read, got %d", n) + } + + // MarkRead is scoped to the recipient: alice can't clear bob's mail. + if err := st.MarkRead("alice", []int64{inbox[1].ID}); err != nil { + t.Fatalf("markread other: %v", err) + } + if n, _ := st.UnreadCount("bob"); n != 1 { + t.Fatalf("cross-user markread leaked: unread=%d", n) + } + + // Empty ids is a no-op. + if err := st.MarkRead("bob", nil); err != nil { + t.Fatalf("markread empty: %v", err) + } +} + +func TestOnlineUsers(t *testing.T) { + st := openTest(t) + + u, _ := st.EnsureUser("carol", "member", "SHA256:ccc") + id, _ := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub") + + online, err := st.OnlineUsers() + if err != nil { + t.Fatalf("online: %v", err) + } + if !online["carol"] { + t.Fatal("carol should be online while her session is open") + } + + if err := st.EndSession(id); err != nil { + t.Fatalf("end: %v", err) + } + online, _ = st.OnlineUsers() + if online["carol"] { + t.Fatal("carol should be offline after her session ends") + } +} diff --git a/plugins/members/members.go b/plugins/members/members.go new file mode 100644 index 0000000..13d77e3 --- /dev/null +++ b/plugins/members/members.go @@ -0,0 +1,421 @@ +// Package members is the member directory + messaging plugin (the BBS "who" and +// store-and-forward inbox). Members browse who else has an account, see who is +// online now, finger a profile, leave a message, and read their own inbox. +// +// It is members-only (RequiresAuth) — guests have no identity to send from or +// receive to. Messaging is store-and-forward via the store's messages table; +// the same inbox is fed by the `ssh msg@host ` CLI route. +package members + +import ( + "fmt" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/store" +) + +type Plugin struct{} + +func (Plugin) ID() string { return "members" } +func (Plugin) Title() string { return "Members" } +func (Plugin) Description() string { return "Who's here · finger a profile · leave a message · inbox" } +func (Plugin) RequiresAuth() bool { return true } + +func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { + return &model{user: user, ctx: ctx, state: stList} +} + +// state is which sub-screen is showing. +type state int + +const ( + stList state = iota + stProfile + stCompose + stInbox +) + +// person is one directory row. +type person struct { + name string + kind string + online bool + lastSeen time.Time + seenOK bool +} + +type model struct { + user auth.User + ctx plugin.Context + state state + + people []person + inbox []store.Message + cursor int // list/inbox cursor + target string // who we're fingering/composing to + + draft string // compose buffer + note string // transient status line + err error + + width, height int +} + +// --- loading --------------------------------------------------------------- + +type loadedMsg struct { + people []person + err error +} + +func (m *model) load() tea.Cmd { + st := m.ctx.Store + me := m.user.Name + return func() tea.Msg { + users, err := st.ListUsers(500) + if err != nil { + return loadedMsg{err: err} + } + online, _ := st.OnlineUsers() + out := make([]person, 0, len(users)) + for _, u := range users { + if u.Name == me { + continue // don't list yourself in the directory + } + p := person{name: u.Name, kind: u.Kind, online: online[strings.ToLower(u.Name)]} + if t, ok, _ := st.LastSeen(u.ID); ok { + p.lastSeen, p.seenOK = t, true + } + out = append(out, p) + } + // Online first, then most-recently-seen, then name. + sort.SliceStable(out, func(i, j int) bool { + if out[i].online != out[j].online { + return out[i].online + } + if out[i].seenOK != out[j].seenOK { + return out[i].seenOK + } + if out[i].seenOK && !out[i].lastSeen.Equal(out[j].lastSeen) { + return out[i].lastSeen.After(out[j].lastSeen) + } + return out[i].name < out[j].name + }) + return loadedMsg{people: out} + } +} + +type inboxMsg struct { + msgs []store.Message + err error +} + +func (m *model) loadInbox() tea.Cmd { + st := m.ctx.Store + me := m.user.Name + return func() tea.Msg { + msgs, err := st.Inbox(me, 100) + if err != nil { + return inboxMsg{err: err} + } + // Opening the inbox marks everything read. + var unread []int64 + for _, mm := range msgs { + if !mm.Read { + unread = append(unread, mm.ID) + } + } + _ = st.MarkRead(me, unread) + return inboxMsg{msgs: msgs} + } +} + +type sentMsg struct{ err error } + +func (m *model) send(to, body string) tea.Cmd { + st := m.ctx.Store + from := m.user.Name + return func() tea.Msg { return sentMsg{err: st.SendMessage(from, to, body)} } +} + +func (m *model) Init() tea.Cmd { return m.load() } + +// --- update ---------------------------------------------------------------- + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case loadedMsg: + m.people, m.err = msg.people, msg.err + if m.cursor >= len(m.people) { + m.cursor = 0 + } + return m, nil + case inboxMsg: + m.inbox, m.err = msg.msgs, msg.err + return m, nil + case sentMsg: + if msg.err != nil { + m.note = "send failed: " + msg.err.Error() + } else { + m.note = "✓ message sent to " + m.target + m.state = stProfile + } + m.draft = "" + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.state == stCompose { + return m.composeKey(k) + } + m.note = "" + switch m.state { + case stList: + switch k.String() { + case "q", "esc": + return m, plugin.Exit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.people)-1 { + m.cursor++ + } + case "i": + m.state = stInbox + m.cursor = 0 + return m, m.loadInbox() + case "r": + return m, m.load() + case "enter": + if p := m.selected(); p != nil { + m.target = p.name + m.state = stProfile + } + case "m": + if p := m.selected(); p != nil { + m.target = p.name + m.draft = "" + m.state = stCompose + } + } + case stProfile: + switch k.String() { + case "q", "esc", "backspace": + m.state = stList + case "m": + m.draft = "" + m.state = stCompose + } + case stInbox: + switch k.String() { + case "q", "esc", "backspace": + m.state = stList + return m, m.load() // refresh unread badge state + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.inbox)-1 { + m.cursor++ + } + } + } + return m, nil +} + +// composeKey runs the minimal one-line message editor. +func (m *model) composeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.Type { + case tea.KeyEsc: + m.state = stProfile + m.draft = "" + return m, nil + case tea.KeyEnter: + body := strings.TrimSpace(m.draft) + if body == "" { + m.note = "type a message first (esc to cancel)" + return m, nil + } + return m, m.send(m.target, body) + case tea.KeyBackspace, tea.KeyDelete: + if n := len(m.draft); n > 0 { + r := []rune(m.draft) + m.draft = string(r[:len(r)-1]) + } + return m, nil + case tea.KeySpace: + m.draft += " " + return m, nil + case tea.KeyRunes: + m.draft += string(k.Runes) + return m, nil + } + return m, nil +} + +func (m *model) selected() *person { + if m.cursor < 0 || m.cursor >= len(m.people) { + return nil + } + return &m.people[m.cursor] +} + +// --- view ------------------------------------------------------------------ + +var ( + hdr = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + sel = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0")) + on = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + off = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + cur = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + frame = lipgloss.NewStyle().Padding(1, 2) +) + +func (m *model) View() string { + var s string + switch m.state { + case stProfile: + s = m.profileView() + case stCompose: + s = m.composeView() + case stInbox: + s = m.inboxView() + default: + s = m.listView() + } + if m.note != "" { + s += "\n" + warn.Render(m.note) + } + return frame.Render(s) +} + +func (m *model) listView() string { + s := hdr.Render("Members") + dim.Render(" · who's here") + "\n\n" + if m.err != nil { + return s + warn.Render("error: "+m.err.Error()) + } + if len(m.people) == 0 { + return s + dim.Render("no members yet") + } + for i, p := range m.people { + dot := off.Render("○") + if p.online { + dot = on.Render("●") + } + name := p.name + c := " " + if i == m.cursor { + c = cur.Render("❯ ") + name = sel.Render(name) + } + seen := "online" + if !p.online { + seen = "last " + relTime(p.lastSeen, p.seenOK) + } + row := fmt.Sprintf("%s%s %-20s %-8s %s", c, dot, name, p.kind, dim.Render(seen)) + s += row + "\n" + } + s += "\n" + dim.Render("↑/↓ move · enter finger · m message · i inbox · r refresh · q back") + return s +} + +func (m *model) profileView() string { + p := m.find(m.target) + s := hdr.Render("finger "+m.target) + "\n\n" + if p == nil { + return s + dim.Render("unknown member") + } + status := off.Render("offline") + dim.Render(" · last "+relTime(p.lastSeen, p.seenOK)) + if p.online { + status = on.Render("online now") + } + home := "~" + p.name + if m.ctx.Host != "" { + home = "https://" + m.ctx.Host + "/~" + p.name + } + lines := []string{ + " Login: " + sel.Render(p.name) + " Kind: " + p.kind, + " Status: " + status, + " Home: " + dim.Render(home), + } + s += strings.Join(lines, "\n") + s += "\n\n" + dim.Render("m message "+p.name+" · esc back") + return s +} + +func (m *model) composeView() string { + s := hdr.Render("message "+m.target) + "\n\n" + s += dim.Render("from "+m.user.Name+" → "+m.target) + "\n\n" + s += " " + m.draft + cur.Render("▏") + "\n\n" + s += dim.Render("enter send · esc cancel") + return s +} + +func (m *model) inboxView() string { + s := hdr.Render("Inbox") + dim.Render(" · "+m.user.Name) + "\n\n" + if m.err != nil { + return s + warn.Render("error: "+m.err.Error()) + } + if len(m.inbox) == 0 { + return s + dim.Render("no messages — select a member and press m to send one") + + "\n\n" + dim.Render("esc back") + } + for i, msg := range m.inbox { + c := " " + from := msg.From + if i == m.cursor { + c = cur.Render("❯ ") + from = sel.Render(from) + } + s += fmt.Sprintf("%s%-16s %s\n", c, from, dim.Render(relTime(msg.At, true)+" ago")) + s += " " + msg.Body + "\n" + } + s += "\n" + dim.Render("↑/↓ scroll · esc back") + return s +} + +func (m *model) find(name string) *person { + for i := range m.people { + if m.people[i].name == name { + return &m.people[i] + } + } + return nil +} + +// relTime renders a coarse "2h", "3d", "just now" style age. ok=false → "never". +func relTime(t time.Time, ok bool) string { + if !ok || t.IsZero() { + return "never" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} diff --git a/setup.sh b/setup.sh index 07cd651..9fbd606 100755 --- a/setup.sh +++ b/setup.sh @@ -48,6 +48,8 @@ ERGO_DATA="${ERGO_DATA:-/var/lib/ergo}" # Ergo state dir (ircd.db, tls/) FORGEJO="${FORGEJO:-1}" # set 0 to skip the AgentGit Forgejo backend (git.${DOMAIN#*.}) GIT_DOMAIN="${GIT_DOMAIN:-git.${DOMAIN#*.}}" # AgentGit host (default: git., e.g. git.profullstack.com) FORGEJO_VERSION="${FORGEJO_VERSION:-11.0.1}" # Forgejo release to install +MAIL="${MAIL:-1}" # set 0 to skip the co-located Mailu mail stack (mail.${DOMAIN#*.}) +MAIL_DOMAIN="${MAIL_DOMAIN:-mail.${DOMAIN#*.}}" # mail host (default: mail., e.g. mail.profullstack.com) FORGEJO_HTTP_ADDR="${FORGEJO_HTTP_ADDR:-127.0.0.1:3000}" # Forgejo loopback HTTP (Caddy fronts it) FORGEJO_DATA="${FORGEJO_DATA:-/var/lib/forgejo}" # Forgejo state dir (repos, db) FORGEJO_ADMIN_USER="${FORGEJO_ADMIN_USER:-agentgit-admin}" # Forgejo admin used to provision members @@ -118,8 +120,11 @@ if ! command -v yt-dlp >/dev/null; then fi # ---- 2. Go toolchain (system go is too old; pin GO_VERSION) ----------------- +# Skipped entirely when SKIP_BUILD=1: the CI deploy builds the binaries on the +# runner and ships them, so the droplet needs no Go toolchain at all. GO_ROOT="/usr/local/go" -if [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then +if [ "$SKIP_BUILD" != "1" ] && \ + [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then log "installing Go ${GO_VERSION}" tmp="$(mktemp -d)" curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o "$tmp/go.tgz" \ @@ -505,6 +510,20 @@ ${IRC_DOMAIN} { " fi +# Mail site (${MAIL_DOMAIN}): Caddy serving this host obtains the LE cert that +# Mailu reuses for SMTP/IMAP TLS (deploy/mailu/refresh-certs.sh copies it). It +# also fronts the loopback Roundcube webmail. Needs A record ${MAIL_DOMAIN} -> +# this host. Webmail is the only member-facing mail surface. Omitted when MAIL=0. +MAIL_SITE="" +if [ "$MAIL" = "1" ]; then + MAIL_SITE=" +${MAIL_DOMAIN} { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 +} +" +fi + cat > /etc/caddy/Caddyfile <.${DOMAIN} (needs wildcard DNS # *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's # ask endpoint confirms is a registered member, so random subdomains @@ -901,6 +920,61 @@ else systemctl disable --now forgejo >/dev/null 2>&1 || true fi +# ---- 9e. Mailu mail stack (co-located mail.${DOMAIN#*.}) -------------------- +# Self-hosted Postfix+Dovecot+Roundcube+rspamd via Docker Compose. Mailu owns +# the mail ports; Caddy fronts the loopback webmail and supplies the TLS cert +# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of paid members. Full setup, +# DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0. +MAILU_DIR="${SRC_DIR}/deploy/mailu" +if [ "$MAIL" = "1" ]; then + log "configuring Mailu mail stack (${MAIL_DOMAIN})" + # Tell agentbbs how to reach the mailbox backend (master user/pass are secrets + # the operator sets; see docs/mail.md). + upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}" + upsert_env AGENTBBS_MAIL_IMAP_ADDR "${MAIL_DOMAIN}:993" + upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25" + + # Cert refresher: copy Caddy's mail cert into Mailu on renewal (like news/IRC). + install -m 0755 "${MAILU_DIR}/refresh-certs.sh" /usr/local/bin/agentbbs-mailu-certs + cat > /etc/systemd/system/agentbbs-mailu-certs.service < /etc/systemd/system/agentbbs-mailu-certs.timer </dev/null 2>&1 || true + + # Open the mail ports; bring Mailu up only once the operator has created + # mailu.env (it carries SECRET_KEY + admin password — never auto-generated). + for p in 25 465 587 993 995; do ufw allow "${p}/tcp" >/dev/null; done + if command -v docker >/dev/null && [ -f "${MAILU_DIR}/mailu.env" ]; then + ( cd "$MAILU_DIR" && docker compose up -d ) || warn "mailu: docker compose up failed — check ${MAILU_DIR}" + else + warn "Mailu not started yet: create ${MAILU_DIR}/mailu.env (cp mailu.env.example) and run 'docker compose up -d' — see docs/mail.md" + fi +else + upsert_env AGENTBBS_MAIL_DOMAIN "" + systemctl disable --now agentbbs-mailu-certs.timer >/dev/null 2>&1 || true +fi + # ---- 10. firewall + start agentbbs on :22 ---------------------------------- log "configuring firewall + starting agentbbs" ufw allow 22/tcp >/dev/null