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,102 @@
|
||||
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package webmail checks a running ihasmail from the outside, the way a
|
||||
// browser would reach it.
|
||||
package webmail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Health is the part of /api/health the tool reports.
|
||||
type Health struct {
|
||||
OK bool `json:"ok"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// CheckHealth reads /api/health once.
|
||||
func CheckHealth(ctx context.Context, baseURL string) (Health, error) {
|
||||
var h Health
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/health", nil)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
res, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return h, fmt.Errorf("/api/health answered HTTP %d", res.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&h); err != nil {
|
||||
return h, fmt.Errorf("/api/health: %w", err)
|
||||
}
|
||||
if !h.OK {
|
||||
return h, fmt.Errorf("/api/health reports not ok")
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// SignIn signs in through ihasmail and straight back out. It is the proof the
|
||||
// two are linked: ihasmail can only accept the credentials by presenting them
|
||||
// to Stalwart over STALWART_URL and getting a JMAP session back.
|
||||
//
|
||||
// It returns whether the session carries Stalwart's own capability, which is
|
||||
// what ihasmail keys its administration features on.
|
||||
func SignIn(ctx context.Context, baseURL, username, password string) (stalwartCapability bool, err error) {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Timeout: 30 * time.Second, Jar: jar}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
res, err := post(ctx, client, baseURL+"/api/auth/login", body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("sign-in as %s answered HTTP %d: %s", username, res.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var session struct {
|
||||
Accounts map[string]struct {
|
||||
AccountCapabilities map[string]json.RawMessage `json:"accountCapabilities"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &session); err != nil || len(session.Accounts) == 0 {
|
||||
return false, fmt.Errorf("sign-in as %s did not return a JMAP session", username)
|
||||
}
|
||||
for _, a := range session.Accounts {
|
||||
if _, ok := a.AccountCapabilities["urn:stalwart:jmap"]; ok {
|
||||
stalwartCapability = true
|
||||
}
|
||||
}
|
||||
|
||||
// Not leaving a session behind matters little with sessions in memory, but
|
||||
// it costs one request.
|
||||
if res, err := post(ctx, client, baseURL+"/api/auth/logout", []byte("{}")); err == nil {
|
||||
res.Body.Close()
|
||||
}
|
||||
return stalwartCapability, nil
|
||||
}
|
||||
|
||||
func post(ctx context.Context, client *http.Client, url string, body []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// ihasmail's CSRF check for a request that is not same-origin by
|
||||
// Sec-Fetch-Site.
|
||||
req.Header.Set("X-Requested-With", "ihasmail")
|
||||
return client.Do(req)
|
||||
}
|
||||
Reference in New Issue
Block a user