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:
2026-09-13 22:02:40 -07:00
commit d19696dec3
18 changed files with 3711 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
// Package config turns the command line into a Plan: every name, address and
// image the deployment uses, validated once, before anything touches Docker.
//
// Nothing downstream re-checks what is here. A plan that validates is one the
// templates can render without quoting surprises, so the rules are strict on
// purpose: a hostname is a hostname, a bind is host:port, a user name is the
// local part of an address and nothing else.
package config
import (
"errors"
"fmt"
"net"
"net/mail"
"net/netip"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Versions this release was tested with, end to end. Stalwart is pinned
// because ihasmail validates against one Stalwart release at a time; the
// ihasmail tag is the newest release at the time; Caddy is pinned so that a
// redeploy months from now renders the same proxy.
const (
DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22"
DefaultIhasmailImage = "ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328"
DefaultCaddyImage = "caddy:2.11.4"
)
// Stalwart's ACME order covers these next to the mail host, all under the mail
// domain: it is what its own DNS zone points at the mail host as CNAMEs, and
// Caddy has to answer for every one of them on port 80 or the order fails.
var stalwartServiceLabels = []string{"autoconfig", "autodiscover", "mta-sts", "ua-auto-config"}
// Options is the command line, as given.
type Options struct {
Local bool
Domain string
MailHost string
WebmailHost string
Email string
Dir string
Project string
Users []string
StalwartImage string
IhasmailImage string
CaddyImage string
WebmailBind string
StalwartBind string
Subnet string
// A private ACME CA, instead of Let's Encrypt. Both are for an internal CA
// (and for the end-to-end test, which runs one); neither is needed on the
// open internet.
ACMEDirectory string
ACMECARoot string
}
// Plan is Options after defaults and validation.
type Plan struct {
Local bool
Domain string
MailHost string
WebmailHost string
Email string
Dir string
Project string
Users []string // local parts, lower-case, without the domain
StalwartImage string
IhasmailImage string
CaddyImage string
WebmailBind string
StalwartBind string
Subnet netip.Prefix
CaddyIP netip.Addr
IhasmailIP netip.Addr
StalwartIP netip.Addr
ACMEDirectory string
ACMECARoot string // absolute path, or empty
}
// StalwartNames is every hostname Caddy fronts for Stalwart: the mail host
// first, then the service names its ACME order includes.
func (p Plan) StalwartNames() []string {
names := []string{p.MailHost}
for _, l := range stalwartServiceLabels {
names = append(names, l+"."+p.Domain)
}
return names
}
// PublishedPorts is every host port the stack binds on all interfaces. Local
// mode binds nothing but the two loopback addresses.
func (p Plan) PublishedPorts() []int {
if p.Local {
return nil
}
// No 587 or 143: Stalwart 0.16 opens no listener on either by default, and
// its own DNS zone advertises 465 and 993. 995 is advertised too, so it is
// published rather than left as an SRV record that points at nothing.
return []int{25, 80, 443, 465, 993, 995, 4190}
}
var (
hostnameRE = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$`)
localRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$`)
projectRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
imageRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*(?::[A-Za-z0-9._-]+)?(?:@sha256:[a-f0-9]{64})?$`)
)
func normaliseHost(s string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".")
}
// Validate applies defaults and checks everything, returning every problem at
// once rather than the first: a one-shot tool that makes you run it five times
// to find five typos is not one shot.
func (o Options) Validate() (Plan, error) {
var errs []error
fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
p := Plan{Local: o.Local}
p.Domain = normaliseHost(o.Domain)
switch {
case p.Domain == "" && o.Local:
p.Domain = "example.test"
case p.Domain == "":
fail("--domain is required: the mail domain this server receives for, e.g. example.com")
case !hostnameRE.MatchString(p.Domain):
fail("--domain %q is not a domain name", o.Domain)
}
// Defaults below are built from the domain. Past a bad one, report only
// what was typed: "webmail.bad domain is not a hostname" is the same
// mistake again, not a second one.
domainOK := hostnameRE.MatchString(p.Domain)
if !domainOK {
p.Domain = "domain.invalid"
}
p.MailHost = normaliseHost(o.MailHost)
if p.MailHost == "" {
p.MailHost = "mail." + p.Domain
}
// Stalwart's ACME certificate and its DNS zone are both built per domain,
// so a mail host outside the domain would get a certificate that does not
// name it. One label under the domain is the shape both are sure to cover.
if label, ok := strings.CutSuffix(p.MailHost, "."+p.Domain); (domainOK && (!ok || strings.Contains(label, "."))) || !hostnameRE.MatchString(p.MailHost) {
fail("--mail-host %q must be one label under the domain, e.g. mail.%s", o.MailHost, p.Domain)
}
p.WebmailHost = normaliseHost(o.WebmailHost)
if p.WebmailHost == "" {
p.WebmailHost = "webmail." + p.Domain
}
if !hostnameRE.MatchString(p.WebmailHost) {
fail("--webmail-host %q is not a hostname", o.WebmailHost)
}
for _, n := range p.StalwartNames() {
if n == p.WebmailHost {
fail("--webmail-host %q is already one of Stalwart's names; give the webmail a name of its own", p.WebmailHost)
}
}
p.Email = strings.TrimSpace(o.Email)
if p.Email == "" {
p.Email = "postmaster@" + p.Domain
}
if a, err := mail.ParseAddress(p.Email); err != nil || a.Address != p.Email {
fail("--email %q is not a plain email address", o.Email)
}
p.Project = strings.TrimSpace(o.Project)
if p.Project == "" {
p.Project = "ihasmail-" + strings.ReplaceAll(p.Domain, ".", "-")
}
if !projectRE.MatchString(p.Project) {
fail("--project %q may hold only lower-case letters, digits, '-' and '_'", p.Project)
}
p.Dir = o.Dir
if p.Dir == "" {
p.Dir = p.Project
}
if abs, err := filepath.Abs(p.Dir); err != nil {
fail("--dir %q: %v", o.Dir, err)
} else {
p.Dir = abs
}
seen := map[string]bool{"admin": true}
for _, u := range o.Users {
local := strings.ToLower(strings.TrimSpace(u))
if at := strings.LastIndexByte(local, '@'); at >= 0 {
if domainOK && local[at+1:] != p.Domain {
fail("--user %q is not in %s, the only domain this deploys", u, p.Domain)
continue
}
local = local[:at]
}
switch {
case !localRE.MatchString(local):
fail("--user %q is not a valid mailbox name", u)
case seen[local]:
fail("--user %q is given twice, or is the administrator", u)
default:
seen[local] = true
p.Users = append(p.Users, local)
}
}
p.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage)
p.IhasmailImage = orDefault(o.IhasmailImage, DefaultIhasmailImage)
p.CaddyImage = orDefault(o.CaddyImage, DefaultCaddyImage)
for flag, img := range map[string]string{"--stalwart-image": p.StalwartImage, "--ihasmail-image": p.IhasmailImage, "--caddy-image": p.CaddyImage} {
if !imageRE.MatchString(img) {
fail("%s %q is not an image reference", flag, img)
}
}
p.WebmailBind = orDefault(o.WebmailBind, "127.0.0.1:8080")
p.StalwartBind = orDefault(o.StalwartBind, "127.0.0.1:8081")
for flag, b := range map[string]string{"--webmail-bind": p.WebmailBind, "--stalwart-bind": p.StalwartBind} {
if err := checkBind(b); err != nil {
fail("%s %q: %v", flag, b, err)
}
}
if p.WebmailBind == p.StalwartBind {
fail("--webmail-bind and --stalwart-bind are both %s", p.WebmailBind)
}
if !p.Local {
for _, port := range p.PublishedPorts() {
for flag, b := range map[string]string{"--webmail-bind": p.WebmailBind, "--stalwart-bind": p.StalwartBind} {
if _, bp, _ := net.SplitHostPort(b); bp == strconv.Itoa(port) {
fail("%s %q collides with port %d, which the mail host publishes", flag, b, port)
}
}
}
}
subnet := orDefault(o.Subnet, "172.31.253.0/24")
if pfx, err := netip.ParsePrefix(subnet); err != nil || !pfx.Addr().Is4() || pfx.Bits() > 27 || pfx.Masked() != pfx {
fail("--subnet %q must be an IPv4 network no smaller than a /27, e.g. 172.31.253.0/24", subnet)
} else {
p.Subnet = pfx
// Fixed addresses, because Stalwart is told about two of them: Caddy's
// forwarded-for header is believed, and ihasmail is exempt from the
// auto-ban. An address Docker picks afresh on every recreate cannot be
// written into either.
base := pfx.Addr().As4()
at := func(n byte) netip.Addr { b := base; b[3] += n; return netip.AddrFrom4(b) }
p.CaddyIP, p.IhasmailIP, p.StalwartIP = at(10), at(11), at(12)
}
p.ACMEDirectory = strings.TrimSpace(o.ACMEDirectory)
if o.ACMECARoot != "" {
if abs, err := filepath.Abs(o.ACMECARoot); err != nil {
fail("--acme-ca-root %q: %v", o.ACMECARoot, err)
} else {
p.ACMECARoot = abs
}
}
if p.Local && (p.ACMEDirectory != "" || p.ACMECARoot != "" || o.Email != "") {
fail("--acme-directory, --acme-ca-root and --email have no effect with --local, which requests no certificates")
}
if p.ACMEDirectory != "" && !strings.HasPrefix(p.ACMEDirectory, "https://") {
fail("--acme-directory %q must be an https URL", p.ACMEDirectory)
}
if len(errs) > 0 {
return Plan{}, errors.Join(errs...)
}
return p, nil
}
func orDefault(s, def string) string {
if s = strings.TrimSpace(s); s == "" {
return def
}
return s
}
func checkBind(b string) error {
host, port, err := net.SplitHostPort(b)
if err != nil {
return errors.New("must be host:port, e.g. 127.0.0.1:8080")
}
if _, err := netip.ParseAddr(host); err != nil {
return errors.New("the host part must be an IP address")
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return errors.New("the port must be 1-65535")
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"slices"
"strings"
"testing"
)
func TestDefaultsFollowTheDomain(t *testing.T) {
p, err := Options{Domain: "Example.COM.", Dir: "/srv/mail"}.Validate()
if err != nil {
t.Fatal(err)
}
for got, want := range map[string]string{
p.Domain: "example.com",
p.MailHost: "mail.example.com",
p.WebmailHost: "webmail.example.com",
p.Email: "[email protected]",
p.Project: "ihasmail-example-com",
p.Dir: "/srv/mail",
} {
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
if p.CaddyIP.String() != "172.31.253.10" || p.IhasmailIP.String() != "172.31.253.11" || p.StalwartIP.String() != "172.31.253.12" {
t.Errorf("addresses %s %s %s", p.CaddyIP, p.IhasmailIP, p.StalwartIP)
}
want := []string{"mail.example.com", "autoconfig.example.com", "autodiscover.example.com", "mta-sts.example.com", "ua-auto-config.example.com"}
if !slices.Equal(p.StalwartNames(), want) {
t.Errorf("StalwartNames = %v", p.StalwartNames())
}
}
func TestLocalNeedsNoDomainAndPublishesNothing(t *testing.T) {
p, err := Options{Local: true}.Validate()
if err != nil {
t.Fatal(err)
}
if p.Domain != "example.test" || len(p.PublishedPorts()) != 0 {
t.Errorf("domain %q, ports %v", p.Domain, p.PublishedPorts())
}
}
func TestRejects(t *testing.T) {
for name, tc := range map[string]struct {
o Options
want string
}{
"no domain": {Options{}, "--domain is required"},
"bad domain": {Options{Domain: "not a domain"}, `--domain "not a domain"`},
"mail host elsewhere": {Options{Domain: "example.com", MailHost: "mx.other.net"}, "one label under the domain"},
"mail host two deep": {Options{Domain: "example.com", MailHost: "a.b.example.com"}, "one label under the domain"},
"webmail is autoconfig": {Options{Domain: "example.com", WebmailHost: "autoconfig.example.com"}, "already one of Stalwart's names"},
"webmail is mail host": {Options{Domain: "example.com", WebmailHost: "mail.example.com"}, "already one of Stalwart's names"},
"user in another domain": {Options{Domain: "example.com", Users: []string{"[email protected]"}}, "not in example.com"},
"user is admin": {Options{Domain: "example.com", Users: []string{"admin"}}, "or is the administrator"},
"duplicate user": {Options{Domain: "example.com", Users: []string{"bob", "[email protected]"}}, "given twice"},
"bad user": {Options{Domain: "example.com", Users: []string{"bob smith"}}, "not a valid mailbox name"},
"bind not host:port": {Options{Domain: "example.com", WebmailBind: "8080"}, "--webmail-bind"},
"bind on a mail port": {Options{Domain: "example.com", StalwartBind: "127.0.0.1:443"}, "collides with port 443"},
"same binds": {Options{Domain: "example.com", WebmailBind: "127.0.0.1:9000", StalwartBind: "127.0.0.1:9000"}, "are both"},
"subnet too small": {Options{Domain: "example.com", Subnet: "10.0.0.0/29"}, "--subnet"},
"subnet not a network": {Options{Domain: "example.com", Subnet: "10.0.0.5/24"}, "--subnet"},
"acme flags with local": {Options{Local: true, ACMEDirectory: "https://ca.internal/dir"}, "no effect with --local"},
"acme over http": {Options{Domain: "example.com", ACMEDirectory: "http://ca.internal/dir"}, "must be an https URL"},
"image with a space": {Options{Domain: "example.com", CaddyImage: "caddy 2"}, "--caddy-image"},
} {
t.Run(name, func(t *testing.T) {
_, err := tc.o.Validate()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want it to mention %q", err, tc.want)
}
})
}
}
// A bad domain is one mistake, not a cascade of defaults built from it.
func TestBadDomainIsReportedOnce(t *testing.T) {
_, err := Options{Domain: "bad domain"}.Validate()
if err == nil {
t.Fatal("no error")
}
if lines := strings.Split(err.Error(), "\n"); len(lines) != 1 {
t.Errorf("got %d errors: %v", len(lines), err)
}
}
func TestEveryProblemAtOnce(t *testing.T) {
_, err := Options{Domain: "example.com", MailHost: "mx.other.net", Users: []string{"bob smith"}, WebmailBind: "x"}.Validate()
if err == nil {
t.Fatal("no error")
}
if lines := strings.Split(err.Error(), "\n"); len(lines) != 3 {
t.Errorf("got %d errors, want 3: %v", len(lines), err)
}
}
+600
View File
@@ -0,0 +1,600 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
// Package deploy is the one shot: preflight, write the directory, bootstrap
// Stalwart, bring the stack up, link and verify it.
package deploy
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
"github.com/Coffey-Labs/ihasmail-oneshot/internal/docker"
"github.com/Coffey-Labs/ihasmail-oneshot/internal/render"
"github.com/Coffey-Labs/ihasmail-oneshot/internal/stalwart"
"github.com/Coffey-Labs/ihasmail-oneshot/internal/webmail"
)
// Log is where progress goes. Each step says what it is doing before it does
// it, and every wait longer than a few seconds says so while it waits.
type Log struct{ W io.Writer }
func (l Log) Step(format string, a ...any) { fmt.Fprintf(l.W, "==> "+format+"\n", a...) }
func (l Log) Info(format string, a ...any) { fmt.Fprintf(l.W, " "+format+"\n", a...) }
func (l Log) Warn(format string, a ...any) { fmt.Fprintf(l.W, "!! "+format+"\n", a...) }
// Preflight checks everything that can be checked without changing anything.
// Warnings are things that will not stop the deployment but will stop it
// being useful until they are fixed, like DNS that does not point here yet.
func Preflight(ctx context.Context, p config.Plan, log Log) (warnings []string, err error) {
engine, compose, err := docker.Versions(ctx)
if err != nil {
return nil, err
}
log.Info("docker %s, compose %s", engine, compose)
var problems []error
if leftovers, err := docker.ProjectLeftovers(ctx, p.Project); err != nil {
problems = append(problems, err)
} else if len(leftovers) > 0 {
problems = append(problems, fmt.Errorf("project %s already exists in Docker (%s); destroy it first or choose another --project", p.Project, strings.Join(leftovers, ", ")))
}
if entries, err := os.ReadDir(p.Dir); err == nil && len(entries) > 0 {
problems = append(problems, fmt.Errorf("%s already has files in it; give --dir a new or empty directory", p.Dir))
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
problems = append(problems, err)
}
if p.ACMECARoot != "" {
if _, err := os.Stat(p.ACMECARoot); err != nil {
problems = append(problems, fmt.Errorf("--acme-ca-root: %w", err))
}
}
addrs := []string{p.WebmailBind, p.StalwartBind}
for _, port := range p.PublishedPorts() {
addrs = append(addrs, fmt.Sprintf(":%d", port))
}
for _, a := range addrs {
if err := portFree(a); err != nil {
problems = append(problems, err)
}
}
if !p.Local {
for _, host := range []string{p.WebmailHost, p.MailHost} {
if ips, err := net.DefaultResolver.LookupHost(ctx, host); err != nil || len(ips) == 0 {
warnings = append(warnings, fmt.Sprintf("%s does not resolve yet: its certificate cannot be issued until it points at this host", host))
}
}
}
return warnings, errors.Join(problems...)
}
// portFree tries to bind an address. A permission error means an unprivileged
// user asking about a low port, which says nothing about whether Docker can
// have it, so it is not reported.
func portFree(addr string) error {
l, err := net.Listen("tcp", addr)
if err == nil {
return l.Close()
}
if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) {
return nil
}
if errors.Is(err, syscall.EADDRINUSE) {
return fmt.Errorf("port %s is already in use on this host", strings.TrimPrefix(addr, ":"))
}
return fmt.Errorf("cannot bind %s: %w", addr, err)
}
// Result is what a successful deployment reports.
type Result struct {
Admin stalwart.Admin
Mailboxes map[string]string
IhasmailVersion string
AdminInWebmail bool
Certificate *stalwart.Certificate
// Caddy's certificates, by hostname, as the issuer's name. A name missing
// here had none by the time the tool stopped waiting.
CaddyCertificates map[string]string
}
// Deploy runs the whole thing. On an error after containers exist, it leaves
// them as they are for inspection and says how to start over.
func Deploy(ctx context.Context, p config.Plan, version string, log Log) (*Result, error) {
log.Step("writing %s", p.Dir)
if err := render.PrepareDir(p.Dir); err != nil {
return nil, err
}
appSecret, err := randomBase64(48)
if err != nil {
return nil, err
}
bootPassword := randomPassword(32)
caBundle := p.ACMECARoot != ""
composeYAML, err := render.Compose(p, version, caBundle)
if err != nil {
return nil, err
}
if err := render.WriteFile(p.Dir, render.ComposeFile, composeYAML, false); err != nil {
return nil, err
}
if err := render.WriteFile(p.Dir, render.EnvFile, render.Env(appSecret), true); err != nil {
return nil, err
}
if !p.Local {
caddyfile, err := render.Caddy(p, version)
if err != nil {
return nil, err
}
if err := render.WriteFile(p.Dir, render.Caddyfile, caddyfile, false); err != nil {
return nil, err
}
}
compose := docker.Compose{Dir: p.Dir, Out: indent(log.W)}
log.Step("pulling images")
if err := compose.Run(ctx, "pull", "--quiet"); err != nil {
return nil, err
}
if caBundle {
root, err := os.ReadFile(p.ACMECARoot)
if err != nil {
return nil, err
}
system, err := docker.SystemCABundle(ctx, p.StalwartImage)
if err != nil {
return nil, fmt.Errorf("reading the CA bundle out of %s: %w", p.StalwartImage, err)
}
if err := render.WriteFile(p.Dir, render.CARootFile, root, false); err != nil {
return nil, err
}
if err := render.WriteFile(p.Dir, render.CABundleFile, append(system, root...), false); err != nil {
return nil, err
}
}
// From here on there are containers, so a failure says how to clear them.
res, err := bringUp(ctx, p, compose, bootPassword, log)
if err != nil {
return res, fmt.Errorf("%w\n\nThe stack is left as it is, to look at. To start again from nothing:\n ihasmail-oneshot destroy --dir %s --yes", err, p.Dir)
}
return res, nil
}
func bringUp(ctx context.Context, p config.Plan, compose docker.Compose, bootPassword string, log Log) (*Result, error) {
stalwartURL := "http://" + p.StalwartBind
res := &Result{Mailboxes: map[string]string{}, CaddyCertificates: map[string]string{}}
// --- bootstrap -----------------------------------------------------------
// The bootstrap account comes from an override file that lives only for
// this step, and its password only in this process's environment. Bringing
// the stack up afterwards without the override recreates Stalwart without
// the variable, so no fixed recovery credential outlives the setup.
override, err := os.CreateTemp("", "ihasmail-oneshot-bootstrap-*.yaml")
if err != nil {
return nil, err
}
defer os.Remove(override.Name())
if _, err := override.WriteString("services:\n stalwart:\n environment:\n STALWART_RECOVERY_ADMIN: ${ONESHOT_BOOTSTRAP_ADMIN:?}\n"); err != nil {
return nil, err
}
override.Close()
log.Step("starting Stalwart in bootstrap mode")
boot := compose
boot.Files = []string{override.Name()}
boot.Env = []string{"ONESHOT_BOOTSTRAP_ADMIN=admin:" + bootPassword}
if err := boot.Run(ctx, "up", "-d", "stalwart"); err != nil {
return nil, err
}
if err := waitFor(ctx, log, "Stalwart", 90*time.Second, func(ctx context.Context) error {
return stalwart.Live(ctx, stalwartURL)
}); err != nil {
return nil, withLogs(ctx, err, compose, "stalwart")
}
bootClient := &stalwart.Client{BaseURL: stalwartURL, Username: "admin", Password: bootPassword}
if err := bootClient.CheckBootstrapMode(ctx); err != nil {
return nil, err
}
log.Step("setting up Stalwart for %s (hostname %s)", p.Domain, p.MailHost)
admin, err := bootClient.Bootstrap(ctx, p.MailHost, p.Domain)
if err != nil {
return nil, fmt.Errorf("bootstrap: %w", err)
}
res.Admin = admin
// Written now, not at the end: from this moment the password exists nowhere
// else, and a failure in a later step must not lose it.
if err := writeCredentials(p, admin); err != nil {
return res, err
}
log.Info("administrator %s, password in %s", admin.Username, render.CredentialsFile)
// --- the whole stack -----------------------------------------------------
log.Step("starting the stack")
if err := compose.Run(ctx, "up", "-d"); err != nil {
return res, err
}
sw := &stalwart.Client{BaseURL: stalwartURL, Username: admin.Username, Password: admin.Secret}
if err := waitFor(ctx, log, "Stalwart to restart configured", 90*time.Second, func(ctx context.Context) error {
_, err := sw.DomainID(ctx, p.Domain)
return err
}); err != nil {
return res, withLogs(ctx, err, compose, "stalwart")
}
domainID, err := sw.DomainID(ctx, p.Domain)
if err != nil {
return res, err
}
// --- linking -------------------------------------------------------------
log.Step("linking ihasmail and Stalwart")
// Every request the webmail makes reaches Stalwart from ihasmail's one
// address -- every sign-in, every push stream opened and dropped as tabs
// come and go. Stalwart bans per address, so a ban on that one would be a
// ban on everybody's webmail. ihasmail rate-limits sign-ins per real client
// itself. (Checked on 0.16.22: failed sign-ins were refused per session but
// never became an address ban; scans and dropped connections are counted,
// and this is not a limit worth finding by losing the webmail to it.)
if err := sw.AllowIP(ctx, p.IhasmailIP.String(), "ihasmail: every webmail request arrives from this address"); err != nil {
return res, fmt.Errorf("exempting ihasmail from the auto-ban: %w", err)
}
log.Info("ihasmail (%s) exempt from Stalwart's auto-ban", p.IhasmailIP)
if !p.Local {
if err := sw.TrustForwardedFor(ctx); err != nil {
return res, fmt.Errorf("trusting Caddy's X-Forwarded-For: %w", err)
}
log.Info("Stalwart takes client addresses from Caddy's X-Forwarded-For")
}
// Neither setting takes effect on a running Stalwart: on 0.16.22 a scan
// through Caddy straight after setting them still banned Caddy, and the
// same scan after a restart banned the scanner. Nor does lifting a ban.
log.Info("restarting Stalwart to apply them")
if err := compose.Run(ctx, "restart", "stalwart"); err != nil {
return res, err
}
if err := waitFor(ctx, log, "Stalwart to restart", 90*time.Second, func(ctx context.Context) error {
_, err := sw.DomainID(ctx, p.Domain)
return err
}); err != nil {
return res, withLogs(ctx, err, compose, "stalwart")
}
for _, name := range p.Users {
password := randomPassword(24)
if _, err := sw.CreateUser(ctx, name, domainID, password); err != nil {
return res, fmt.Errorf("creating mailbox %s@%s: %w", name, p.Domain, err)
}
address := name + "@" + p.Domain
res.Mailboxes[address] = password
if err := render.AppendFile(p.Dir, render.CredentialsFile, []byte(fmt.Sprintf("mailbox %s = %s\n", address, password))); err != nil {
return res, err
}
log.Info("mailbox %s created", address)
}
webmailURL := "http://" + p.WebmailBind
if err := waitFor(ctx, log, "ihasmail", 60*time.Second, func(ctx context.Context) error {
h, err := webmail.CheckHealth(ctx, webmailURL)
res.IhasmailVersion = h.Version
return err
}); err != nil {
return res, withLogs(ctx, err, compose, "ihasmail")
}
// Retried briefly: ihasmail can report healthy a moment before its first
// session discovery against a Stalwart that has only just restarted.
if err := waitFor(ctx, log, "a sign-in through ihasmail", 30*time.Second, func(ctx context.Context) error {
ok, err := webmail.SignIn(ctx, webmailURL, admin.Username, admin.Secret)
res.AdminInWebmail = ok
return err
}); err != nil {
return res, withLogs(ctx, err, compose, "ihasmail")
}
log.Info("signed in to ihasmail %s as %s: linked", res.IhasmailVersion, admin.Username)
if p.Local {
return res, nil
}
// --- certificates and DNS -----------------------------------------------
log.Step("requesting Stalwart's certificate")
if err := waitFor(ctx, log, "Caddy", 30*time.Second, func(ctx context.Context) error {
if !compose.Running(ctx, "caddy") {
return errors.New("not running")
}
return nil
}); err != nil {
return res, withLogs(ctx, err, compose, "caddy")
}
if _, err := sw.EnableACME(ctx, domainID, p.ACMEDirectory, p.Email); err != nil {
return res, fmt.Errorf("enabling ACME: %w", err)
}
// Not an error if it does not arrive: DNS that does not point here yet is
// the usual reason, and the fix is DNS and then `certs`, not a redeploy.
_ = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error {
certs, err := sw.Certificates(ctx)
if err != nil {
return err
}
for _, c := range certs {
if c.SubjectAlternativeNames[p.MailHost] {
res.Certificate = &c
return nil
}
}
return errors.New("not issued yet")
})
// Caddy obtains its own in the background. Waited for, so that "done"
// means the HTTPS it prints works -- and, like Stalwart's, not an error
// when it does not arrive.
for _, host := range []string{p.WebmailHost, p.MailHost} {
_ = waitFor(ctx, log, "Caddy's certificate for "+host, 60*time.Second, func(ctx context.Context) error {
issuer, err := servedCertificate(ctx, host)
if err == nil {
res.CaddyCertificates[host] = issuer
}
return err
})
}
zone, err := sw.DNSZone(ctx, domainID)
if err != nil {
return res, fmt.Errorf("reading the DNS records: %w", err)
}
if err := render.WriteFile(p.Dir, render.DNSFile, []byte(dnsFile(p, zone)), false); err != nil {
return res, err
}
return res, nil
}
// servedCertificate reports the issuer of the certificate Caddy presents for
// host on this machine's port 443, without trusting it: the question is
// whether Caddy has one yet, not whether this host trusts the CA. Until it has
// one the handshake fails outright.
func servedCertificate(ctx context.Context, host string) (string, error) {
d := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
conn, err := d.DialContext(ctx, "tcp", "127.0.0.1:443")
if err != nil {
return "", err
}
defer conn.Close()
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
if len(certs) == 0 {
return "", errors.New("no certificate presented")
}
if err := certs[0].VerifyHostname(host); err != nil {
return "", err
}
return certs[0].Issuer.String(), nil
}
// waitFor retries check until it passes or timeout passes, saying every ten
// seconds that it is still waiting and what the last answer was.
func waitFor(ctx context.Context, log Log, what string, timeout time.Duration, check func(context.Context) error) error {
start := time.Now()
deadline := start.Add(timeout)
lastReport := start
for {
attempt, cancel := context.WithTimeout(ctx, 10*time.Second)
err := check(attempt)
cancel()
if err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("gave up waiting for %s after %s: %w", what, timeout, err)
}
if time.Since(lastReport) >= 10*time.Second {
log.Info("still waiting for %s (%s): %v", what, time.Since(start).Round(time.Second), err)
lastReport = time.Now()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
}
func withLogs(ctx context.Context, err error, compose docker.Compose, service string) error {
return fmt.Errorf("%w\n\nlast lines of %s's log:\n%s", err, service, compose.Logs(ctx, service, 25))
}
func writeCredentials(p config.Plan, a stalwart.Admin) error {
var b strings.Builder
fmt.Fprintf(&b, "# ihasmail-oneshot credentials for %s, written %s.\n", p.Domain, time.Now().UTC().Format(time.RFC3339))
b.WriteString("# Keep this file private. The administrator can sign in to the webmail too,\n")
b.WriteString("# and to Stalwart's own admin UI. Change the passwords after first sign-in.\n")
fmt.Fprintf(&b, "domain = %s\n", p.Domain)
fmt.Fprintf(&b, "mail_host = %s\n", p.MailHost)
fmt.Fprintf(&b, "stalwart_url = http://%s\n", p.StalwartBind)
fmt.Fprintf(&b, "admin = %s\n", a.Username)
fmt.Fprintf(&b, "admin_password = %s\n", a.Secret)
return render.WriteFile(p.Dir, render.CredentialsFile, []byte(b.String()), true)
}
func dnsFile(p config.Plan, zone string) string {
var b strings.Builder
fmt.Fprintf(&b, "; DNS records for %s, from Stalwart. Publish all of them.\n", p.Domain)
b.WriteString(";\n; Two more that Stalwart cannot know, because they are this host's address:\n")
for _, h := range []string{p.MailHost, p.WebmailHost} {
fmt.Fprintf(&b, "; %s. IN A <this host's IPv4 address>\n", h)
fmt.Fprintf(&b, "; %s. IN AAAA <this host's IPv6 address, if it has one>\n", h)
}
fmt.Fprintf(&b, ";\n; And one at your hosting provider rather than in this zone: reverse DNS (PTR)\n; for this host's address, pointing at %s.\n\n", p.MailHost)
b.WriteString(zone)
if !strings.HasSuffix(zone, "\n") {
b.WriteString("\n")
}
return b.String()
}
// Destroy removes the stack, its volumes, and the files the tool wrote. Mail,
// accounts and certificates go with the volumes; that is the point of it.
func Destroy(ctx context.Context, dir string, log Log) error {
if _, err := os.Stat(filepath.Join(dir, render.ComposeFile)); err != nil {
return fmt.Errorf("%s is not a deployment directory: %w", dir, err)
}
log.Step("removing containers, networks and volumes")
compose := docker.Compose{Dir: dir, Out: indent(log.W)}
// APP_SECRET is required by compose.yaml's interpolation, and a missing
// .env must not make the stack impossible to remove.
compose.Env = []string{"APP_SECRET=unused-by-down"}
if err := compose.Run(ctx, "down", "--volumes", "--remove-orphans"); err != nil {
return err
}
log.Step("removing the files it wrote")
for _, name := range render.Written {
if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := os.Remove(dir); err != nil {
log.Info("left %s in place: it holds files this tool did not write", dir)
}
return nil
}
// Certs starts a new certificate order, for after DNS has been fixed.
func Certs(ctx context.Context, dir string, log Log) error {
creds, err := ReadCredentials(filepath.Join(dir, render.CredentialsFile))
if err != nil {
return err
}
sw := &stalwart.Client{BaseURL: creds["stalwart_url"], Username: creds["admin"], Password: creds["admin_password"]}
domainID, err := sw.DomainID(ctx, creds["domain"])
if err != nil {
return err
}
covering := func(ctx context.Context) (*stalwart.Certificate, error) {
certs, err := sw.Certificates(ctx)
if err != nil {
return nil, err
}
for _, c := range certs {
if c.SubjectAlternativeNames[creds["mail_host"]] {
return &c, nil
}
}
return nil, nil
}
// A new order while a certificate is still valid is refused by Stalwart as
// "renewal not due", so asking would only look like it had done something.
if c, err := covering(ctx); err != nil {
return err
} else if c != nil {
log.Info("Stalwart already holds a certificate for %s, issued by %s, valid until %s", creds["mail_host"], c.Issuer, c.NotValidAfter)
return nil
}
log.Step("starting a new certificate order for %s", creds["domain"])
if err := sw.RetryCertificates(ctx, domainID); err != nil {
return err
}
var got *stalwart.Certificate
err = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error {
c, err := covering(ctx)
if err == nil && c == nil {
err = errors.New("not issued yet")
}
got = c
return err
})
if err != nil {
return fmt.Errorf("%w\n Stalwart's log says why: docker compose --project-directory %s logs stalwart | grep -i acme", err, dir)
}
log.Info("issued by %s, valid until %s", got.Issuer, got.NotValidAfter)
return nil
}
// ReadCredentials parses credentials.txt's "key = value" lines.
func ReadCredentials(path string) (map[string]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
out := map[string]string{}
for _, line := range strings.Split(string(raw), "\n") {
if line = strings.TrimSpace(line); line == "" || strings.HasPrefix(line, "#") {
continue
}
if k, v, ok := strings.Cut(line, " = "); ok {
out[k] = v
}
}
for _, k := range []string{"domain", "mail_host", "stalwart_url", "admin", "admin_password"} {
if out[k] == "" {
return nil, fmt.Errorf("%s has no %s", path, k)
}
}
return out, nil
}
func randomBase64(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
// randomPassword is letters and digits only, so it survives YAML, an env file,
// a shell and being read aloud without any quoting.
func randomPassword(n int) string {
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, n)
buf := make([]byte, 1)
for i := 0; i < n; {
if _, err := rand.Read(buf); err != nil {
panic(err) // crypto/rand.Read does not fail on supported platforms
}
// Rejection sampling keeps every character equally likely.
if int(buf[0]) < 256-256%len(alphabet) {
out[i] = alphabet[int(buf[0])%len(alphabet)]
i++
}
}
return string(out)
}
type indentWriter struct {
w io.Writer
bol bool
}
func (iw *indentWriter) Write(p []byte) (int, error) {
for _, c := range p {
if iw.bol {
if _, err := iw.w.Write([]byte(" ")); err != nil {
return 0, err
}
}
if _, err := iw.w.Write([]byte{c}); err != nil {
return 0, err
}
iw.bol = c == '\n'
}
return len(p), nil
}
// indent passes docker's own output through, indented under the step it
// belongs to.
func indent(w io.Writer) io.Writer { return &indentWriter{w: w, bol: true} }
+140
View File
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
// Package docker drives the docker CLI. The CLI rather than the Engine API,
// because compose is the thing being driven and the CLI is how it ships: the
// deployment the tool leaves behind is one an operator manages with the same
// commands.
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// Output runs docker with args and returns its standard output.
func Output(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("docker %s: %s", strings.Join(args, " "), msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// Versions returns the engine and compose versions, which is also the check
// that both are installed and this user may use them.
func Versions(ctx context.Context) (engine, compose string, err error) {
if _, err := exec.LookPath("docker"); err != nil {
return "", "", errors.New("docker is not installed, or not on PATH")
}
if engine, err = Output(ctx, "version", "--format", "{{.Server.Version}}"); err != nil {
return "", "", fmt.Errorf("cannot talk to the Docker daemon -- is it running, and may this user use it? (%w)", err)
}
if compose, err = Output(ctx, "compose", "version", "--short"); err != nil {
return engine, "", fmt.Errorf("the docker compose plugin is not installed (%w)", err)
}
return engine, compose, nil
}
// ProjectLeftovers lists containers, volumes and networks already labelled
// with a compose project name. Any at all means an earlier run of the same
// project, and its volumes would hand a "fresh" deployment an old server.
func ProjectLeftovers(ctx context.Context, project string) ([]string, error) {
filter := "label=com.docker.compose.project=" + project
var found []string
for _, kind := range []struct{ name, format string }{
{"container", "{{.Names}}"},
{"volume", "{{.Name}}"},
{"network", "{{.Name}}"},
} {
args := []string{kind.name, "ls", "--filter", filter, "--format", kind.format}
if kind.name == "container" {
args = []string{"ps", "-a", "--filter", filter, "--format", kind.format}
}
out, err := Output(ctx, args...)
if err != nil {
return nil, err
}
for _, line := range strings.Fields(out) {
found = append(found, kind.name+" "+line)
}
}
return found, nil
}
// Compose runs docker compose against one deployment directory.
type Compose struct {
Dir string
Files []string // extra -f files after compose.yaml, e.g. the bootstrap override
Env []string // added to the environment compose interpolates from
Out io.Writer
}
// Run runs a compose command with its output passed through: pulling images
// takes long enough that silence would look like a hang. Everything but a pull
// is quiet, because without a terminal compose prints each container's every
// state change twice and the tool already says what step it is on.
func (c Compose) Run(ctx context.Context, args ...string) error {
full := []string{"compose", "--project-directory", c.Dir, "-f", c.Dir + "/compose.yaml"}
if len(args) > 0 && args[0] != "pull" {
full = append(full, "--progress", "quiet")
}
for _, f := range c.Files {
full = append(full, "-f", f)
}
full = append(full, args...)
cmd := exec.CommandContext(ctx, "docker", full...)
cmd.Env = append(os.Environ(), c.Env...)
cmd.Stdout, cmd.Stderr = c.Out, c.Out
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker compose %s: %w", strings.Join(args, " "), err)
}
return nil
}
// Running reports whether a service has a running container.
func (c Compose) Running(ctx context.Context, service string) bool {
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
"ps", "--status", "running", "--services")
if err != nil {
return false
}
for _, s := range strings.Fields(out) {
if s == service {
return true
}
}
return false
}
// Logs returns the last lines of one service's log, for a failure report.
func (c Compose) Logs(ctx context.Context, service string, lines int) string {
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
"logs", "--no-color", "--tail", fmt.Sprint(lines), service)
if err != nil {
return err.Error()
}
return out
}
// SystemCABundle reads the CA bundle out of an image, so a private CA can be
// added to the roots the image already trusts rather than replacing them.
func SystemCABundle(ctx context.Context, image string) ([]byte, error) {
out, err := Output(ctx, "run", "--rm", "--entrypoint", "cat", image, "/etc/ssl/certs/ca-certificates.crt")
if err != nil {
return nil, err
}
return []byte(out + "\n"), nil
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
// Package render writes the deployment directory: compose.yaml, the Caddyfile,
// and the files holding secrets.
package render
import (
"bytes"
"embed"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
)
//go:embed templates/*.tmpl
var templates embed.FS
var tmpl = template.Must(template.New("").Funcs(template.FuncMap{
"join": strings.Join,
}).ParseFS(templates, "templates/*.tmpl"))
// Files the tool writes into a deployment directory, and nothing else. Destroy
// removes exactly these, so a file the operator added survives it.
const (
ComposeFile = "compose.yaml"
Caddyfile = "Caddyfile"
EnvFile = ".env"
CredentialsFile = "credentials.txt"
DNSFile = "dns-records.zone"
CARootFile = "acme-ca-root.pem"
CABundleFile = "ca-bundle.crt"
)
// Written lists every file name Destroy may remove.
var Written = []string{ComposeFile, Caddyfile, EnvFile, CredentialsFile, DNSFile, CARootFile, CABundleFile}
type data struct {
Version string
Plan config.Plan
CABundle bool
}
// Compose renders compose.yaml.
func Compose(p config.Plan, version string, caBundle bool) ([]byte, error) {
return execute("compose.yaml.tmpl", data{Version: version, Plan: p, CABundle: caBundle})
}
// Caddy renders the Caddyfile.
func Caddy(p config.Plan, version string) ([]byte, error) {
return execute("Caddyfile.tmpl", data{Version: version, Plan: p})
}
func execute(name string, d data) ([]byte, error) {
var b bytes.Buffer
if err := tmpl.ExecuteTemplate(&b, name, d); err != nil {
return nil, fmt.Errorf("render %s: %w", name, err)
}
return b.Bytes(), nil
}
// Env renders .env. Only the app secret lives here: compose.yaml reads it by
// interpolation, so the file compose.yaml sits in can be shown to someone
// without showing them the key every session is sealed with.
func Env(appSecret string) []byte {
return []byte("# Read by docker compose. Changing APP_SECRET signs everyone out.\nAPP_SECRET=" + appSecret + "\n")
}
// PrepareDir creates dir, or accepts it if it exists and is empty. Anything in
// it already is refused: the tool writes a fresh deployment, and silently
// replacing someone's compose.yaml is not a fresh deployment.
func PrepareDir(dir string) error {
entries, err := os.ReadDir(dir)
switch {
case errors.Is(err, os.ErrNotExist):
return os.MkdirAll(dir, 0o750)
case err != nil:
return err
case len(entries) > 0:
return fmt.Errorf("%s already has files in it; give --dir a new or empty directory", dir)
}
return nil
}
// WriteFile writes one file into dir, private if it holds a secret. It refuses
// to replace an existing file, for the same reason PrepareDir refuses a full
// directory.
func WriteFile(dir, name string, content []byte, secret bool) error {
mode := os.FileMode(0o644)
if secret {
mode = 0o600
}
f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
if _, err := f.Write(content); err != nil {
f.Close()
return err
}
return f.Close()
}
// AppendFile adds to a file WriteFile created, keeping its mode.
func AppendFile(dir, name string, content []byte) error {
f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
return err
}
if _, err := f.Write(content); err != nil {
f.Close()
return err
}
return f.Close()
}
+122
View File
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package render
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
)
func plan(t *testing.T, o config.Options) config.Plan {
t.Helper()
p, err := o.Validate()
if err != nil {
t.Fatal(err)
}
return p
}
func TestPublicCompose(t *testing.T) {
p := plan(t, config.Options{Domain: "example.com"})
out, err := Compose(p, "test", false)
if err != nil {
t.Fatal(err)
}
s := string(out)
for _, want := range []string{
"name: ihasmail-example-com",
"hostname: mail.example.com",
`- "127.0.0.1:8081:8080"`,
`- "25:25"`, `- "465:465"`, `- "993:993"`, `- "995:995"`, `- "4190:4190"`,
`- "80:80"`, `- "443:443"`, `- "443:443/udp"`,
"STALWART_URL: http://stalwart:8080",
"APP_SECRET: ${APP_SECRET:?",
"PUSH_URL: https://webmail.example.com",
"read_only: true",
"ipv4_address: 172.31.253.11",
"caddy-data:",
} {
if !strings.Contains(s, want) {
t.Errorf("compose.yaml lacks %q:\n%s", want, s)
}
}
// Caddy's ports belong to Caddy; Stalwart must not also claim them.
stalwart := s[strings.Index(s, " stalwart:"):strings.Index(s, " ihasmail:")]
if strings.Contains(stalwart, `"80:80"`) || strings.Contains(stalwart, `"443:443"`) {
t.Errorf("Stalwart publishes 80 or 443:\n%s", stalwart)
}
if strings.Contains(s, "ca-bundle.crt") || strings.Contains(s, "acme-ca-root.pem") {
t.Error("CA files mounted with no private CA")
}
}
func TestLocalComposeHasNoCaddyAndNoMailPorts(t *testing.T) {
p := plan(t, config.Options{Local: true})
out, err := Compose(p, "test", false)
if err != nil {
t.Fatal(err)
}
s := string(out)
for _, unwanted := range []string{"caddy", `"25:25"`, "PUSH_URL"} {
if strings.Contains(s, unwanted) {
t.Errorf("local compose.yaml has %q:\n%s", unwanted, s)
}
}
}
func TestCaddyfile(t *testing.T) {
p := plan(t, config.Options{Domain: "example.com", MailHost: "mx.example.com", Email: "[email protected]"})
out, err := Caddy(p, "test")
if err != nil {
t.Fatal(err)
}
s := string(out)
for _, want := range []string{
"email [email protected]",
"webmail.example.com {",
"mx.example.com, autoconfig.example.com, autodiscover.example.com, mta-sts.example.com, ua-auto-config.example.com {",
"disable_http_challenge",
"http://mx.example.com, http://autoconfig.example.com, http://autodiscover.example.com, http://mta-sts.example.com, http://ua-auto-config.example.com {",
"handle /.well-known/acme-challenge/* {",
"flush_interval -1",
} {
if !strings.Contains(s, want) {
t.Errorf("Caddyfile lacks %q:\n%s", want, s)
}
}
if strings.Contains(s, "acme_ca") {
t.Error("Caddyfile names a CA without --acme-directory")
}
p = plan(t, config.Options{Domain: "example.com", ACMEDirectory: "https://ca.internal/dir", ACMECARoot: "/tmp/root.pem"})
out, _ = Caddy(p, "test")
for _, want := range []string{"acme_ca https://ca.internal/dir", "dir https://ca.internal/dir", "trusted_roots /etc/caddy/acme-ca-root.pem"} {
if !strings.Contains(string(out), want) {
t.Errorf("Caddyfile with a private CA lacks %q", want)
}
}
}
func TestDirectoryIsNeverOverwritten(t *testing.T) {
dir := filepath.Join(t.TempDir(), "d")
if err := PrepareDir(dir); err != nil {
t.Fatal(err)
}
if err := WriteFile(dir, EnvFile, []byte("x"), true); err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(filepath.Join(dir, EnvFile)); fi.Mode().Perm() != 0o600 {
t.Errorf("secret file mode %v", fi.Mode().Perm())
}
if err := WriteFile(dir, EnvFile, []byte("y"), true); err == nil {
t.Error("WriteFile replaced an existing file")
}
if err := PrepareDir(dir); err == nil {
t.Error("PrepareDir accepted a directory with files in it")
}
}
+59
View File
@@ -0,0 +1,59 @@
# Written by ihasmail-oneshot {{.Version}} for {{.Plan.Domain}}.
#
# Caddy holds ports 80 and 443 for two things that both want certificates for
# some of the same names: Caddy itself, to serve HTTPS, and Stalwart, whose
# IMAP and SMTP listeners need a certificate of their own. They are kept apart
# by challenge type rather than by name:
#
# Caddy TLS-ALPN-01 on 443 -- for Stalwart's names it never uses port 80.
# Stalwart HTTP-01 on 80, which Caddy forwards to it untouched.
#
# So neither answers the other's challenge, and neither needs the other's key.
{
email {{.Plan.Email}}
{{- if .Plan.ACMEDirectory}}
acme_ca {{.Plan.ACMEDirectory}}
{{- end}}
{{- if .Plan.ACMECARoot}}
acme_ca_root /etc/caddy/acme-ca-root.pem
{{- end}}
}
# The webmail. Push arrives as Server-Sent Events, so responses are flushed as
# they are written rather than buffered.
{{.Plan.WebmailHost}} {
encode zstd gzip
reverse_proxy ihasmail:8080 {
flush_interval -1
}
}
# Stalwart's web side: its admin UI, JMAP for other clients, CalDAV, CardDAV,
# autoconfig and MTA-STS. Stalwart is told to believe the X-Forwarded-For Caddy
# sets here, so a scanner is banned by its own address and not by Caddy's.
{{join .Plan.StalwartNames ", "}} {
tls {
issuer acme {
{{- if .Plan.ACMEDirectory}}
dir {{.Plan.ACMEDirectory}}
{{- end}}
{{- if .Plan.ACMECARoot}}
trusted_roots /etc/caddy/acme-ca-root.pem
{{- end}}
email {{.Plan.Email}}
disable_http_challenge
}
}
reverse_proxy stalwart:8080
}
# Port 80 for Stalwart's names is Stalwart's challenge path and a redirect.
{{range $i, $n := .Plan.StalwartNames}}{{if $i}}, {{end}}http://{{$n}}{{end}} {
handle /.well-known/acme-challenge/* {
reverse_proxy stalwart:8080
}
handle {
redir https://{host}{uri} 308
}
}
@@ -0,0 +1,94 @@
# Written by ihasmail-oneshot {{.Version}} for {{.Plan.Domain}}.
#
# This is the whole deployment: bring it up again with `docker compose up -d`
# from this directory. Secrets are in .env next to it, and the Stalwart
# administrator's password is in credentials.txt -- both readable only by you.
#
# Stalwart's plain-HTTP port is reachable only on the private network below and
# on {{.Plan.StalwartBind}}. ihasmail talks to it over that network, which is
# why STALWART_URL is http://: the leg never leaves this host.
name: {{.Plan.Project}}
services:
stalwart:
image: {{.Plan.StalwartImage}}
hostname: {{.Plan.MailHost}}
restart: unless-stopped
ports:
- "{{.Plan.StalwartBind}}:8080"
{{- range .Plan.PublishedPorts}}{{if and (ne . 80) (ne . 443)}}
- "{{.}}:{{.}}"
{{- end}}{{end}}
volumes:
- stalwart-etc:/etc/stalwart
- stalwart-data:/var/lib/stalwart
{{- if .CABundle}}
# The system roots plus the private ACME CA, so Stalwart can reach it.
- ./ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro
{{- end}}
networks:
stack:
ipv4_address: {{.Plan.StalwartIP}}
ihasmail:
image: {{.Plan.IhasmailImage}}
restart: unless-stopped
depends_on: [stalwart]
# Immutable: read-only root, no volume, sessions in memory. A restart signs
# everyone out; nothing else is lost, because nothing else is kept here.
read_only: true
tmpfs: [/tmp]
ports:
- "{{.Plan.WebmailBind}}:8080"
environment:
STALWART_URL: http://stalwart:8080
APP_SECRET: ${APP_SECRET:?APP_SECRET is missing from .env}
IMMUTABLE: "1"
SESSION_FILE: ""
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
{{- if not .Plan.Local}}
# Stalwart pushes changes to this URL instead of holding a connection per
# tab. If it cannot reach it, every tab uses the relay; nothing breaks.
PUSH_URL: https://{{.Plan.WebmailHost}}
{{- end}}
networks:
stack:
ipv4_address: {{.Plan.IhasmailIP}}
{{- if not .Plan.Local}}
caddy:
image: {{.Plan.CaddyImage}}
restart: unless-stopped
depends_on: [ihasmail, stalwart]
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
{{- if .Plan.ACMECARoot}}
- ./acme-ca-root.pem:/etc/caddy/acme-ca-root.pem:ro
{{- end}}
networks:
stack:
ipv4_address: {{.Plan.CaddyIP}}
{{- end}}
networks:
stack:
ipam:
config:
- subnet: {{.Plan.Subnet}}
volumes:
stalwart-etc:
stalwart-data:
{{- if not .Plan.Local}}
# Certificates and the ACME account. Losing this means asking for every
# certificate again, which is how rate limits are reached.
caddy-data:
caddy-config:
{{- end}}
+230
View File
@@ -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
}
+124
View File
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package stalwart
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fake answers each request with the response registered for its first
// method, and records what it was sent.
func fake(t *testing.T, responses map[string]string) (*Client, *[]map[string]any) {
t.Helper()
var seen []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, p, _ := r.BasicAuth(); u != "admin" || p != "pw" {
w.WriteHeader(http.StatusUnauthorized)
return
}
var req struct {
Using []string `json:"using"`
MethodCalls [][3]json.RawMessage `json:"methodCalls"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("bad request body: %v", err)
}
var method string
_ = json.Unmarshal(req.MethodCalls[0][0], &method)
var args map[string]any
_ = json.Unmarshal(req.MethodCalls[0][1], &args)
seen = append(seen, map[string]any{"method": method, "args": args, "using": req.Using})
body, ok := responses[method]
if !ok {
t.Errorf("unexpected method %s", method)
}
w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return &Client{BaseURL: srv.URL, Username: "admin", Password: "pw"}, &seen
}
func TestBootstrapReturnsTheAdministrator(t *testing.T) {
c, seen := fake(t, map[string]string{
"x:Bootstrap/set": `{"methodResponses":[["x:Bootstrap/set",{"updated":{"singleton":{"username":"[email protected]","secret":"s3cret"}}},"0"]]}`,
})
a, err := c.Bootstrap(context.Background(), "mail.example.com", "example.com")
if err != nil {
t.Fatal(err)
}
if a.Username != "[email protected]" || a.Secret != "s3cret" {
t.Errorf("admin = %+v", a)
}
update := (*seen)[0]["args"].(map[string]any)["update"].(map[string]any)["singleton"].(map[string]any)
if update["requestTlsCertificate"] != false || update["tracer"].(map[string]any)["@type"] != "Stdout" {
t.Errorf("bootstrap sent %v", update)
}
if using := (*seen)[0]["using"].([]string); len(using) != 2 || using[1] != "urn:stalwart:jmap" {
t.Errorf("using = %v", using)
}
}
func TestSetRefusalIsAnError(t *testing.T) {
c, _ := fake(t, map[string]string{
"x:Account/set": `{"methodResponses":[["x:Account/set",{"notCreated":{"user":{"type":"invalidPatch","description":"Missing or invalid '@type' property in object","properties":["roles"]}}},"0"]]}`,
})
_, err := c.CreateUser(context.Background(), "alice", "b", "pw")
if err == nil || !strings.Contains(err.Error(), "invalidPatch") || !strings.Contains(err.Error(), "roles") {
t.Fatalf("err = %v", err)
}
}
func TestMethodErrorNamesTheMethod(t *testing.T) {
c, _ := fake(t, map[string]string{
"x:Bootstrap/get": `{"methodResponses":[["error",{"type":"unknownMethod"},"0"]]}`,
})
err := c.CheckBootstrapMode(context.Background())
var me *MethodError
if !errors.As(err, &me) || me.Method != "x:Bootstrap/get" || me.Type != "unknownMethod" {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "not in bootstrap mode") {
t.Errorf("err = %v", err)
}
}
func TestConfiguredServerRefusesBootstrapCredentials(t *testing.T) {
c, _ := fake(t, nil)
c.Password = "the-bootstrap-password"
if err := c.CheckBootstrapMode(context.Background()); err == nil || !strings.Contains(err.Error(), "not in bootstrap mode") {
t.Fatalf("err = %v", err)
}
}
func TestEnableACMEUsesHTTP01AndTheBackReference(t *testing.T) {
c, seen := fake(t, map[string]string{
"x:AcmeProvider/set": `{"methodResponses":[["x:AcmeProvider/set",{"created":{"acme":{"id":"p1"}}},"0"],["x:Domain/set",{"updated":{"b":null}},"1"]]}`,
})
id, err := c.EnableACME(context.Background(), "b", "", "[email protected]")
if err != nil || id != "p1" {
t.Fatalf("id %q, err %v", id, err)
}
provider := (*seen)[0]["args"].(map[string]any)["create"].(map[string]any)["acme"].(map[string]any)
if provider["challengeType"] != "Http01" {
t.Errorf("challengeType = %v", provider["challengeType"])
}
if _, set := provider["directory"]; set {
t.Error("directory sent without --acme-directory; Stalwart's default is Let's Encrypt")
}
}
func TestDomainIDNotFound(t *testing.T) {
c, _ := fake(t, map[string]string{
"x:Domain/query": `{"methodResponses":[["x:Domain/query",{"ids":["b"]},"0"],["x:Domain/get",{"list":[{"id":"b","name":"other.test"}]},"1"]]}`,
})
if _, err := c.DomainID(context.Background(), "example.com"); err == nil {
t.Fatal("found a domain that is not there")
}
}
+289
View File
@@ -0,0 +1,289 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package stalwart
import (
"context"
"encoding/json"
"errors"
"fmt"
)
// Admin is the permanent administrator bootstrap provisions.
type Admin struct {
Username string `json:"username"`
Secret string `json:"secret"`
}
// CheckBootstrapMode confirms the server is fresh. x:Bootstrap exists only in
// bootstrap mode, so a server that has been set up before -- a volume left
// over from an earlier run, say -- refuses the call, and the tool stops before
// writing anything into someone's configured mail server.
func (c *Client) CheckBootstrapMode(ctx context.Context) error {
_, err := c.Do(ctx, Call{"x:Bootstrap/get", map[string]any{"ids": []string{"singleton"}, "properties": []string{"id"}}, "0"})
var me *MethodError
var he *HTTPError
// A configured server either refuses the method or, having no bootstrap
// account any more, the credentials.
if errors.As(err, &me) || (errors.As(err, &he) && he.Status == 401) {
return fmt.Errorf("this Stalwart is not in bootstrap mode, so it has been configured before (%w)", err)
}
return err
}
// Bootstrap completes the setup wizard the web UI would otherwise walk someone
// through, and returns the administrator it creates. The temporary bootstrap
// account stops working once the server restarts out of bootstrap mode.
func (c *Client) Bootstrap(ctx context.Context, hostname, domain string) (Admin, error) {
update := map[string]any{
"serverHostname": hostname,
"defaultDomain": domain,
// Off, and done explicitly afterwards (see EnableACME): on 0.16.22 this
// flag creates no ACME provider and leaves the domain on manual
// certificates, so turning it on would only look like it had worked.
"requestTlsCertificate": false,
"generateDkimKeys": true,
// The default logs to /var/log/stalwart, which does not exist in the
// image and is not a volume. A container logs to stdout.
"tracer": map[string]any{
"@type": "Stdout", "enable": true, "level": "info",
"ansi": false, "multiline": false, "lossy": false,
"events": map[string]any{}, "eventsPolicy": "exclude",
},
}
rs, err := c.Do(ctx, Call{"x:Bootstrap/set", map[string]any{"update": map[string]any{"singleton": update}}, "0"})
if err != nil {
return Admin{}, err
}
s, err := decodeSet(rs[0])
if err != nil {
return Admin{}, err
}
var a Admin
if err := json.Unmarshal(s.Updated["singleton"], &a); err != nil || a.Username == "" || a.Secret == "" {
return Admin{}, errors.New("x:Bootstrap/set did not return the administrator it created")
}
return a, nil
}
// DomainID finds a domain by name.
func (c *Client) DomainID(ctx context.Context, name string) (string, error) {
rs, err := c.Do(ctx,
Call{"x:Domain/query", map[string]any{}, "0"},
Call{"x:Domain/get", map[string]any{
"#ids": map[string]any{"resultOf": "0", "name": "x:Domain/query", "path": "/ids"},
"properties": []string{"name"},
}, "1"},
)
if err != nil {
return "", err
}
var got struct {
List []struct{ ID, Name string } `json:"list"`
}
if err := json.Unmarshal(rs[1].Args, &got); err != nil {
return "", err
}
for _, d := range got.List {
if d.Name == name {
return d.ID, nil
}
}
return "", fmt.Errorf("domain %s does not exist on this server", name)
}
// EnableACME creates an ACME account using HTTP-01 and moves the domain's
// certificates onto it. Stalwart starts the order at once, with no restart.
//
// HTTP-01 rather than Stalwart's default TLS-ALPN-01, because Caddy holds 443.
// Caddy forwards /.well-known/acme-challenge/ on port 80 for Stalwart's names
// to Stalwart and uses TLS-ALPN-01 itself, so the two never compete.
func (c *Client) EnableACME(ctx context.Context, domainID, directory, contact string) (string, error) {
provider := map[string]any{
"challengeType": "Http01",
"contact": map[string]bool{contact: true},
"renewBefore": "R23",
"maxRetries": 10,
"reuseKey": false,
}
if directory != "" {
provider["directory"] = directory
}
rs, err := c.Do(ctx,
Call{"x:AcmeProvider/set", map[string]any{"create": map[string]any{"acme": provider}}, "0"},
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates("#acme")}}, "1"},
)
if err != nil {
return "", err
}
s, err := decodeSet(rs[0])
if err != nil {
return "", err
}
id, err := createdID(s, "acme")
if err != nil {
return "", fmt.Errorf("x:AcmeProvider/set: %w", err)
}
if _, err := decodeSet(rs[1]); err != nil {
return id, err
}
return id, nil
}
// RetryCertificates starts a fresh ACME order for the domain. A failed order
// is not retried on a restart; moving the domain to manual and straight back
// is what starts a new one.
func (c *Client) RetryCertificates(ctx context.Context, domainID string) error {
var got struct {
List []struct {
CertificateManagement struct {
Type string `json:"@type"`
AcmeProviderID string `json:"acmeProviderId"`
} `json:"certificateManagement"`
} `json:"list"`
}
rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"certificateManagement"}}, "0"})
if err != nil {
return err
}
if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 {
return errors.New("x:Domain/get did not return the domain")
}
cm := got.List[0].CertificateManagement
if cm.Type != "Automatic" || cm.AcmeProviderID == "" {
return fmt.Errorf("the domain's certificates are %q, not managed by ACME", cm.Type)
}
rs, err = c.Do(ctx,
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: map[string]any{"certificateManagement": map[string]any{"@type": "Manual"}}}}, "0"},
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates(cm.AcmeProviderID)}}, "1"},
)
if err != nil {
return err
}
for _, r := range rs {
if _, err := decodeSet(r); err != nil {
return err
}
}
return nil
}
func automaticCertificates(providerID string) map[string]any {
return map[string]any{"certificateManagement": map[string]any{
"@type": "Automatic",
"acmeProviderId": providerID,
// Empty is Stalwart's default set: the mail host plus autoconfig,
// autodiscover, mta-sts and ua-auto-config under the domain.
"subjectAlternativeNames": map[string]bool{},
}}
}
// Certificate is what the tool reports about an issued certificate.
type Certificate struct {
Issuer string `json:"issuer"`
NotValidAfter string `json:"notValidAfter"`
SubjectAlternativeNames map[string]bool `json:"subjectAlternativeNames"`
}
// Certificates lists the certificates Stalwart holds.
func (c *Client) Certificates(ctx context.Context) ([]Certificate, error) {
rs, err := c.Do(ctx,
Call{"x:Certificate/query", map[string]any{}, "0"},
Call{"x:Certificate/get", map[string]any{
"#ids": map[string]any{"resultOf": "0", "name": "x:Certificate/query", "path": "/ids"},
"properties": []string{"issuer", "notValidAfter", "subjectAlternativeNames"},
}, "1"},
)
if err != nil {
return nil, err
}
var got struct {
List []Certificate `json:"list"`
}
return got.List, json.Unmarshal(rs[1].Args, &got)
}
// TrustForwardedFor makes Stalwart take a client's address from
// X-Forwarded-For. Its auto-ban works per address: behind Caddy, without this,
// one scanner probing for WordPress bans Caddy -- and with it every autoconfig
// lookup, DAV client and certificate renewal that comes through it. Seen on
// 0.16.22, as was the fix. It applies only once Stalwart restarts.
//
// Safe here because nothing untrusted reaches Stalwart's HTTP port: it is
// published on loopback only, and on the private network the only peers are
// Caddy, which sets the header itself, and ihasmail, which sends none.
func (c *Client) TrustForwardedFor(ctx context.Context) error {
rs, err := c.Do(ctx, Call{"x:Http/set", map[string]any{"update": map[string]any{"singleton": map[string]any{"useXForwarded": true}}}, "0"})
if err != nil {
return err
}
_, err = decodeSet(rs[0])
return err
}
// AllowIP exempts an address from the auto-ban. It applies only once Stalwart
// restarts.
func (c *Client) AllowIP(ctx context.Context, address, reason string) error {
rs, err := c.Do(ctx, Call{"x:AllowedIp/set", map[string]any{"create": map[string]any{
"allow": map[string]any{"address": address, "reason": reason},
}}, "0"})
if err != nil {
return err
}
s, err := decodeSet(rs[0])
if err != nil {
return err
}
_, err = createdID(s, "allow")
return err
}
// CreateUser creates an ordinary mailbox, in the shape ihasmail's own
// Administration creates one.
func (c *Client) CreateUser(ctx context.Context, name, domainID, password string) (string, error) {
rs, err := c.Do(ctx, Call{"x:Account/set", map[string]any{"create": map[string]any{
"user": map[string]any{
"@type": "User",
"name": name,
"domainId": domainID,
"description": nil,
"credentials": map[string]any{"0": map[string]any{"@type": "Password", "secret": password}},
"roles": map[string]any{"@type": "User"},
"permissions": map[string]any{"@type": "Inherit"},
"quotas": map[string]any{},
"aliases": map[string]any{},
"memberGroupIds": map[string]any{},
// Required on create. Turning it on cannot be undone, which is not a
// decision for a deploy tool to make on anyone's behalf.
"encryptionAtRest": map[string]any{"@type": "Disabled"},
},
}}, "0"})
if err != nil {
return "", err
}
s, err := decodeSet(rs[0])
if err != nil {
return "", err
}
return createdID(s, "user")
}
// DNSZone returns the records Stalwart wants published for the domain, as a
// zone file fragment: MX, SPF, DKIM, DMARC, the SRV records, MTA-STS and the
// autoconfig names. It has no A or AAAA records; those depend on the host.
func (c *Client) DNSZone(ctx context.Context, domainID string) (string, error) {
rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"dnsZoneFile"}}, "0"})
if err != nil {
return "", err
}
var got struct {
List []struct {
DNSZoneFile string `json:"dnsZoneFile"`
} `json:"list"`
}
if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 {
return "", errors.New("x:Domain/get did not return the domain")
}
return got.List[0].DNSZoneFile, nil
}
+102
View File
@@ -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)
}