Autonomous deploy + free-pod/Premium-email membership (#1)

Deploy automation (idempotent, runs on every deploy):
- .github/workflows/deploy.yml: push to main/master (or dispatch) SSHes to the
  droplet and re-runs setup.sh; deploys the pushed branch; smoke-tests :22.
- scripts/self-update.sh + agentbbs-update.timer: autonomous backstop that
  redeploys only when origin advances.
- setup.sh hardened: flock, fetch+reset (survives force-push), fixed the
  always-skipped arcade asset fetch path.

Membership model:
- Free, email-verified members get their own Docker pod (pod@ paywall removed)
  and a /~name homepage (seeded at join@).
- join@ is now interactive: email -> emailed 6-digit code -> enter code.
- Premium ($10 one-time, lifetime via CoinPay) grants a personal
  <name>@host email (new internal/forwardemail; forwardemail.net aliases) and
  custom domains (domain@ gated to Premium).
- ensurePremium() silently verifies/grants/provisions on hub login, join@, and
  domain@. New-signup details emailed to AGENTBBS_SIGNUP_NOTIFY (subject "bbs").

Store: User.Premium + premium/premium_ref cols, ConfirmEmailCode, GrantPremium.
Tests: store_premium_test.go, forwardemail_test.go. Build/vet/gofmt/test green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 00:59:52 -07:00 committed by GitHub
parent 1086d57a4d
commit 7a85f2dbbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 900 additions and 101 deletions

View file

@ -0,0 +1,91 @@
// Package forwardemail provisions members' personal @bbs email addresses by
// creating aliases on forwardemail.net (https://forwardemail.net) via its REST
// API. A premium member gets <username>@<domain> forwarded to the real email
// they verified at join@. When unconfigured (no API key) Configured() reports
// false and callers just display the address without creating it.
//
// Config (env):
//
// AGENTBBS_FORWARDEMAIL_API_KEY forwardemail.net API key (HTTP basic user)
// AGENTBBS_FORWARDEMAIL_DOMAIN alias domain (defaults to the BBS host)
// AGENTBBS_WEBMAIL_URL webmail interface URL shown to members
package forwardemail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const apiBase = "https://api.forwardemail.net/v1"
// Config holds the forwardemail.net credentials and the alias domain.
type Config struct {
APIKey string
Domain string
Webmail string
}
// ConfigFromEnv reads the forwardemail settings from the environment.
func ConfigFromEnv() Config {
return Config{
APIKey: os.Getenv("AGENTBBS_FORWARDEMAIL_API_KEY"),
Domain: os.Getenv("AGENTBBS_FORWARDEMAIL_DOMAIN"),
Webmail: os.Getenv("AGENTBBS_WEBMAIL_URL"),
}
}
// Configured reports whether aliases can actually be created.
func (c Config) Configured() bool { return c.APIKey != "" && c.Domain != "" }
// WebmailURL is the webmail interface members use to read their mail (may be "").
func (c Config) WebmailURL() string { return c.Webmail }
// Address is the personal email for a username, e.g. alice@bbs.profullstack.com.
func (c Config) Address(localPart string) string { return localPart + "@" + c.Domain }
// CreateAlias creates (or confirms) localPart@Domain forwarding to recipient.
// It is idempotent: an "already exists" response is treated as success.
func (c Config) CreateAlias(localPart, recipient string) error {
if !c.Configured() {
return fmt.Errorf("forwardemail not configured")
}
form := url.Values{
"name": {localPart},
"recipients": {recipient},
"is_enabled": {"true"},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
endpoint := apiBase + "/domains/" + url.PathEscape(c.Domain) + "/aliases"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
strings.NewReader(form.Encode()))
if err != nil {
return err
}
// forwardemail uses HTTP basic auth with the API key as the username and an
// empty password.
req.SetBasicAuth(c.APIKey, "")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
// Re-running for an existing member is normal — don't treat it as an error.
if strings.Contains(strings.ToLower(string(body)), "already exists") {
return nil
}
return fmt.Errorf("forwardemail create alias: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}

View file

@ -0,0 +1,27 @@
package forwardemail
import "testing"
func TestConfiguredAndAddress(t *testing.T) {
var empty Config
if empty.Configured() {
t.Fatal("empty config must not be Configured")
}
if (Config{APIKey: "k"}).Configured() {
t.Fatal("API key without domain must not be Configured")
}
c := Config{APIKey: "k", Domain: "bbs.profullstack.com", Webmail: "https://webmail.example"}
if !c.Configured() {
t.Fatal("API key + domain should be Configured")
}
if got := c.Address("alice"); got != "alice@bbs.profullstack.com" {
t.Fatalf("Address = %q", got)
}
if c.WebmailURL() != "https://webmail.example" {
t.Fatalf("WebmailURL = %q", c.WebmailURL())
}
// Creating an alias without config is a clean error, not a panic.
if err := empty.CreateAlias("alice", "alice@x.com"); err == nil {
t.Fatal("CreateAlias on unconfigured must error")
}
}