package stalwart import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) var ( ErrDisabled = errors.New("stalwart client disabled") ErrNotFound = errors.New("stalwart resource not found") ) type Config struct { Enabled bool BaseURL string APIKey string IMAPHost string IMAPPort int IMAPTLS bool SMTPHost string SMTPPort int SMTPTLS bool HTTPClient *http.Client } type Client struct { cfg Config } func NewClient(cfg Config) *Client { if cfg.HTTPClient == nil { cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second} } if cfg.IMAPPort == 0 { cfg.IMAPPort = 993 } if cfg.SMTPPort == 0 { cfg.SMTPPort = 587 } return &Client{cfg: cfg} } func (c *Client) Enabled() bool { return c != nil && c.cfg.Enabled && strings.TrimSpace(c.cfg.BaseURL) != "" } func (c *Client) IMAPEndpoint() (host string, port int, tls bool) { return c.cfg.IMAPHost, c.cfg.IMAPPort, c.cfg.IMAPTLS } func (c *Client) SMTPEndpoint() (host string, port int, tls bool) { return c.cfg.SMTPHost, c.cfg.SMTPPort, c.cfg.SMTPTLS } type Domain struct { ID string Name string } type Account struct { ID string Email string } type jmapRequest struct { Using []string `json:"using"` MethodCalls []any `json:"methodCalls"` } type jmapResponse struct { MethodResponses []json.RawMessage `json:"methodResponses"` } func (c *Client) call(ctx context.Context, method string, args any, callID string) (json.RawMessage, error) { if !c.Enabled() { return nil, ErrDisabled } body, err := json.Marshal(jmapRequest{ Using: []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"}, MethodCalls: []any{[]any{method, args, callID}}, }) if err != nil { return nil, err } url := strings.TrimRight(c.cfg.BaseURL, "/") + "/api" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") if c.cfg.APIKey != "" { req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) } resp, err := c.cfg.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, fmt.Errorf("stalwart api %s: %s", resp.Status, strings.TrimSpace(string(raw))) } var envelope jmapResponse if err := json.Unmarshal(raw, &envelope); err != nil { return nil, err } if len(envelope.MethodResponses) == 0 { return nil, fmt.Errorf("stalwart: empty method response") } var parts []json.RawMessage if err := json.Unmarshal(envelope.MethodResponses[0], &parts); err != nil { return nil, err } if len(parts) < 2 { return nil, fmt.Errorf("stalwart: malformed method response") } var status string if err := json.Unmarshal(parts[0], &status); err != nil { return nil, err } if status == "error" { return nil, fmt.Errorf("stalwart error: %s", string(parts[1])) } return parts[1], nil } func (c *Client) CreateDomain(ctx context.Context, name string) (Domain, error) { name = strings.ToLower(strings.TrimSpace(name)) if name == "" { return Domain{}, fmt.Errorf("domain name required") } if !c.Enabled() { return Domain{ID: "local-" + name, Name: name}, nil } raw, err := c.call(ctx, "x:Domain/set", map[string]any{ "create": map[string]any{ "d1": map[string]any{"name": name}, }, }, "c1") if err != nil { return Domain{}, err } var parsed struct { Created map[string]struct { ID string `json:"id"` } `json:"created"` } if err := json.Unmarshal(raw, &parsed); err != nil { return Domain{}, err } for _, v := range parsed.Created { return Domain{ID: v.ID, Name: name}, nil } return Domain{}, fmt.Errorf("stalwart: domain not created") } func (c *Client) CreateAccount(ctx context.Context, domainID, localPart, password string, quotaBytes int64) (Account, error) { localPart = strings.ToLower(strings.TrimSpace(localPart)) if localPart == "" { return Account{}, fmt.Errorf("local part required") } if !c.Enabled() { return Account{ID: "local-" + localPart, Email: localPart + "@local"}, nil } fields := map[string]any{ "name": localPart, "domainId": domainID, } if password != "" { fields["credentials"] = map[string]any{"password": password} } if quotaBytes > 0 { fields["quota"] = map[string]any{"maxDiskQuota": quotaBytes} } raw, err := c.call(ctx, "x:Account/set", map[string]any{ "create": map[string]any{"a1": fields}, }, "c1") if err != nil { return Account{}, err } var parsed struct { Created map[string]struct { ID string `json:"id"` } `json:"created"` } if err := json.Unmarshal(raw, &parsed); err != nil { return Account{}, err } for _, v := range parsed.Created { return Account{ID: v.ID, Email: localPart}, nil } return Account{}, fmt.Errorf("stalwart: account not created") } func (c *Client) SetAccountPassword(ctx context.Context, accountID, password string) error { if password == "" { return fmt.Errorf("password required") } if !c.Enabled() { return nil } _, err := c.call(ctx, "x:Account/set", map[string]any{ "update": map[string]any{ accountID: map[string]any{ "credentials": map[string]any{"password": password}, }, }, }, "c1") return err } func (c *Client) DeleteAccount(ctx context.Context, accountID string) error { if accountID == "" { return nil } if !c.Enabled() { return nil } _, err := c.call(ctx, "x:Account/set", map[string]any{ "destroy": []string{accountID}, }, "c1") return err }