mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 14:57:27 +00:00
Merge remote-tracking branch 'origin/main' into fix/pod-public-html-bind-mount
# Conflicts: # cmd/agentbbs/main.go # internal/hub/hub.go # internal/pods/pods.go
This commit is contained in:
commit
662250af35
33 changed files with 3127 additions and 42 deletions
74
.github/workflows/deploy.yml
vendored
74
.github/workflows/deploy.yml
vendored
|
|
@ -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:-}" \
|
||||
|
|
|
|||
|
|
@ -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 <name>@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
|
||||
// "<name>*<master>") 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 <cmd>, 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 <user> [text]`
|
||||
// leaves a note in <user>'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+" <user> [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.
|
||||
|
|
|
|||
3
deploy/mailu/.gitignore
vendored
Normal file
3
deploy/mailu/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mailu.env
|
||||
certs/
|
||||
data/
|
||||
53
deploy/mailu/README.md
Normal file
53
deploy/mailu/README.md
Normal file
|
|
@ -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 `<name>*<master>`). 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=<that secret>
|
||||
```
|
||||
|
||||
> 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
|
||||
```
|
||||
83
deploy/mailu/docker-compose.yml
Normal file
83
deploy/mailu/docker-compose.yml
Normal file
|
|
@ -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
|
||||
44
deploy/mailu/mailu.env.example
Normal file
44
deploy/mailu/mailu.env.example
Normal file
|
|
@ -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 <name>@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 "<name>*<master>"). 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=<the master password you set>
|
||||
|
||||
# --- Admin bootstrap ---------------------------------------------------------
|
||||
INITIAL_ADMIN_ACCOUNT=admin
|
||||
INITIAL_ADMIN_DOMAIN=mail.profullstack.com
|
||||
INITIAL_ADMIN_PW=CHANGEME_admin_password
|
||||
50
deploy/mailu/provision-mailbox.sh
Executable file
50
deploy/mailu/provision-mailbox.sh
Executable file
|
|
@ -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 <name> # create <name>@$DOMAIN (random pw)
|
||||
# provision-mailbox.sh --master <pass> # (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 <password>}"
|
||||
# A Dovecot master user can authenticate as any mailbox: login "<name>*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 <name>}"
|
||||
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 <<EOF 2>/dev/null || true
|
||||
users:
|
||||
- email: ${name}@${DOMAIN}
|
||||
quota_bytes: ${QUOTA_BYTES}
|
||||
EOF
|
||||
|
||||
echo "mailbox ${name}@${DOMAIN} provisioned"
|
||||
41
deploy/mailu/refresh-certs.sh
Executable file
41
deploy/mailu/refresh-certs.sh
Executable file
|
|
@ -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/<acme-dir>/<host>/<host>.{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
|
||||
118
docs/mail.md
Normal file
118
docs/mail.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Mail — self-hosted Mailu at `mail.profullstack.com`
|
||||
|
||||
AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at
|
||||
`<name>@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.
|
||||
3
go.mod
3
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
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
|
|
@ -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 <user>` 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 (<name>.<host>), 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-<code> call routes
|
||||
|
|
|
|||
26
internal/brand/banner.go
Normal file
26
internal/brand/banner.go
Normal file
|
|
@ -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 <name>@ 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") }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
173
internal/mailbox/bot.go
Normal file
173
internal/mailbox/bot.go
Normal file
|
|
@ -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 <mailbox> <uid> full message (marks seen; "peek" 4th arg to keep unseen)
|
||||
search <query> [mailbox] search summaries
|
||||
send read a JSON Draft on stdin, send it
|
||||
reply <mailbox> <uid> read {"text":...,"replyAll":bool} on stdin
|
||||
flag <mailbox> <uid> [on|off] set/clear the flagged flag
|
||||
seen <mailbox> <uid> [on|off] set/clear the seen flag
|
||||
delete <mailbox> <uid> 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 <query> [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 <mailbox> <uid>", 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
|
||||
}
|
||||
172
internal/mailbox/client.go
Normal file
172
internal/mailbox/client.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
336
internal/mailbox/imap.go
Normal file
336
internal/mailbox/imap.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
144
internal/mailbox/mailbox_test.go
Normal file
144
internal/mailbox/mailbox_test.go
Normal file
|
|
@ -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 <ada@x.com>")
|
||||
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" <j@x.com>` {
|
||||
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")
|
||||
}
|
||||
}
|
||||
186
internal/mailbox/memory.go
Normal file
186
internal/mailbox/memory.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
251
internal/mailbox/reader_tui.go
Normal file
251
internal/mailbox/reader_tui.go
Normal file
|
|
@ -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
|
||||
}
|
||||
81
internal/mailbox/smtp.go
Normal file
81
internal/mailbox/smtp.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
100
internal/mailbox/transport.go
Normal file
100
internal/mailbox/transport.go
Normal file
|
|
@ -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 <a@b>" 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 <addr>" (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
|
||||
}
|
||||
99
internal/mailbox/types.go
Normal file
99
internal/mailbox/types.go
Normal file
|
|
@ -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"
|
||||
)
|
||||
10
internal/news/nntpd/range_test.go
Normal file
10
internal/news/nntpd/range_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
129
internal/news/nntpd/server_test.go
Normal file
129
internal/news/nntpd/server_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
82
internal/store/store_messages_test.go
Normal file
82
internal/store/store_messages_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
421
plugins/members/members.go
Normal file
421
plugins/members/members.go
Normal file
|
|
@ -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 <user>` 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))
|
||||
}
|
||||
}
|
||||
78
setup.sh
78
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.<root-of-DOMAIN>, 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.<root-of-DOMAIN>, 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 <<CADDY
|
||||
{
|
||||
email ${ACME_EMAIL}
|
||||
|
|
@ -548,7 +567,7 @@ ${DOMAIN} {
|
|||
file_server
|
||||
}
|
||||
}
|
||||
${GIT_SITE}${NEWS_SITE}${IRC_SITE}
|
||||
${GIT_SITE}${NEWS_SITE}${IRC_SITE}${MAIL_SITE}
|
||||
# Free per-user homepages at <name>.${DOMAIN} (needs wildcard DNS
|
||||
# *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's
|
||||
# ask endpoint confirms <name> 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 <<UNIT
|
||||
[Unit]
|
||||
Description=Refresh Mailu TLS cert from Caddy for ${MAIL_DOMAIN}
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=DOMAIN=${DOMAIN#*.}
|
||||
Environment=MAIL_HOST=${MAIL_DOMAIN}
|
||||
Environment=MAILU_DIR=${MAILU_DIR}
|
||||
ExecStart=/usr/local/bin/agentbbs-mailu-certs
|
||||
UNIT
|
||||
cat > /etc/systemd/system/agentbbs-mailu-certs.timer <<UNIT
|
||||
[Unit]
|
||||
Description=Periodic Mailu TLS cert refresh from Caddy
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5min
|
||||
OnUnitActiveSec=12h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue