From f2a15c69aa0afd793e5b7089bcb2a9f1e011075f Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 10 May 2026 11:02:34 +0200 Subject: [PATCH] refactor: enhance NewClient to support environment variable fallback and add tests --- README.md | 22 +++++++++++++-- cmd/miab/main.go | 31 +++++++------------- dns.go | 51 +++++++++++++++++++++++++++++++-- dns_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 148 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 3ad8656..7e84b7f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,11 @@ Go client library and CLI for the [Mail-in-a-Box](https://mailinabox.email/) adm ```go import miab "gitea.koszewscy.waw.pl/koszewscy/mailinabox-go" -c := miab.NewClient("box.example.com", "admin@example.com", "password") +// Explicit credentials: +c, err := miab.NewClient("box.example.com", "admin@example.com", "password") + +// From environment variables (MIAB_* or MAILINABOX_* style): +c, err := miab.NewClient("", "", "") c.SetRecord("foo.example.com", "TXT", "v=spf1 ~all") c.AddRecord("foo.example.com", "A", "1.2.3.4") @@ -18,18 +22,30 @@ records, _ := c.ListRecords("TXT") ## CLI -Install: +Install from registry: ```sh go install gitea.koszewscy.waw.pl/koszewscy/mailinabox-go/cmd/miab@latest ``` -Credentials via environment variables: +Build locally: ```sh +go build -o miab ./cmd/miab +``` + +Credentials via environment variables (either style is accepted): + +```sh +# MIAB style export MIAB_HOST=box.example.com export MIAB_USERNAME=admin@example.com export MIAB_PASSWORD=password + +# Mail-in-a-Box style +export MAILINABOX_BASE_URL=https://box.example.com +export MAILINABOX_EMAIL=admin@example.com +export MAILINABOX_PASSWORD=password ``` Commands: diff --git a/cmd/miab/main.go b/cmd/miab/main.go index f12dc26..8b43bbd 100644 --- a/cmd/miab/main.go +++ b/cmd/miab/main.go @@ -8,6 +8,7 @@ import ( miab "gitea.koszewscy.waw.pl/koszewscy/mailinabox-go" ) + func usage() { fmt.Fprintf(os.Stderr, `Usage: miab [options] @@ -17,33 +18,21 @@ Commands: delete Delete a DNS record add Add a DNS record -Credentials are read from environment variables: - MIAB_HOST Mail-in-a-Box hostname (e.g. box.example.com) - MIAB_USERNAME Admin email address - MIAB_PASSWORD Admin password +Credentials: any empty parameter falls back to environment variables. + MIAB_HOST / MAILINABOX_BASE_URL Mail-in-a-Box hostname (MIAB_HOST takes precedence; + hostname is parsed from MAILINABOX_BASE_URL if set) + MIAB_USERNAME / MAILINABOX_EMAIL Admin email address + MIAB_PASSWORD / MAILINABOX_PASSWORD Admin password `) } func mustClient() *miab.Client { - host := os.Getenv("MIAB_HOST") - username := os.Getenv("MIAB_USERNAME") - password := os.Getenv("MIAB_PASSWORD") - - var missing []string - if host == "" { - missing = append(missing, "MIAB_HOST") - } - if username == "" { - missing = append(missing, "MIAB_USERNAME") - } - if password == "" { - missing = append(missing, "MIAB_PASSWORD") - } - if len(missing) > 0 { - fmt.Fprintf(os.Stderr, "miab: missing required environment variables: %v\n", missing) + c, err := miab.NewClient("", "", "") + if err != nil { + fmt.Fprintln(os.Stderr, err) os.Exit(1) } - return miab.NewClient(host, username, password) + return c } func main() { diff --git a/dns.go b/dns.go index 0e03ff4..98da2ac 100644 --- a/dns.go +++ b/dns.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "os" "strings" "time" ) @@ -24,8 +26,51 @@ type DNSRecord struct { Value string `json:"value"` } -// NewClient constructs a Client. host should be the bare hostname (e.g. "box.example.com"). -func NewClient(host, username, password string) *Client { +// NewClient constructs a Client. Any empty parameter is filled from environment variables: +// +// host: MIAB_HOST or hostname parsed from MAILINABOX_BASE_URL +// username: MIAB_USERNAME or MAILINABOX_EMAIL +// password: MIAB_PASSWORD or MAILINABOX_PASSWORD +// +// Returns an error if a value is still empty after the env lookup. +func NewClient(host, username, password string) (*Client, error) { + if host == "" { + host = os.Getenv("MIAB_HOST") + if host == "" { + if base := os.Getenv("MAILINABOX_BASE_URL"); base != "" { + if u, err := url.Parse(base); err == nil { + host = u.Hostname() + } + } + } + } + if username == "" { + username = os.Getenv("MIAB_USERNAME") + if username == "" { + username = os.Getenv("MAILINABOX_EMAIL") + } + } + if password == "" { + password = os.Getenv("MIAB_PASSWORD") + if password == "" { + password = os.Getenv("MAILINABOX_PASSWORD") + } + } + + var missing []string + if host == "" { + missing = append(missing, "MIAB_HOST or MAILINABOX_BASE_URL") + } + if username == "" { + missing = append(missing, "MIAB_USERNAME or MAILINABOX_EMAIL") + } + if password == "" { + missing = append(missing, "MIAB_PASSWORD or MAILINABOX_PASSWORD") + } + if len(missing) > 0 { + return nil, fmt.Errorf("miab: missing required environment variables: %v", missing) + } + return &Client{ host: host, username: username, @@ -33,7 +78,7 @@ func NewClient(host, username, password string) *Client { httpClient: &http.Client{ Timeout: 30 * time.Second, }, - } + }, nil } // SetRecord replaces all existing records of the given type for name with value (HTTP PUT). diff --git a/dns_test.go b/dns_test.go index 3ebeb47..282fa9d 100644 --- a/dns_test.go +++ b/dns_test.go @@ -10,8 +10,74 @@ import ( "testing" ) +func TestNewClient_miabStyle(t *testing.T) { + t.Setenv("MIAB_HOST", "box.example.com") + t.Setenv("MIAB_USERNAME", "user@example.com") + t.Setenv("MIAB_PASSWORD", "secret") + + c, err := NewClient("", "", "") + if err != nil { + t.Fatal(err) + } + if c.host != "box.example.com" || c.username != "user@example.com" || c.password != "secret" { + t.Errorf("unexpected client fields: %+v", c) + } +} + +func TestNewClient_mailinaboxStyle(t *testing.T) { + t.Setenv("MAILINABOX_BASE_URL", "https://box.example.com") + t.Setenv("MAILINABOX_EMAIL", "user@example.com") + t.Setenv("MAILINABOX_PASSWORD", "secret") + + c, err := NewClient("", "", "") + if err != nil { + t.Fatal(err) + } + if c.host != "box.example.com" || c.username != "user@example.com" || c.password != "secret" { + t.Errorf("unexpected client fields: %+v", c) + } +} + +func TestNewClient_miabTakesPrecedence(t *testing.T) { + t.Setenv("MIAB_HOST", "miab.example.com") + t.Setenv("MIAB_USERNAME", "miab@example.com") + t.Setenv("MIAB_PASSWORD", "miabpass") + t.Setenv("MAILINABOX_BASE_URL", "https://other.example.com") + t.Setenv("MAILINABOX_EMAIL", "other@example.com") + t.Setenv("MAILINABOX_PASSWORD", "otherpass") + + c, err := NewClient("", "", "") + if err != nil { + t.Fatal(err) + } + if c.host != "miab.example.com" || c.username != "miab@example.com" || c.password != "miabpass" { + t.Errorf("MIAB_* vars should take precedence, got: %+v", c) + } +} + +func TestNewClient_missingVars(t *testing.T) { + _, err := NewClient("", "", "") + if err == nil { + t.Fatal("expected error when no env vars set") + } +} + +func TestNewClient_explicitParamsTakePrecedence(t *testing.T) { + t.Setenv("MIAB_HOST", "env.example.com") + t.Setenv("MIAB_USERNAME", "env@example.com") + t.Setenv("MIAB_PASSWORD", "envpass") + + c, err := NewClient("explicit.example.com", "explicit@example.com", "explicitpass") + if err != nil { + t.Fatal(err) + } + if c.host != "explicit.example.com" || c.username != "explicit@example.com" || c.password != "explicitpass" { + t.Errorf("explicit params should take precedence, got: %+v", c) + } +} + func TestBuildURL(t *testing.T) { - c := NewClient("box.example.com", "user", "pass") + c, _ := NewClient("box.example.com", "user", "pass") tests := []struct { name, recordType, want string @@ -153,7 +219,10 @@ func TestErrorResponse(t *testing.T) { // testClient builds a Client pointed at the test server's host. func testClient(srv *httptest.Server) *Client { - c := NewClient(strings.TrimPrefix(srv.URL, "http://"), "user@example.com", "secret") + c, err := NewClient(strings.TrimPrefix(srv.URL, "http://"), "user@example.com", "secret") + if err != nil { + panic(err) + } c.httpClient = srv.Client() // Override buildURL to use http instead of https for the test server. // We do this by wrapping the httpClient transport (no-op) and patching