// Copyright (c) 2026 Sławomir Koszewski. All rights reserved. // Use of this source code is governed by the MIT License // that can be found in the LICENSE file. package miab import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) // Client holds connection configuration for the Mail-in-a-Box admin DNS API. type Client struct { host string username string password string httpClient *http.Client } // DNSRecord represents a single custom DNS record returned by the MIAB API. type DNSRecord struct { Name string `json:"name"` Type string `json:"type"` Value string `json:"value"` } // 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, password: password, httpClient: &http.Client{ Timeout: 30 * time.Second, }, }, nil } // SetRecord replaces all existing records of the given type for name with value (HTTP PUT). func (c *Client) SetRecord(name, recordType, value string) error { _, err := c.do(http.MethodPut, c.buildURL(name, recordType), value) return err } // DeleteRecord removes the record matching name, recordType and value (HTTP DELETE). // Pass an empty value to remove all records of that type for the name. func (c *Client) DeleteRecord(name, recordType, value string) error { _, err := c.do(http.MethodDelete, c.buildURL(name, recordType), value) return err } // AddRecord adds a record without replacing existing ones (HTTP POST). func (c *Client) AddRecord(name, recordType, value string) error { _, err := c.do(http.MethodPost, c.buildURL(name, recordType), value) return err } // ListRecords returns all custom DNS records. If recordType is non-empty, only // records of that type are returned. func (c *Client) ListRecords(recordType string) ([]DNSRecord, error) { body, err := c.do(http.MethodGet, fmt.Sprintf("https://%s/admin/dns/custom", c.host), "") if err != nil { return nil, err } // MIAB returns an array of objects with fields: qname, rtype, value var raw []struct { QName string `json:"qname"` RType string `json:"rtype"` Value string `json:"value"` } if err := json.Unmarshal([]byte(body), &raw); err != nil { return nil, fmt.Errorf("miab: failed to parse response: %w", err) } var records []DNSRecord upper := strings.ToUpper(recordType) for _, r := range raw { if recordType == "" || r.RType == upper { records = append(records, DNSRecord{ Name: r.QName, Type: r.RType, Value: r.Value, }) } } return records, nil } // buildURL mirrors miab.py's get_miab_url: omits the record type segment for "A" records. func (c *Client) buildURL(name, recordType string) string { if strings.ToUpper(recordType) == "A" { return fmt.Sprintf("https://%s/admin/dns/custom/%s", c.host, name) } return fmt.Sprintf("https://%s/admin/dns/custom/%s/%s", c.host, name, strings.ToUpper(recordType)) } // do executes an HTTP request with Basic Auth and returns the response body as a string. func (c *Client) do(method, url, body string) (string, error) { var bodyReader io.Reader if body != "" { bodyReader = strings.NewReader(body) } req, err := http.NewRequest(method, url, bodyReader) if err != nil { return "", fmt.Errorf("miab: failed to create request: %w", err) } req.SetBasicAuth(c.username, c.password) if body != "" { req.Header.Set("Content-Type", "text/plain") } resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("miab: request failed: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("miab: failed to read response body: %w", err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("miab: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) } return string(respBody), nil }