Files
ihasmail-oneshot/internal/stalwart/client.go
T
jcoffey-dev d19696dec3 Deploy a fresh Stalwart and ihasmail, linked, in one command
deploy stands up Stalwart 0.16, ihasmail and (for a mail host) Caddy as a
compose project: completes Stalwart's bootstrap over x:Bootstrap, links
ihasmail over the private network, requests certificates for both Caddy
(TLS-ALPN-01) and Stalwart (HTTP-01 through Caddy), makes the auto-ban safe
behind the proxy, and proves the link by signing in through the webmail.
--local gives a loopback-only pair. certs retries Stalwart's certificate;
destroy removes a deployment.

e2e/public.sh runs the whole mail-host path against Pebble with no
internet involved.
2026-09-13 22:02:40 -07:00

231 lines
6.3 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
// Package stalwart configures a fresh Stalwart 0.16 over JMAP.
//
// 0.16 has no REST management API and no configuration file to template: a
// server with an empty /etc/stalwart starts in bootstrap mode, and everything
// from the first administrator to the ACME account is a registry object read
// and written with x: methods. Every call here was worked out against a real
// 0.16.22, not the documentation.
package stalwart
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var using = []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"}
// Client calls one Stalwart as one account. The zero HTTP uses a client with a
// timeout, so a hung server fails a step instead of hanging the tool.
type Client struct {
BaseURL string // e.g. http://127.0.0.1:8081, no trailing slash
Username string
Password string
HTTP *http.Client
}
// Call is one method call in a request.
type Call struct {
Method string
Args any
ID string
}
// Response is one method response.
type Response struct {
Method string
Args json.RawMessage
ID string
}
// MethodError is a JMAP method-level error: the request was fine, the call was
// refused.
type MethodError struct {
Method string
Type string
Description string
}
func (e *MethodError) Error() string {
if e.Description != "" {
return fmt.Sprintf("%s: %s: %s", e.Method, e.Type, e.Description)
}
return fmt.Sprintf("%s: %s", e.Method, e.Type)
}
// HTTPError is a response that was not a JMAP response at all.
type HTTPError struct {
Status int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body))
}
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 30 * time.Second}
}
// Do sends calls in one request and returns their responses in order. A method
// error in any of them is returned as a *MethodError, after the responses
// before it -- JMAP stops nothing on an error, but every caller here needs
// all of its calls to have worked.
func (c *Client) Do(ctx context.Context, calls ...Call) ([]Response, error) {
mc := make([][3]any, len(calls))
for i, call := range calls {
mc[i] = [3]any{call.Method, call.Args, call.ID}
}
body, err := json.Marshal(map[string]any{"using": using, "methodCalls": mc})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/jmap/", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.Username, c.Password)
req.Header.Set("Content-Type", "application/json")
res, err := c.httpClient().Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 16<<20))
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, &HTTPError{Status: res.StatusCode, Body: string(raw)}
}
var envelope struct {
MethodResponses [][3]json.RawMessage `json:"methodResponses"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("not a JMAP response: %w", err)
}
out := make([]Response, 0, len(envelope.MethodResponses))
for _, mr := range envelope.MethodResponses {
var r Response
if err := json.Unmarshal(mr[0], &r.Method); err != nil {
return nil, fmt.Errorf("not a JMAP response: %w", err)
}
if err := json.Unmarshal(mr[2], &r.ID); err != nil {
return nil, fmt.Errorf("not a JMAP response: %w", err)
}
r.Args = mr[1]
if r.Method == "error" {
var e struct {
Type string `json:"type"`
Description string `json:"description"`
}
_ = json.Unmarshal(r.Args, &e)
method := r.ID
for _, call := range calls {
if call.ID == r.ID {
method = call.Method
}
}
return out, &MethodError{Method: method, Type: e.Type, Description: e.Description}
}
out = append(out, r)
}
if len(out) != len(calls) {
return out, fmt.Errorf("sent %d method calls and got %d responses", len(calls), len(out))
}
return out, nil
}
// SetError is one entry of notCreated, notUpdated or notDestroyed.
type SetError struct {
Type string `json:"type"`
Description string `json:"description"`
Properties []string `json:"properties"`
}
func (e SetError) Error() string {
s := e.Type
if e.Description != "" {
s += ": " + e.Description
}
if len(e.Properties) > 0 {
s += " (" + strings.Join(e.Properties, ", ") + ")"
}
return s
}
// SetResult is the part of a /set response every caller here reads.
type SetResult struct {
Created map[string]json.RawMessage `json:"created"`
Updated map[string]json.RawMessage `json:"updated"`
NotCreated map[string]SetError `json:"notCreated"`
NotUpdated map[string]SetError `json:"notUpdated"`
NotDestroyed map[string]SetError `json:"notDestroyed"`
}
// Refused returns the first refusal in the result, if any.
func (r SetResult) Refused() error {
for _, m := range []map[string]SetError{r.NotCreated, r.NotUpdated, r.NotDestroyed} {
for id, e := range m {
return fmt.Errorf("%s: %w", id, e)
}
}
return nil
}
func decodeSet(r Response) (SetResult, error) {
var s SetResult
if err := json.Unmarshal(r.Args, &s); err != nil {
return s, fmt.Errorf("%s: %w", r.Method, err)
}
if err := s.Refused(); err != nil {
return s, fmt.Errorf("%s refused %w", r.Method, err)
}
return s, nil
}
// createdID reads the server-assigned id of a created object.
func createdID(s SetResult, key string) (string, error) {
raw, ok := s.Created[key]
if !ok {
return "", errors.New("the server did not confirm the create")
}
var obj struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &obj); err != nil || obj.ID == "" {
return "", errors.New("the server confirmed the create without an id")
}
return obj.ID, nil
}
// Live reports whether Stalwart answers its liveness probe, which it does in
// bootstrap mode too.
func Live(ctx context.Context, baseURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz/live", nil)
if err != nil {
return err
}
res, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return err
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
return &HTTPError{Status: res.StatusCode}
}
return nil
}