mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
1086d57a4d
commit
7a85f2dbbb
11 changed files with 900 additions and 101 deletions
91
internal/forwardemail/forwardemail.go
Normal file
91
internal/forwardemail/forwardemail.go
Normal 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)))
|
||||
}
|
||||
27
internal/forwardemail/forwardemail_test.go
Normal file
27
internal/forwardemail/forwardemail_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue