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.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user