Author SHA1 Message Date
jcoffey-dev 7d3c4d4524 ci: add Gitea Actions workflow ported from .gitlab-ci.yml
ci / test (pull_request) Successful in 35s
ci / release (pull_request) Skipped
ci / test (push) Successful in 1m19s
ci / release (push) Failing after 16s
2026-09-21 22:44:22 -07:00
jcoffey-dev 153077eab9 Merge branch 'ci/release-permalink' into 'main'
Give releases a stable latest-download URL

See merge request coffey-labs/ihasmail-oneshot!2
2026-09-20 21:15:14 -07:00
jcoffey-dev a4f0953353 Give releases a stable latest-download URL
The install guide tells people to curl
  .../releases/latest/download/<file>
which is a GitHub URL shape. GitLab's equivalent is
  /-/releases/permalink/latest/downloads/<path>
but it only resolves for assets that declare direct_asset_path, and the
release job was creating plain links to the package registry. Those carry
the tag in the URL, so they can never be a "latest" link.

Each asset now also declares /binaries/<file>, which is what the docs will
point at. The path is load-bearing: changing it breaks a documented install
command.
2026-09-20 20:58:45 -07:00
jcoffey-dev 4627b95ac9 Merge branch 'ci/gitlab-pipeline' into 'main'
Run CI on the self-hosted GitLab

See merge request coffey-labs/ihasmail-oneshot!1
2026-09-20 20:35:35 -07:00
jcoffey-dev 2d64e30cb2 Run releases on the self-hosted GitLab
Ports .github/workflows/release.yml after the GitHub account was suspended:
tag-driven, reproducible tarballs, the same refusal to release a tag that is
not an ancestor of the default branch, with the assets going to the generic
package registry and a Release created from them.

e2e.yml is not ported. e2e/public.sh publishes 25, 80, 443, 465, 993, 995
and 4190 on the machine it runs on. On Actions that was a throwaway VM; the
runner here is Web_Host, where 80 and 443 are nginx serving every live site.
It stays a manual check on a disposable host.

The Actions workflows stay in the tree as the reference.
2026-09-20 20:13:35 -07:00
jcoffey f874cb8651 Merge pull request #8 from Coffey-Labs/docs/guide-link-first
README: documentation links first
2026-09-15 12:31:19 -07:00
jcoffey-dev bbd84da276 Put the documentation links first, with the guide marked as the place to start 2026-09-15 12:25:35 -07:00
jcoffey 07ed6ea582 Merge pull request #7 from Coffey-Labs/docs/shorter-readme
Shorter README; technical detail moves to docs/
2026-09-15 12:16:51 -07:00
jcoffey-dev 97995fdebb Shorten the README; move the technical detail into docs/
The README keeps what the tool is, how to install it and the first commands,
and points to the guide on docs.ihasmail.org. Everything else moves, whole,
into docs/ and CONTRIBUTING.md, where it is organized for readers who want
the detail. Where the old README disagreed with the code, the code wins.
2026-09-15 12:14:34 -07:00
jcoffey 74fa9321f9 Merge pull request #6 from Coffey-Labs/feat/resolve-latest-ihasmail
Deploy ihasmail's newest release, recorded by its dated tag
2026-09-15 12:01:48 -07:00
jcoffey-dev 408fc20d7b Deploy ihasmail's newest release, recorded by its dated tag
The ihasmail default was a pin that went stale within days, and ihasmail
keeps ten releases' images, so an old default would in time stop pulling.
With no --ihasmail-image the tool now pulls :latest before asking, reads the
version the image carries, confirms the dated tag is the same image, and
writes that tag into compose.yaml (or the digest, if there is no such tag).
Stalwart and Caddy stay pinned. A weekly end-to-end run against the newest
release, three hours after ihasmail publishes, is what keeps it safe.
2026-09-15 12:00:20 -07:00
jcoffey c151df45d0 Merge pull request #5 from Coffey-Labs/chore/us-spelling-license
Use American English spelling
2026-09-15 11:47:13 -07:00
jcoffey-dev eb1ccc415e Use American English spelling throughout 2026-09-15 11:46:08 -07:00
jcoffey-dev 1765b31d5e Spell license the US way 2026-09-15 11:35:45 -07:00
jcoffey c6e1d026d1 Merge pull request #4 from Coffey-Labs/docs/drop-gpl-release-note
Drop the README note about the GPL release
2026-09-13 22:39:24 -07:00
jcoffey-dev 83ba353b9f Drop the README note about the GPL release
v2026.9.13 has been deleted, so the note named a release nobody can find.
2026-09-13 22:39:19 -07:00
17 changed files with 1216 additions and 837 deletions
+103
View File
@@ -0,0 +1,103 @@
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub.
#
# Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
# instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in.
#
# The shape is the same as before -- tag-driven, amd64 and arm64,
# reproducible. GitLab needed a generic package registry plus release-cli
# links; Gitea attaches the tarballs to the Release itself, as GitHub did, so
# the build and the release are one job and nothing is handed between jobs.
#
# e2e.yml is deliberately NOT ported. e2e/public.sh publishes 25, 80, 443,
# 465, 993, 995 and 4190 on the machine it runs on, which on GitHub was a
# throwaway VM and here would be the CI host -- where 80 and 443 are nginx
# and the mail ports belong to the mail netns. It stays a manual check on a
# disposable host until there is a runner that can safely be given those
# ports.
name: ci
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: docker
container:
image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: go vet ./...
- run: go test ./...
# Kept as `go run ...@latest` exactly as the workflow had it: the point
# of a vulnerability check is to use today's database, not a pinned copy
# of last month's.
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
release:
if: startsWith(github.ref, 'refs/tags/')
needs: [test]
runs-on: docker
container:
image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm
steps:
# Full history: the ancestry check below cannot be answered from a
# shallow clone. The checkout also fetches every branch as origin/*.
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
# The workflow refused to release a tag that is not an ancestor of main,
# so that a release can never describe code that was never reviewed onto
# the default branch.
- shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|| { echo "!! $TAG is not on main"; exit 1; }
# SOURCE_DATE_EPOCH is what makes the tarballs reproducible: without it
# every build stamps a new mtime and two builds of one tag differ.
- shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist
sha256sum dist/*.tar.gz
# Create the Release, then attach every file. Archive names carry no
# version, so /releases/latest/download/<name> always means the newest.
# The API is reached on the internal address so the uploads never cross
# Cloudflare. If an upload fails the half-made Release is deleted: a
# Release whose assets 404 is worse than no Release, since the install
# guide sends people straight at these URLs.
- shell: bash
env:
TAG: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# CI_SERVER_INTERNAL is set on every job container by the runner.
API="$CI_SERVER_INTERNAL/api/v1/repos/$REPO"
auth=(--header "Authorization: token $TOKEN")
id=$(curl --fail --silent --show-error "${auth[@]}" \
--header "Content-Type: application/json" \
--data "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Binaries for linux/amd64 and linux/arm64. Verify with SHA256SUMS.\"}" \
"$API/releases" | grep -o '^{"id":[0-9]*' | cut -d: -f2)
[ -n "$id" ] || { echo "!! could not create the release"; exit 1; }
for f in dist/*; do
n=$(basename "$f")
echo "uploading $n"
curl --fail --silent --show-error --output /dev/null "${auth[@]}" \
--form "attachment=@$f" "$API/releases/$id/assets?name=$n" \
|| { curl --silent "${auth[@]}" -X DELETE "$API/releases/$id"; exit 1; }
done
+41
View File
@@ -0,0 +1,41 @@
# The end-to-end test, every week, against ihasmail's newest release.
#
# A deploy with no --ihasmail-image takes whatever ihasmail release is newest
# when it runs, rather than one this repository pinned. That is only safe if
# something notices when a new release stops working with the Stalwart and
# Caddy versions pinned here -- this is that something. It runs on Mondays
# three hours after ihasmail's weekly release (09:00 UTC), so a release that
# breaks the one shot shows up the same day, before most people deploy it.
#
# e2e/public.sh needs nothing from the internet but images: Pebble stands in for
# Let's Encrypt and a DNS stub answers every name, and it publishes 25, 80, 443,
# 465, 993, 995 and 4190 on the runner while it runs.
name: End-to-end
on:
schedule:
- cron: "0 12 * * 1"
workflow_dispatch:
concurrency:
group: e2e
cancel-in-progress: false
permissions:
contents: read
jobs:
public:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Vet and test
run: |
go vet ./...
go test ./...
- name: Deploy and check a public stack
run: e2e/public.sh
+116
View File
@@ -0,0 +1,116 @@
# CI on the self-hosted GitLab, ported from .github/workflows/release.yml when
# the GitHub account was suspended on 2026-09-20. The Actions file stays in the
# tree: it is the reference this was written from and works unchanged if the
# appeal succeeds.
#
# e2e.yml is deliberately NOT ported. e2e/public.sh publishes 25, 80, 443,
# 465, 993, 995 and 4190 on the machine it runs on, which on GitHub was a
# throwaway VM and here would be Web_Host -- where 80 and 443 are nginx
# serving every live site and the mail ports belong to the mail netns.
# Running it on this runner would take the sites down for the length of the
# test. It stays a manual check on a disposable host until there is a runner
# that can safely be given those ports.
#
# The shape is the same -- tag-driven, amd64 and arm64, reproducible -- but the
# publishing half is necessarily different. There is no `gh release`, so the
# tarballs go to this project's generic package registry and the Release is
# created with release-cli, linking to them. The docs guide installs from
# release assets, so those links are the part that has to keep working.
#
# Images are pinned by digest, with the tag in the trailing comment: the
# replacement for the workflow's SHA-pinned actions, since GitLab has no
# action allowlist.
stages: [test, build, release]
variables:
PKG: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ihasmail-oneshot"
default:
interruptible: true
.go: &go
image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm
cache:
key: go-mod
paths: [.gocache/]
variables:
GOPATH: "$CI_PROJECT_DIR/.gocache"
test:
<<: *go
stage: test
script:
- go vet ./...
- go test ./...
# Kept as `go run ...@latest` exactly as the workflow had it: the point of
# a vulnerability check is to use today's database, not a pinned copy of
# last month's.
- go run golang.org/x/vuln/cmd/govulncheck@latest ./...
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
build:
<<: *go
stage: build
needs: [test]
script:
# The workflow refused to release a tag that is not an ancestor of main,
# so that a release can never describe code that was never reviewed onto
# the default branch. GIT_DEPTH is unset below to make the ancestry
# available -- a shallow clone cannot answer this.
- git fetch --quiet origin "$CI_DEFAULT_BRANCH"
- |
git merge-base --is-ancestor "$(git rev-parse "${CI_COMMIT_TAG}^{commit}")" "origin/$CI_DEFAULT_BRANCH" \
|| { echo "!! $CI_COMMIT_TAG is not on $CI_DEFAULT_BRANCH"; exit 1; }
# SOURCE_DATE_EPOCH is what makes the tarballs reproducible: without it
# every build stamps a new mtime and two builds of one tag differ.
- SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$CI_COMMIT_TAG")" scripts/build-release.sh "$CI_COMMIT_TAG" dist
- sha256sum dist/*.tar.gz
variables:
GIT_DEPTH: "0"
artifacts:
paths: [dist/]
expire_in: 1 week
rules:
- if: $CI_COMMIT_TAG
release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest@sha256:3f0a591b3b96c39ac8e28480ee99bb93201b7bcea1fbca7ed50c034098111db2 # latest
needs: [build]
script:
# Upload first, then create the Release pointing at what was uploaded. A
# Release whose assets 404 is worse than no Release: the install guide
# sends people straight at these URLs.
- |
set -eu
for f in dist/*; do
n=$(basename "$f")
echo "uploading $n"
curl --fail --silent --show-error \
--header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "$f" \
"${PKG}/${CI_COMMIT_TAG}/${n}"
done
- |
set -eu
args=""
for f in dist/*; do
n=$(basename "$f")
# direct_asset_path is what makes the permalink work. Without it the
# only stable URL is the package registry one, which carries the tag
# and so cannot be a "latest" link. With it, every release exposes
# /-/releases/permalink/latest/downloads/binaries/<file>
# which is the GitLab equivalent of the GitHub
# /releases/latest/download/<file> URL the install guide has always
# used. Changing this path breaks documented install commands.
args="$args --assets-link {\"name\":\"${n}\",\"url\":\"${PKG}/${CI_COMMIT_TAG}/${n}\",\"direct_asset_path\":\"/binaries/${n}\"}"
done
# shellcheck disable=SC2086
release-cli create --name "$CI_COMMIT_TAG" --tag-name "$CI_COMMIT_TAG" \
--description "Binaries for linux/amd64 and linux/arm64. Verify with SHA256SUMS." $args
rules:
- if: $CI_COMMIT_TAG
+81
View File
@@ -0,0 +1,81 @@
# Contributing to ihasmail-oneshot
How to build and test the tool, how the code is organized, and how versions and
releases work. Back to the [README](README.md).
## Building
With Go 1.26.8 or newer:
```bash
go build -o ihasmail-oneshot ./cmd/ihasmail-oneshot
```
## Testing
```bash
go vet ./...
go test ./... # unit tests: validation, rendered files, the Stalwart client
e2e/public.sh # the whole mail-host deploy, for real, on this machine
```
`e2e/public.sh` runs a complete mail-host deployment with no internet involved.
[Pebble](https://github.com/letsencrypt/pebble), the ACME test server, stands in
for Let's Encrypt, and a DNS stub answers every name with the host's own address.
Caddy and Stalwart both obtain real certificates from it, through the real
ports and the real `Caddyfile`. It then checks, among other things, that:
- the deploy completes, links ihasmail and Stalwart, and reports both kinds of
certificate;
- `credentials.txt` and `.env` are private, and the bootstrap credential is gone
from the Stalwart container;
- Stalwart's IMAPS (993) and submission (465) ports present certificates that
verify for the mail host;
- the webmail, Stalwart's JMAP and autoconfig are served over verified HTTPS, and
a user signs in through the webmail;
- `dns-records.zone` holds the MX and DKIM records, and `certs` recognizes an
existing certificate;
- a scanner probing through Caddy is banned by its own address, not Caddy's, and
other clients still get through;
- ihasmail is exempt from bans, and a user still signs in after failed attempts.
It publishes ports 25, 80, 443, 465, 993, 995 and 4190 on the machine while it
runs, and removes everything it created when it ends, pass or fail. `KEEP=1
e2e/public.sh` leaves the stack up to inspect.
## How the code is laid out
| Package | Does |
| --- | --- |
| `cmd/ihasmail-oneshot` | Commands, flags, confirmation, the summary |
| `internal/config` | Validates the command line into a plan: names, addresses, images |
| `internal/render` | Renders `compose.yaml` and the `Caddyfile`; writes files without ever overwriting |
| `internal/docker` | Drives the `docker` and `docker compose` CLIs |
| `internal/stalwart` | The JMAP client and every Stalwart registry call: bootstrap, ACME, bans, mailboxes, DNS zone |
| `internal/webmail` | ihasmail's health check and the sign-in that proves the link |
| `internal/deploy` | Preflight, the deploy sequence, `certs`, `destroy` |
## Versions and releases
Releases are tagged by date, like ihasmail's: `v2026.9.13`, with `.1`, `.2`
added for another release the same day. Each release pins the Stalwart and
Caddy images it was tested with as its defaults. A newer release of the tool
generally means newer tested versions of those.
ihasmail is the exception: a deploy takes its newest release, so a new
ihasmail needs no new release of this tool. What keeps that safe is the
end-to-end test, which runs every Monday against ihasmail's newest release, a
few hours after ihasmail publishes it. Stalwart is never taken this way — an
upgrade migrates its data with no way back, so its version only changes in a
release of this tool.
Binaries for `linux/amd64` and `linux/arm64` and a `SHA256SUMS` file are
attached to every [release](https://github.com/Coffey-Labs/ihasmail-oneshot/releases).
Every release is built by the [release workflow](.github/workflows/release.yml)
from a tagged commit on `main`, after the tests and a known-vulnerabilities
check pass. The archives are reproducible: `scripts/build-release.sh` builds the
same bytes from the same commit.
That weekly run is [`e2e.yml`](.github/workflows/e2e.yml), Mondays at 12:00
UTC. It can also be started by hand from the Actions tab.
+45 -821
View File
@@ -2,6 +2,7 @@
[![Latest release](https://img.shields.io/github/v/release/Coffey-Labs/ihasmail-oneshot?sort=date)](https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest) [![Latest release](https://img.shields.io/github/v/release/Coffey-Labs/ihasmail-oneshot?sort=date)](https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest)
[![License: AGPL-3.0-or-later](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE)
[![Docs: docs.ihasmail.org](https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9)](https://docs.ihasmail.org/install/oneshot/)
**One command that turns a Linux Docker host into a working mail server with **One command that turns a Linux Docker host into a working mail server with
webmail.** It deploys a fresh [Stalwart](https://stalw.art) mail server and a webmail.** It deploys a fresh [Stalwart](https://stalw.art) mail server and a
@@ -13,158 +14,39 @@ the DNS records to publish.
ihasmail-oneshot deploy --domain example.com --user alice ihasmail-oneshot deploy --domain example.com --user alice
``` ```
When it finishes you have a mail server for `example.com`, webmail at ## Documentation
`https://webmail.example.com`, Stalwart's admin UI at
`https://mail.example.com/admin`, and a mailbox for `[email protected]`. Once
you publish the DNS records it hands you, mail flows in and out. What it
leaves behind is an ordinary `docker compose` project, managed with the usual
commands and not tied to this tool.
--- | | |
| --- | --- |
| 📘 **[Step-by-step guide](https://docs.ihasmail.org/install/oneshot/)** | **Start here.** On docs.ihasmail.org: DNS, ports, deploying, publishing records, signing in, upgrading and backups |
| 📋 **[Reference](docs/reference.md)** | Requirements, every command and flag, the deployment directory, upgrading, backing up, removing |
| ⚙️ **[How it works](docs/how-it-works.md)** | Why it exists, the deploy sequence, Stalwart setup without its wizard, shared certificates, IP bans behind a proxy |
| 🔒 **[Security model](docs/security-model.md)** | What is exposed, where secrets live, the trust decisions it makes |
| 🧰 **[Troubleshooting](docs/troubleshooting.md)** | Problems by message or symptom, and known limits |
| 🧪 **[Contributing](CONTRIBUTING.md)** | Building, the unit and end-to-end tests, the code layout, releases |
- [What it is](#what-it-is) ## What you get
- [What it does](#what-it-does)
- [Why it exists](#why-it-exists)
- [Requirements](#requirements)
- [Installing](#installing)
- [Trying it locally in two minutes](#trying-it-locally-in-two-minutes)
- [Deploying a mail host, step by step](#deploying-a-mail-host-step-by-step)
- [The deployment directory](#the-deployment-directory)
- [Running it afterwards](#running-it-afterwards)
- [Command reference](#command-reference)
- [How it works](#how-it-works)
- [Security model](#security-model)
- [Troubleshooting](#troubleshooting)
- [Known limits](#known-limits)
- [Development and testing](#development-and-testing)
- [Versions and releases](#versions-and-releases)
- [License](#license)
--- - A mail server for `example.com`, with webmail at `https://webmail.example.com`
and Stalwart's admin UI at `https://mail.example.com/admin`.
## What it is - A mailbox for each `--user`, and an administrator, with generated passwords in
`credentials.txt`.
A single, statically linked binary for Linux (amd64 and arm64). You run it on - Certificates for the webmail and for Stalwart's mail ports.
the Docker host that should become the mail server. It needs Docker and the - `dns-records.zone`: every DNS record to publish, DKIM keys included.
compose plugin, and nothing else: no configuration file to write first, no - An ordinary `docker compose` project, managed with the usual commands and not
language runtime, no other scripts. tied to this tool. ihasmail runs read-only with no volume; everything durable
is in Stalwart's volume.
It deploys three containers:
| Container | Image | Role |
| --- | --- | --- |
| **Stalwart** | `stalwartlabs/stalwart` | The mail server: SMTP, IMAP, POP3, JMAP, CalDAV, CardDAV, spam filtering, DKIM. It holds all the mail and all the accounts |
| **ihasmail** | `ghcr.io/coffey-labs/ihasmail` | The webmail: mail, calendars, contacts, files and filters in the browser, talking to Stalwart over JMAP. It holds nothing but sessions |
| **Caddy** | `caddy` | The HTTPS front: certificates and TLS for the webmail and for Stalwart's web side |
It has two shapes:
| | `deploy --domain example.com` | `deploy --local` |
| --- | --- | --- |
| **For** | A real mail host on the internet | Trying ihasmail against a real Stalwart on your own machine |
| **Containers** | Stalwart, ihasmail, Caddy | Stalwart, ihasmail |
| **Publishes** | 25, 80, 443, 465, 993, 995, 4190 on every interface | Nothing but two loopback ports |
| **Certificates** | Let's Encrypt, for both Caddy and Stalwart | None |
| **Webmail** | `https://webmail.example.com` | `http://127.0.0.1:8080` |
| **Stalwart admin** | `https://mail.example.com/admin` | `http://127.0.0.1:8081/admin` |
## What it does
In order, in one run:
1. **Checks the host** before changing anything: Docker and compose are there
and usable, every port it needs is free, the deployment directory is new or
empty, no compose project of the same name exists, and the hostnames resolve.
It reports every problem at once, not the first.
2. **Shows you the plan and asks** before going ahead. `--yes` skips the
question, and is required when there is no terminal to ask on.
3. **Writes the deployment directory**: `compose.yaml`, the `Caddyfile`, and
`.env` holding a freshly generated `APP_SECRET`.
4. **Pulls the images**, pinned to versions tested together.
5. **Starts Stalwart in bootstrap mode** with a one-time administrator whose
password exists only in the tool's memory.
6. **Completes Stalwart's setup** through its API, the same setup its web UI
would otherwise walk you through: hostname, mail domain, DKIM keys, logging.
Stalwart creates the permanent administrator and returns its password, which
is written to `credentials.txt` straight away.
7. **Starts the whole stack.** Stalwart is recreated *without* the one-time
administrator.
8. **Links ihasmail and Stalwart**: exempts ihasmail from Stalwart's automatic
IP bans, tells Stalwart to trust the client addresses Caddy forwards, and
restarts Stalwart so both apply.
9. **Creates the mailboxes** you asked for with `--user`, each with a generated
password.
10. **Proves the link** by signing in *through ihasmail* as the administrator.
That only succeeds if ihasmail can reach Stalwart and Stalwart accepts the
credentials.
11. **Requests certificates** (mail host only): sets up an ACME account in
Stalwart for its IMAP and SMTP certificate, and waits for that and for
Caddy's certificates to arrive.
12. **Writes `dns-records.zone`**, every DNS record Stalwart wants published,
and prints a summary of URLs, credentials and anything still to do.
## Why it exists
Stalwart and ihasmail each install easily on their own. Making them a working,
safe mail host *together* takes a series of decisions that are easy to get
wrong, and most of the mistakes don't show until later:
- **Stalwart 0.16 has no configuration file to template.** Its settings live in
its data store, and a new server starts in a bootstrap mode that expects a
person at a web wizard. Automating that means driving its registry API, and
making sure the bootstrap credential doesn't quietly outlive the setup.
- **Both services want the same ports for certificates.** The webmail needs
HTTPS on 443. Stalwart needs its own certificate for IMAP and SMTP, and its
default way of getting one also wants port 443. Two ACME clients fighting over
one port fail in confusing, intermittent ways.
- **Stalwart's automatic IP bans don't expect a proxy in front of it.** Stalwart
bans addresses that probe for things like WordPress admin pages. Behind a
reverse proxy, every client arrives from the proxy's address, so one bot
scanning your mail host gets *the proxy* banned. Autoconfig, calendar sync and
certificate renewals all stop working, for everyone, with nothing obviously
wrong. This was reproduced on Stalwart 0.16.22 while building this tool, as
was a second trap: the setting that fixes it only takes effect after a
restart.
- **The route from webmail to mail server matters.** Sent over the private
Docker network, it never leaves the host and costs about a third of the
memory per signed-in browser tab that going back out over public HTTPS does.
- **Mail doesn't flow until DNS is right**, and the list of records (MX, SPF,
DKIM, DMARC, SRV, MTA-STS, autoconfig) is long.
This tool makes each of those decisions once, the same way every time, and
checks the result before telling you it is done. Each one is explained in
[How it works](#how-it-works).
## Requirements ## Requirements
**On the host:** - Linux (amd64 or arm64) with Docker Engine and the compose plugin.
- For a mail host: a domain whose DNS you control, a static public IP, ports
**25, 80, 443, 465, 993, 995 and 4190** open, **outbound port 25** allowed by
your provider, and **reverse DNS** for the host's address naming the mail host.
- Linux, amd64 or arm64. The full list is in [docs/reference.md](docs/reference.md#requirements).
- Docker Engine with the compose plugin (`docker compose version` works), run
as a user allowed to use Docker.
**For a mail host, additionally:** ## Install
- A domain whose DNS you control.
- **Ports 25, 80, 443, 465, 993, 995 and 4190** free on the host and open in any
firewall or cloud security group in front of it.
- **Outbound port 25.** Many cloud and VPS providers block it by default and
unblock it on request. Without it you can receive mail but not send it.
- **Reverse DNS**: a PTR record for the host's IP address naming the mail host
(`mail.example.com`). This is set at your hosting provider, not in your DNS
zone. Many receiving servers reject mail from an address without one.
- A static public IP address.
**Resources:** ihasmail on its own needs 256 MiB of RAM at minimum. Stalwart's
needs grow with the mail it stores and the number of people using it; size the
host for Stalwart.
## Installing
### From a release (recommended)
Download the archive for your architecture and its checksums, verify, and
unpack:
```bash ```bash
ARCH=amd64 # or arm64 ARCH=amd64 # or arm64
@@ -173,694 +55,39 @@ curl -fsSLO https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest/down
sha256sum --ignore-missing -c SHA256SUMS sha256sum --ignore-missing -c SHA256SUMS
tar -xzf ihasmail-oneshot-linux-$ARCH.tar.gz tar -xzf ihasmail-oneshot-linux-$ARCH.tar.gz
sudo install -m 0755 ihasmail-oneshot /usr/local/bin/ sudo install -m 0755 ihasmail-oneshot /usr/local/bin/
ihasmail-oneshot version
``` ```
Every release is built by the [release workflow](.github/workflows/release.yml) Or build from source with Go 1.26.8 or newer:
from a tagged commit on `main`, after the tests and a known-vulnerabilities `go build -o ihasmail-oneshot ./cmd/ihasmail-oneshot`.
check pass. The archives are reproducible: `scripts/build-release.sh` builds the
same bytes from the same commit.
### From source ## Use
With Go 1.26.8 or newer: Try ihasmail against a real Stalwart on your own machine, with no domain and
nothing reachable from outside:
```bash
git clone https://github.com/Coffey-Labs/ihasmail-oneshot.git
cd ihasmail-oneshot
go build -o ihasmail-oneshot ./cmd/ihasmail-oneshot
```
## Trying it locally in two minutes
To see ihasmail against a real Stalwart on your own machine, without a domain
or open ports:
```bash ```bash
ihasmail-oneshot deploy --local --user alice ihasmail-oneshot deploy --local --user alice
ihasmail-oneshot destroy --dir ihasmail-example-test # when you're done
``` ```
```text Deploy a real mail host, after pointing `mail.example.com` and
==> a local pair for example.test, in /home/you/ihasmail-example-test `webmail.example.com` at it:
ihasmail http://127.0.0.1:8080
Stalwart http://127.0.0.1:8081 (admin UI; no mail ports published)
images stalwartlabs/stalwart:v0.16.22, ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328
mailboxes alice
==> preflight
docker 29.8.0, compose 5.5.1
deploy this? [y/N] y
==> writing /home/you/ihasmail-example-test
==> pulling images
==> starting Stalwart in bootstrap mode
==> setting up Stalwart for example.test (hostname mail.example.test)
administrator [email protected], password in credentials.txt
==> starting the stack
==> linking ihasmail and Stalwart
ihasmail (172.31.253.11) exempt from Stalwart's auto-ban
restarting Stalwart to apply them
mailbox [email protected] created
signed in to ihasmail 2026.9.10+pr328 as [email protected]: linked
==> done
webmail http://127.0.0.1:8080
Stalwart http://127.0.0.1:8081/admin
sign in as [email protected] (password in /home/you/ihasmail-example-test/credentials.txt)
the administrator gets the webmail's Administration menu
mailbox [email protected]
```
Open `http://127.0.0.1:8080` and sign in as `[email protected]` with the
password from `ihasmail-example-test/credentials.txt`. Nothing outside your
machine can reach the pair, and no mail from outside can be delivered to it.
When you're done:
```bash ```bash
ihasmail-oneshot destroy --dir ihasmail-example-test ihasmail-oneshot deploy --domain example.com --email [email protected] --user alice
``` ```
## Deploying a mail host, step by step It checks the host, shows the plan with the exact ihasmail release it will use,
and asks before changing anything. Then publish `dns-records.zone`, and mail
flows.
The example uses `example.com`. Substitute your own domain throughout. ## Versions
### 1. Point two names at the host Releases are tagged by date (`v2026.9.15`). Each pins the Stalwart and Caddy
versions it was tested with. ihasmail is the newest release at deploy time,
Before running anything, create these records at your DNS provider, using the written into `compose.yaml` by its dated tag, and checked by an end-to-end test
host's public addresses: every Monday. Nothing upgrades by itself afterwards. Details in
[CONTRIBUTING.md](CONTRIBUTING.md#versions-and-releases).
```dns
mail.example.com. IN A 203.0.113.10
webmail.example.com. IN A 203.0.113.10
; and, if the host has IPv6:
mail.example.com. IN AAAA 2001:db8::10
webmail.example.com. IN AAAA 2001:db8::10
```
Doing this first means certificates are issued during the deploy. If you skip
it, the deploy still completes and tells you how to finish once DNS is in place.
> If your DNS provider offers a proxy or CDN mode, leave these records
> **DNS-only**. Mail ports cannot be proxied, and certificate validation must
> reach this host directly.
### 2. Open the ports
Allow inbound TCP 25, 80, 443, 465, 993, 995 and 4190, and UDP 443 (HTTP/3),
in the host firewall and in any provider firewall. Ask your provider to unblock
**outbound** port 25 if they block it, and set **reverse DNS** for the host's
address to `mail.example.com`.
### 3. Run the deploy
On the host:
```bash
ihasmail-oneshot deploy \
--domain example.com \
--email [email protected] \
--user alice --user bob
```
`--email` is the address Let's Encrypt associates with your certificates. Use
one that doesn't depend on this new server working. It defaults to
`[email protected]`.
The tool prints its plan, checks the host, and asks for confirmation. A run
takes a minute or two, most of it pulling images. It ends with a summary:
```text
==> done
webmail https://webmail.example.com
Stalwart https://mail.example.com/admin
sign in as [email protected] (password in /root/ihasmail-example-com/credentials.txt)
the administrator gets the webmail's Administration menu
mailbox [email protected]
mailbox [email protected]
DNS /root/ihasmail-example-com/dns-records.zone -- publish every record in it
certificate issued by <Let's Encrypt intermediate>, valid until <date>
https webmail.example.com: issued by <Let's Encrypt intermediate>
https mail.example.com: issued by <Let's Encrypt intermediate>
Before mail flows: reverse DNS for this host's address should name mail.example.com,
and outbound port 25 must be open -- many providers block it until asked.
```
The last three lines are how you know certificates were issued. If any is
replaced by a warning, see [step 5](#5-get-stalwarts-certificate-if-dns-came-late).
### 4. Publish the DNS records
`dns-records.zone` holds every record Stalwart wants published, including the
DKIM keys it just generated:
```dns
v1-ed25519-20260913._domainkey.example.com. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=…"
v1-rsa-20260913._domainkey.example.com. IN TXT ( "v=DKIM1; k=rsa; h=sha256; p=…" )
mail.example.com. IN TXT "v=spf1 a -all"
example.com. IN TXT "v=spf1 mx -all"
example.com. IN MX 10 mail.example.com.
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
_caldavs._tcp.example.com. IN SRV 0 1 443 mail.example.com.
_carddavs._tcp.example.com. IN SRV 0 1 443 mail.example.com.
_imaps._tcp.example.com. IN SRV 0 1 993 mail.example.com.
_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.
_pop3s._tcp.example.com. IN SRV 0 1 995 mail.example.com.
_submissions._tcp.example.com. IN SRV 0 1 465 mail.example.com.
mta-sts.example.com. IN CNAME mail.example.com.
_mta-sts.example.com. IN TXT "v=STSv1; id=…"
_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]"
autoconfig.example.com. IN CNAME mail.example.com.
autodiscover.example.com. IN CNAME mail.example.com.
```
Publish all of them. Many DNS providers can import a zone file directly. The
**MX**, **SPF**, **DKIM** and **DMARC** records decide whether your mail is
delivered and whether others' mail reaches you. The rest let mail apps configure
themselves from just an email address.
> DMARC is published as `p=reject`: receivers are told to reject mail that fails
> SPF and DKIM. That's the right policy for a domain that sends only from this
> server. If other services send mail as your domain, start with `p=none`.
### 5. Get Stalwart's certificate, if DNS came late
If the names didn't resolve during the deploy, the summary says Stalwart has no
certificate yet. Caddy keeps retrying its own certificates, but Stalwart doesn't
retry a failed order. Once DNS points at the host:
```bash
ihasmail-oneshot certs --dir ihasmail-example-com
```
This works now that every Stalwart name has a record, including the
`autoconfig`, `autodiscover`, `mta-sts` and `ua-auto-config` CNAMEs from
`dns-records.zone`, because Stalwart's certificate covers all of them.
### 6. Sign in
- **Webmail:** `https://webmail.example.com`, as any mailbox. As
`[email protected]` you also get the Administration menu, for adding people
and domains.
- **Stalwart's admin UI:** `https://mail.example.com/admin`, as
`[email protected]`, for every server setting.
- **Mail apps** (Thunderbird, Apple Mail, phones): add the account by email
address and password. Once the autoconfig records are published, most apps
find the servers themselves. By hand, they're IMAP on `mail.example.com:993`
(TLS) and SMTP submission on `mail.example.com:465` (TLS).
**Change the generated passwords** after first sign-in, then delete them from
`credentials.txt` or keep that file somewhere safe. An account with two-factor
authentication turned on signs in to the webmail with an app password created
in Stalwart.
## The deployment directory
The deploy writes a directory named after the domain, `./ihasmail-example-com`
by default (`--dir` to choose):
| File | Mode | What it is |
| --- | --- | --- |
| `compose.yaml` | 0644 | The whole deployment: three services, a private network, named volumes. Commented |
| `Caddyfile` | 0644 | Caddy's configuration: the webmail, Stalwart's web names, and port 80's challenge forwarding |
| `.env` | 0600 | `APP_SECRET`, which seals ihasmail's session cookies. Read by compose |
| `credentials.txt` | 0600 | The administrator's and each mailbox's generated password, plus what `certs` needs to reach Stalwart |
| `dns-records.zone` | 0644 | The DNS records to publish |
| `acme-ca-root.pem`, `ca-bundle.crt` | 0644 | Only with `--acme-ca-root`: the private CA, and the system roots with it added |
Data lives in Docker named volumes, prefixed with the project name:
| Volume | Holds |
| --- | --- |
| `…_stalwart-data` | All mail, accounts, calendars, contacts, files, settings, DKIM keys, Stalwart's certificates |
| `…_stalwart-etc` | Stalwart's store location file |
| `…_caddy-data` | Caddy's certificates and ACME account |
| `…_caddy-config` | Caddy's autosaved configuration |
ihasmail has no volume. It runs read-only and keeps sessions in memory.
## Running it afterwards
The directory is a standard compose project. From inside it:
```bash
docker compose ps # what's running
docker compose logs -f stalwart # follow Stalwart's log
docker compose logs caddy | grep -i error
docker compose restart ihasmail # signs everyone out of the webmail; nothing else is lost
docker compose down # stop everything (data stays in the volumes)
docker compose up -d # start it again
```
The containers restart by themselves after a crash or a reboot
(`restart: unless-stopped`).
### Upgrading
Change the image tag in `compose.yaml` and apply it:
```bash
docker compose pull && docker compose up -d
```
- **ihasmail** is safe to move to any newer release that supports your
Stalwart version. Its release notes say which.
- **Stalwart**: read its upgrade notes before changing versions. Check that the
ihasmail version you run supports the new Stalwart release first, since
ihasmail validates against one Stalwart release at a time. Back up
`stalwart-data` first.
- **Caddy** minor releases are routine.
### Backing up
Everything that can't be recreated is in the `stalwart-data` volume. For a
consistent copy, stop Stalwart briefly:
```bash
docker compose stop stalwart
docker run --rm -v ihasmail-example-com_stalwart-data:/data -v "$PWD":/backup alpine \
tar -czf /backup/stalwart-data-$(date +%F).tar.gz -C /data .
docker compose start stalwart
```
Keep `caddy-data` too, or certificates are requested again on a rebuild. That's
harmless unless it happens often enough to meet Let's Encrypt's rate limits. Keep
the deployment directory itself, which is small and contains your secrets.
### Removing a deployment
```bash
ihasmail-oneshot destroy --dir ihasmail-example-com
```
This removes the containers, the network, **the volumes with every message and
account**, and the files the tool wrote. It asks first unless given `--yes`.
Files you added to the directory yourself are left in place, and so is the
directory.
## Command reference
### `deploy`
```text
ihasmail-oneshot deploy --domain example.com [flags]
ihasmail-oneshot deploy --local [flags]
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--domain` | required, or `example.test` with `--local` | The mail domain this server receives for |
| `--local` | off | The loopback-only shape: no mail ports, no Caddy, no certificates |
| `--mail-host` | `mail.DOMAIN` | Stalwart's hostname. Must be one label under the domain, e.g. `mx.example.com` |
| `--webmail-host` | `webmail.DOMAIN` | The webmail's hostname. Any name except Stalwart's |
| `--email` | `postmaster@DOMAIN` | ACME contact address for both Caddy and Stalwart |
| `--user NAME` | none | Create a mailbox `NAME@DOMAIN` with a generated password. Repeat for more |
| `--dir` | `./PROJECT` | Deployment directory to write. Must be new or empty |
| `--project` | `ihasmail-DOMAIN` (dots as dashes) | Compose project name, which prefixes containers, network and volumes |
| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | Stalwart image |
| `--ihasmail-image` | `ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328` | ihasmail image |
| `--caddy-image` | `caddy:2.11.4` | Caddy image |
| `--webmail-bind` | `127.0.0.1:8080` | Host address for ihasmail's own port, for reaching it without Caddy |
| `--stalwart-bind` | `127.0.0.1:8081` | Host address for Stalwart's plain-HTTP port. The tool configures Stalwart through it |
| `--subnet` | `172.31.253.0/24` | The stack's private network. Change it if it overlaps a network you already have |
| `--acme-directory` | Let's Encrypt | ACME directory URL of a private CA, for both Caddy and Stalwart |
| `--acme-ca-root` | none | PEM file of the root the private CA's HTTPS endpoint is signed by |
| `--yes` | off | Don't ask for confirmation. Required without a terminal |
The defaults are the versions tested together for this release.
### `certs`
```text
ihasmail-oneshot certs --dir DIR
```
Starts a new certificate order in Stalwart for the deployment in `DIR` and waits
for it, typically after fixing DNS. If Stalwart already holds a valid
certificate for the mail host, it reports that and does nothing.
### `destroy`
```text
ihasmail-oneshot destroy --dir DIR [--yes]
```
Removes the deployment in `DIR` with all of its data. See
[Removing a deployment](#removing-a-deployment).
### `version`
Prints the tool's version.
## How it works
### The shape of the deployment
```mermaid
flowchart LR
Browser(["Browser"])
Apps(["Mail apps, other mail servers"])
subgraph Host["Docker host"]
subgraph Net["private network 172.31.253.0/24"]
Caddy["Caddy<br/>.10"]
Ihasmail["ihasmail<br/>.11<br/>read-only, no volume"]
Stalwart["Stalwart<br/>.12"]
end
Data[("stalwart-data")]
end
Browser -- "443 webmail.example.com" --> Caddy
Browser -- "443 mail.example.com/admin, DAV, autoconfig" --> Caddy
Caddy -- "http :8080" --> Ihasmail
Caddy -- "http :8080" --> Stalwart
Ihasmail -- "JMAP over http://stalwart:8080" --> Stalwart
Apps -- "25, 465, 993, 995, 4190" --> Stalwart
Stalwart --- Data
```
- **Caddy** is the only thing on ports 80 and 443. It serves the webmail at the
webmail host, and Stalwart's web side (admin UI, JMAP for other clients,
CalDAV, CardDAV, autoconfig, MTA-STS) at the mail host and the
`autoconfig`, `autodiscover`, `mta-sts` and `ua-auto-config` names.
- **Stalwart** publishes its mail ports directly, so SMTP and IMAP clients reach
it with their real addresses.
- **ihasmail** talks to Stalwart over plain HTTP on the private network, as
`http://stalwart:8080`. The browser never talks to Stalwart for webmail; it
talks to ihasmail, and ihasmail makes the JMAP calls.
- The three containers have **fixed addresses**, because Stalwart is told about
two of them (see below), and an address Docker picks afresh on every recreate
couldn't be written into Stalwart's settings.
### The deploy, as a sequence
```mermaid
sequenceDiagram
autonumber
participant T as ihasmail-oneshot
participant S as Stalwart
participant I as ihasmail
participant C as Caddy
participant CA as Let's Encrypt
T->>S: start with a one-time admin (bootstrap mode)
T->>S: x:Bootstrap/set: hostname, domain, DKIM, logging
S-->>T: permanent administrator + password
T->>S: recreate without the one-time admin
T->>I: start
T->>C: start
C->>CA: certificates for its names (TLS-ALPN-01, port 443)
T->>S: allow ihasmail's address, trust Caddy's X-Forwarded-For
T->>S: restart to apply
T->>S: create mailboxes
T->>I: sign in as the administrator
I->>S: JMAP session (proves the link)
T->>S: create ACME account (HTTP-01), domain to automatic certificates
S->>CA: order certificate
CA->>C: HTTP-01 challenge on port 80
C->>S: forwarded /.well-known/acme-challenge/
S-->>T: certificate issued
T->>S: read the DNS zone
```
### Setting up Stalwart without its web wizard
Stalwart 0.16 keeps its configuration in its data store rather than a file. A
server started with an empty configuration comes up in **bootstrap mode**: a
temporary administrator and a setup wizard on port 8080. The wizard is a single
registry object, `x:Bootstrap`, written with one JMAP call.
The tool starts Stalwart with `STALWART_RECOVERY_ADMIN` set to a random password
held only in memory. It's passed through an override file that exists only for
that step, and through the environment of the `docker compose` process, never
written into `compose.yaml`. It then sets:
- `serverHostname` and `defaultDomain` from `--mail-host` and `--domain`;
- `generateDkimKeys` on, so outgoing mail is signed from the start;
- the log to **stdout**, because Stalwart's default log directory doesn't exist
in the container image and isn't a volume;
- `requestTlsCertificate` **off**, because on 0.16.22 that switch creates no ACME
account and leaves the domain on manual certificates, so it would look like
certificates had been handled when they hadn't. The tool sets ACME up itself,
explicitly.
Stalwart answers with a permanent administrator, `admin@DOMAIN`, and its
password. The tool writes that to `credentials.txt` before doing anything else,
so a failure later can't lose it. Then it brings the full stack up from
`compose.yaml` alone, which recreates Stalwart without the one-time
administrator, so no fixed recovery credential outlives the setup.
The tool refuses to set up a Stalwart that isn't in bootstrap mode, so it can
never reconfigure a server that already holds someone's mail.
### Certificates for two services on one pair of ports
Caddy needs certificates to serve HTTPS. Stalwart needs its own for IMAP, POP3,
submission and STARTTLS on SMTP. Both need one for the mail host's name, and
both would normally want port 443 to prove they control it.
They're separated by **ACME challenge type**:
| | Challenge | Port | How |
| --- | --- | --- | --- |
| **Caddy** | TLS-ALPN-01 | 443 | Configured with `disable_http_challenge` for Stalwart's names |
| **Stalwart** | HTTP-01 | 80 | Caddy forwards `/.well-known/acme-challenge/*` for Stalwart's names to Stalwart, untouched. Everything else on port 80 is redirected to HTTPS |
Neither ever answers the other's challenge, and neither needs the other's key.
Stalwart's certificate covers the mail host plus `autoconfig`, `autodiscover`,
`mta-sts` and `ua-auto-config` under the domain, the names its own DNS zone
points at the mail host, and Caddy fronts all five so every challenge reaches
it.
Stalwart starts its order as soon as the ACME account exists, and renews by
itself from then on. If an order fails, usually because DNS isn't in place yet,
Stalwart doesn't try again by itself. `certs` starts a new order.
### Stalwart's automatic IP bans, behind a proxy
Stalwart bans an IP address that behaves like an attacker. For example, 30
requests in a day for scanner paths such as `/wp-admin.php` get an address
banned. That's good protection, but it counts by the address a connection comes
from, and two kinds of traffic reach Stalwart from a single shared address:
1. **Everything through Caddy** arrives from Caddy's address. Unchanged, one bot
scanning `https://mail.example.com` bans Caddy. That was reproduced on
0.16.22: after a scan, autoconfig answered `403` to everyone, and so would
calendar sync and certificate renewal.
**Fix:** Stalwart is told to take the client's address from the
`X-Forwarded-For` header Caddy sets. Re-tested after the change, the scanner
was banned and every other client still got through. This is safe only
because nothing untrusted can reach Stalwart's HTTP port: it's published on
loopback alone, and on the private network its only peers are Caddy, which
sets the header itself, and ihasmail, which sends none.
2. **Every webmail request** arrives from ihasmail's address: every sign-in, and
every push stream opened and dropped as people open and close tabs. A ban on
that one address would be a ban on everyone's webmail.
**Fix:** ihasmail's fixed address is added to Stalwart's allowed addresses.
ihasmail limits sign-in attempts per real client itself, so repeated wrong
passwords are still slowed down, just not by banning the webmail.
On 0.16.22 **neither setting takes effect until Stalwart restarts**. Applied to
a running server, a scan straight afterwards still banned Caddy. So the tool
restarts Stalwart after making both changes, before anything else depends on
them.
### The webmail's side
ihasmail is deployed the way its own documentation recommends for this
situation:
- **Immutable**: read-only root filesystem, no volume, sessions in memory. A
restart signs everyone out and loses nothing else, because everything durable,
each user's settings included, lives in Stalwart.
- **Private route to Stalwart** (`STALWART_URL=http://stalwart:8080`): the
credentials that travel on that leg never leave the host.
- **Push by subscription** (`PUSH_URL=https://webmail.example.com`): Stalwart
posts mailbox changes to ihasmail instead of holding a connection open for
every browser tab. If Stalwart can't reach that URL, every tab uses the
ordinary relay instead and nothing breaks. `/api/health` shows which is in use.
- **Behind a trusted proxy** (`TRUST_PROXY=1`): ihasmail believes Caddy's
forwarded headers, because Caddy is on a private network range, so it sets
secure cookies and attributes sign-in attempts to the real client.
## Security model
**What's reachable from outside** (mail host shape):
| Port | Service |
| --- | --- |
| 25 | SMTP, Stalwart (receiving mail; STARTTLS) |
| 80 | Caddy: redirects to HTTPS, and ACME HTTP-01 challenges for Stalwart |
| 443 (TCP, UDP) | Caddy: the webmail, and Stalwart's web side |
| 465 | SMTP submission with TLS, Stalwart |
| 993 | IMAP with TLS, Stalwart |
| 995 | POP3 with TLS, Stalwart |
| 4190 | ManageSieve, Stalwart |
Stalwart's plain-HTTP port (8080) and ihasmail's port are published on
`127.0.0.1` only. In `--local` mode, those two loopback ports are all that's
published.
**Secrets:**
- `APP_SECRET` is 48 random bytes, in `.env` (0600). It seals ihasmail's session
cookies.
- Generated passwords come from the operating system's cryptographic random
source, letters and digits only, in `credentials.txt` (0600).
- The one-time bootstrap password is never written to disk, and Stalwart is
recreated without it once setup completes.
**Trust decisions the deployment makes**, each explained above:
- Stalwart believes `X-Forwarded-For` on its HTTP port, which only Caddy and
ihasmail can reach.
- ihasmail's address is exempt from Stalwart's automatic bans.
- ihasmail believes forwarded headers from private-range peers, which here is
Caddy.
**What the tool doesn't do:** configure a host firewall, harden the Docker
daemon, set up backups or monitoring, or turn on encryption at rest for
mailboxes. Encryption at rest can't be turned off again once on, which is not a
decision for a deploy tool to make.
## Troubleshooting
**`port 443 is already in use on this host`**
Something else, often an existing web server, holds a port the mail host needs.
Free it, or use a separate host. With `--local`, move `--webmail-bind` or
`--stalwart-bind` instead.
**`project ihasmail-example-com already exists in Docker`**
An earlier run left containers or volumes behind. If it's a failed attempt you
want to discard: `ihasmail-oneshot destroy --dir ihasmail-example-com --yes`,
then deploy again. To keep it and deploy another, pass a different `--project`
and `--dir`.
**`... already has files in it; give --dir a new or empty directory`**
The tool never writes over an existing deployment. Choose another directory, or
destroy the old deployment first.
**A deploy stopped partway.**
The error says which step failed and shows the last lines of the relevant
container's log. The stack is left as it was, for you to inspect. To start
again from nothing: `ihasmail-oneshot destroy --dir DIR --yes`, fix the cause,
and deploy again.
**`Pool overlaps with other one on this address space`**
The default private network `172.31.253.0/24` collides with a Docker network you
already have. Destroy the partial deployment and deploy again with, for
example, `--subnet 172.31.200.0/24`.
**`Stalwart has no certificate yet`**
Usually DNS: the mail host or one of the `autoconfig`, `autodiscover`,
`mta-sts`, `ua-auto-config` names doesn't resolve to this host yet, or port 80
isn't reachable from the internet. Check with `dig +short mail.example.com` from
somewhere else, fix it, then run `ihasmail-oneshot certs --dir DIR`. Stalwart's
reasons are in its log:
```bash
docker compose logs stalwart | grep -i acme
```
**`Caddy has no certificate for webmail.example.com yet`**
Same causes. Caddy retries by itself with increasing delays. See
`docker compose logs caddy | grep -i error`.
**Mail arrives but sent mail never does.**
Outbound port 25 is almost always the cause. Test from the host with
`nc -vz gmail-smtp-in.l.google.com 25`. Stalwart's log names each delivery
failure (`docker compose logs stalwart`). Also check that reverse DNS for the host's address
names the mail host.
**Sent mail lands in spam.**
Check that the SPF, DKIM and DMARC records from `dns-records.zone` are
published exactly, and that reverse DNS is set. New addresses need a little time
to build a sending reputation.
**The webmail signs everyone out.**
Expected after `docker compose restart ihasmail`, any ihasmail upgrade, or a
reboot: sessions live in memory.
**`/api/health` shows push accounts `pending` that never become `verified`.**
Stalwart can't reach `https://webmail.example.com` from inside its container.
Mail still updates in real time over the relay. The usual causes are a host
firewall that drops traffic from Docker's bridge to the host's own public
address, or the webmail name not resolving from the host.
## Known limits
- **One mail domain per deploy.** More can be added afterwards in Stalwart or in
ihasmail's Administration. Their certificates and DNS records are then yours
to arrange.
- **Linux Docker hosts only.** The tool drives the local Docker daemon and
publishes ports on the host it runs on.
- **IPv4 on the private network.** Ports are also published on IPv6 wherever
Docker does so on the host.
- **Fresh deployments only.** It doesn't import an existing Stalwart, or adopt a
deployment it didn't write.
- **Stalwart doesn't retry a failed certificate order** by itself, on a
transient CA error either. `certs` starts a new one.
- **Push by subscription isn't covered by the end-to-end test**, whose lab has no
public DNS for Stalwart to resolve the webmail's name through. Its fallback,
the relay, is what the test exercises.
- Validated against **Stalwart 0.16.22**. Other Stalwart versions may change the
registry objects the setup uses.
## Development and testing
```bash
go vet ./...
go test ./... # unit tests: validation, rendered files, the Stalwart client
e2e/public.sh # the whole mail-host deploy, for real, on this machine
```
`e2e/public.sh` runs a complete mail-host deployment with no internet involved.
[Pebble](https://github.com/letsencrypt/pebble), the ACME test server, stands in
for Let's Encrypt, and a DNS stub answers every name with the host's own address.
Caddy and Stalwart both obtain real certificates from it, through the real
ports and the real `Caddyfile`. It then checks, among other things, that:
- the deploy completes, links ihasmail and Stalwart, and reports both kinds of
certificate;
- `credentials.txt` and `.env` are private, and the bootstrap credential is gone
from the Stalwart container;
- Stalwart's IMAPS (993) and submission (465) ports present certificates that
verify for the mail host;
- the webmail, Stalwart's JMAP and autoconfig are served over verified HTTPS, and
a user signs in through the webmail;
- `dns-records.zone` holds the MX and DKIM records, and `certs` recognises an
existing certificate;
- a scanner probing through Caddy is banned by its own address, not Caddy's, and
other clients still get through;
- ihasmail is exempt from bans, and a user still signs in after failed attempts.
It publishes ports 25, 80, 443, 465, 993, 995 and 4190 on the machine while it
runs, and removes everything it created when it ends, pass or fail. `KEEP=1
e2e/public.sh` leaves the stack up to inspect.
The code:
| Package | Does |
| --- | --- |
| `cmd/ihasmail-oneshot` | Commands, flags, confirmation, the summary |
| `internal/config` | Validates the command line into a plan: names, addresses, images |
| `internal/render` | Renders `compose.yaml` and the `Caddyfile`; writes files without ever overwriting |
| `internal/docker` | Drives the `docker` and `docker compose` CLIs |
| `internal/stalwart` | The JMAP client and every Stalwart registry call: bootstrap, ACME, bans, mailboxes, DNS zone |
| `internal/webmail` | ihasmail's health check and the sign-in that proves the link |
| `internal/deploy` | Preflight, the deploy sequence, `certs`, `destroy` |
## Versions and releases
Releases are tagged by date, like ihasmail's: `v2026.9.13`, with `.1`, `.2`
added for another release the same day. Each release pins the Stalwart,
ihasmail and Caddy images it was tested with as its defaults. A newer release
of the tool generally means newer tested versions.
Binaries for `linux/amd64` and `linux/arm64` and a `SHA256SUMS` file are
attached to every [release](https://github.com/Coffey-Labs/ihasmail-oneshot/releases).
## Security ## Security
@@ -872,9 +99,6 @@ public issue.
AGPL-3.0-or-later, the same as ihasmail. See [LICENSE](LICENSE). AGPL-3.0-or-later, the same as ihasmail. See [LICENSE](LICENSE).
Running the tool to deploy your own mail host places no obligations on you. The Running the tool to deploy your own mail host places no obligations on you. The
licence matters if you modify the tool and offer it to others, including as a license matters if you modify the tool and offer it to others, including as a
hosted service that deploys on their behalf: then your modified source must be hosted service that deploys on their behalf: then your modified source must be
available to them. available to them.
`v2026.9.13`, the first release, was published under GPL-3.0-or-later and
stays under it for anyone who has it. Every later release is AGPL-3.0-or-later.
+12 -4
View File
@@ -101,7 +101,7 @@ Flags:
fs.StringVar(&o.Dir, "dir", "", "deployment directory to write, new or empty (default ./PROJECT)") fs.StringVar(&o.Dir, "dir", "", "deployment directory to write, new or empty (default ./PROJECT)")
fs.StringVar(&o.Project, "project", "", "compose project name (default ihasmail-DOMAIN, dots as dashes)") fs.StringVar(&o.Project, "project", "", "compose project name (default ihasmail-DOMAIN, dots as dashes)")
fs.StringVar(&o.StalwartImage, "stalwart-image", config.DefaultStalwartImage, "Stalwart image") fs.StringVar(&o.StalwartImage, "stalwart-image", config.DefaultStalwartImage, "Stalwart image")
fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.DefaultIhasmailImage, "ihasmail image") fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.NewestIhasmail, "ihasmail image; the default is the newest release, written into compose.yaml as its dated tag")
fs.StringVar(&o.CaddyImage, "caddy-image", config.DefaultCaddyImage, "Caddy image") fs.StringVar(&o.CaddyImage, "caddy-image", config.DefaultCaddyImage, "Caddy image")
fs.StringVar(&o.WebmailBind, "webmail-bind", "127.0.0.1:8080", "host address for ihasmail's own port") fs.StringVar(&o.WebmailBind, "webmail-bind", "127.0.0.1:8080", "host address for ihasmail's own port")
fs.StringVar(&o.StalwartBind, "stalwart-bind", "127.0.0.1:8081", "host address for Stalwart's plain-HTTP port (admin UI)") fs.StringVar(&o.StalwartBind, "stalwart-bind", "127.0.0.1:8081", "host address for Stalwart's plain-HTTP port (admin UI)")
@@ -132,6 +132,10 @@ Flags:
for _, w := range warnings { for _, w := range warnings {
log.Warn("%s", w) log.Warn("%s", w)
} }
// Before the question, so the answer is about the exact ihasmail release.
if plan, err = deploy.ResolveIhasmail(ctx, plan, log); err != nil {
return err
}
if !yes { if !yes {
if err := confirm("deploy this?"); err != nil { if err := confirm("deploy this?"); err != nil {
return err return err
@@ -142,7 +146,7 @@ Flags:
if err != nil { if err != nil {
return err return err
} }
summarise(plan, res, log) summarize(plan, res, log)
return nil return nil
} }
@@ -162,7 +166,11 @@ func describe(p config.Plan, log deploy.Log) {
log.Info("ACME Let's Encrypt, contact %s", p.Email) log.Info("ACME Let's Encrypt, contact %s", p.Email)
} }
} }
log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage) if p.FollowsNewestIhasmail() {
log.Info("images %s, ihasmail's newest release", p.StalwartImage)
} else {
log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage)
}
if !p.Local { if !p.Local {
log.Info(" %s", p.CaddyImage) log.Info(" %s", p.CaddyImage)
} }
@@ -171,7 +179,7 @@ func describe(p config.Plan, log deploy.Log) {
} }
} }
func summarise(p config.Plan, r *deploy.Result, log deploy.Log) { func summarize(p config.Plan, r *deploy.Result, log deploy.Log) {
log.Step("done") log.Step("done")
if p.Local { if p.Local {
log.Info("webmail http://%s", p.WebmailBind) log.Info("webmail http://%s", p.WebmailBind)
+275
View File
@@ -0,0 +1,275 @@
# How ihasmail-oneshot works
The design of the deployment this tool writes, and the reasons behind each
choice: what runs where, the order the deploy happens in, how Stalwart is set up
without its web wizard, how two services share certificates on one pair of
ports, and how Stalwart's automatic bans are kept from locking everyone out. For
installing and using the tool, start with the [README](../README.md) or the
[guide on docs.ihasmail.org](https://docs.ihasmail.org/install/oneshot/).
## Why it exists
Stalwart and ihasmail each install easily on their own. Making them a working,
safe mail host *together* takes a series of decisions that are easy to get
wrong, and most of the mistakes don't show until later:
- **Stalwart 0.16 has no configuration file to template.** Its settings live in
its data store, and a new server starts in a bootstrap mode that expects a
person at a web wizard. Automating that means driving its registry API, and
making sure the bootstrap credential doesn't quietly outlive the setup.
- **Both services want the same ports for certificates.** The webmail needs
HTTPS on 443. Stalwart needs its own certificate for IMAP and SMTP, and its
default way of getting one also wants port 443. Two ACME clients fighting over
one port fail in confusing, intermittent ways.
- **Stalwart's automatic IP bans don't expect a proxy in front of it.** Stalwart
bans addresses that probe for things like WordPress admin pages. Behind a
reverse proxy, every client arrives from the proxy's address, so one bot
scanning your mail host gets *the proxy* banned. Autoconfig, calendar sync and
certificate renewals all stop working, for everyone, with nothing obviously
wrong. This was reproduced on Stalwart 0.16.22 while building this tool, as
was a second trap: the setting that fixes it only takes effect after a
restart.
- **The route from webmail to mail server matters.** Sent over the private
Docker network, it never leaves the host and costs about a third of the
memory per signed-in browser tab that going back out over public HTTPS does.
- **Mail doesn't flow until DNS is right**, and the list of records (MX, SPF,
DKIM, DMARC, SRV, MTA-STS, autoconfig) is long.
This tool makes each of those decisions once, the same way every time, and
checks the result before telling you it is done. Each one is explained below.
## What it deploys
A single, statically linked binary for Linux (amd64 and arm64). You run it on
the Docker host that should become the mail server. It needs Docker and the
compose plugin, and nothing else: no configuration file to write first, no
language runtime, no other scripts.
It deploys three containers:
| Container | Image | Role |
| --- | --- | --- |
| **Stalwart** | `stalwartlabs/stalwart` | The mail server: SMTP, IMAP, POP3, JMAP, CalDAV, CardDAV, spam filtering, DKIM. It holds all the mail and all the accounts |
| **ihasmail** | `ghcr.io/coffey-labs/ihasmail` | The webmail: mail, calendars, contacts, files and filters in the browser, talking to Stalwart over JMAP. It holds nothing but sessions |
| **Caddy** | `caddy` | The HTTPS front: certificates and TLS for the webmail and for Stalwart's web side |
It has two shapes:
| | `deploy --domain example.com` | `deploy --local` |
| --- | --- | --- |
| **For** | A real mail host on the internet | Trying ihasmail against a real Stalwart on your own machine |
| **Containers** | Stalwart, ihasmail, Caddy | Stalwart, ihasmail |
| **Publishes** | 25, 80, 443, 465, 993, 995, 4190 on every interface | Nothing but two loopback ports |
| **Certificates** | Let's Encrypt, for both Caddy and Stalwart | None |
| **Webmail** | `https://webmail.example.com` | `http://127.0.0.1:8080` |
| **Stalwart admin** | `https://mail.example.com/admin` | `http://127.0.0.1:8081/admin` |
## What a deploy does, in order
In order, in one run:
1. **Checks the host** before changing anything: Docker and compose are there
and usable, every port it needs is free, the deployment directory is new or
empty, no compose project of the same name exists, and the hostnames resolve.
It reports every problem at once, not the first.
2. **Finds ihasmail's newest release, shows you the plan and asks** before
going ahead, so what you agree to is an exact ihasmail version. `--yes` skips the
question, and is required when there is no terminal to ask on.
3. **Writes the deployment directory**: `compose.yaml`, the `Caddyfile`, and
`.env` holding a freshly generated `APP_SECRET`.
4. **Pulls the images**: Stalwart and Caddy at the versions this release of the
tool was tested with, and the ihasmail release it found, written into
`compose.yaml` by its dated tag so nothing moves it later.
5. **Starts Stalwart in bootstrap mode** with a one-time administrator whose
password exists only in the tool's memory.
6. **Completes Stalwart's setup** through its API, the same setup its web UI
would otherwise walk you through: hostname, mail domain, DKIM keys, logging.
Stalwart creates the permanent administrator and returns its password, which
is written to `credentials.txt` straight away.
7. **Starts the whole stack.** Stalwart is recreated *without* the one-time
administrator.
8. **Links ihasmail and Stalwart**: exempts ihasmail from Stalwart's automatic
IP bans, tells Stalwart to trust the client addresses Caddy forwards, and
restarts Stalwart so both apply.
9. **Creates the mailboxes** you asked for with `--user`, each with a generated
password.
10. **Proves the link** by signing in *through ihasmail* as the administrator.
That only succeeds if ihasmail can reach Stalwart and Stalwart accepts the
credentials.
11. **Requests certificates** (mail host only): sets up an ACME account in
Stalwart for its IMAP and SMTP certificate, and waits for that and for
Caddy's certificates to arrive.
12. **Writes `dns-records.zone`**, every DNS record Stalwart wants published,
and prints a summary of URLs, credentials and anything still to do.
## The shape of the deployment
```mermaid
flowchart LR
Browser(["Browser"])
Apps(["Mail apps, other mail servers"])
subgraph Host["Docker host"]
subgraph Net["private network 172.31.253.0/24"]
Caddy["Caddy<br/>.10"]
Ihasmail["ihasmail<br/>.11<br/>read-only, no volume"]
Stalwart["Stalwart<br/>.12"]
end
Data[("stalwart-data")]
end
Browser -- "443 webmail.example.com" --> Caddy
Browser -- "443 mail.example.com/admin, DAV, autoconfig" --> Caddy
Caddy -- "http :8080" --> Ihasmail
Caddy -- "http :8080" --> Stalwart
Ihasmail -- "JMAP over http://stalwart:8080" --> Stalwart
Apps -- "25, 465, 993, 995, 4190" --> Stalwart
Stalwart --- Data
```
- **Caddy** is the only thing on ports 80 and 443. It serves the webmail at the
webmail host, and Stalwart's web side (admin UI, JMAP for other clients,
CalDAV, CardDAV, autoconfig, MTA-STS) at the mail host and the
`autoconfig`, `autodiscover`, `mta-sts` and `ua-auto-config` names.
- **Stalwart** publishes its mail ports directly, so SMTP and IMAP clients reach
it with their real addresses.
- **ihasmail** talks to Stalwart over plain HTTP on the private network, as
`http://stalwart:8080`. The browser never talks to Stalwart for webmail; it
talks to ihasmail, and ihasmail makes the JMAP calls.
- The three containers have **fixed addresses**, because Stalwart is told about
two of them (see below), and an address Docker picks afresh on every recreate
couldn't be written into Stalwart's settings.
## The deploy, as a sequence
```mermaid
sequenceDiagram
autonumber
participant T as ihasmail-oneshot
participant S as Stalwart
participant I as ihasmail
participant C as Caddy
participant CA as Let's Encrypt
T->>S: start with a one-time admin (bootstrap mode)
T->>S: x:Bootstrap/set: hostname, domain, DKIM, logging
S-->>T: permanent administrator + password
T->>S: recreate without the one-time admin
T->>I: start
T->>C: start
C->>CA: certificates for its names (TLS-ALPN-01, port 443)
T->>S: allow ihasmail's address, trust Caddy's X-Forwarded-For
T->>S: restart to apply
T->>S: create mailboxes
T->>I: sign in as the administrator
I->>S: JMAP session (proves the link)
T->>S: create ACME account (HTTP-01), domain to automatic certificates
S->>CA: order certificate
CA->>C: HTTP-01 challenge on port 80
C->>S: forwarded /.well-known/acme-challenge/
S-->>T: certificate issued
T->>S: read the DNS zone
```
## Setting up Stalwart without its web wizard
Stalwart 0.16 keeps its configuration in its data store rather than a file. A
server started with an empty configuration comes up in **bootstrap mode**: a
temporary administrator and a setup wizard on port 8080. The wizard is a single
registry object, `x:Bootstrap`, written with one JMAP call.
The tool starts Stalwart with `STALWART_RECOVERY_ADMIN` set to a random password
held only in memory. It's passed through an override file that exists only for
that step, and through the environment of the `docker compose` process, never
written into `compose.yaml`. It then sets:
- `serverHostname` and `defaultDomain` from `--mail-host` and `--domain`;
- `generateDkimKeys` on, so outgoing mail is signed from the start;
- the log to **stdout**, because Stalwart's default log directory doesn't exist
in the container image and isn't a volume;
- `requestTlsCertificate` **off**, because on 0.16.22 that switch creates no ACME
account and leaves the domain on manual certificates, so it would look like
certificates had been handled when they hadn't. The tool sets ACME up itself,
explicitly.
Stalwart answers with a permanent administrator, `admin@DOMAIN`, and its
password. The tool writes that to `credentials.txt` before doing anything else,
so a failure later can't lose it. Then it brings the full stack up from
`compose.yaml` alone, which recreates Stalwart without the one-time
administrator, so no fixed recovery credential outlives the setup.
The tool refuses to set up a Stalwart that isn't in bootstrap mode, so it can
never reconfigure a server that already holds someone's mail.
## Certificates for two services on one pair of ports
Caddy needs certificates to serve HTTPS. Stalwart needs its own for IMAP, POP3,
submission and STARTTLS on SMTP. Both need one for the mail host's name, and
both would normally want port 443 to prove they control it.
They're separated by **ACME challenge type**:
| | Challenge | Port | How |
| --- | --- | --- | --- |
| **Caddy** | TLS-ALPN-01 | 443 | Configured with `disable_http_challenge` for Stalwart's names |
| **Stalwart** | HTTP-01 | 80 | Caddy forwards `/.well-known/acme-challenge/*` for Stalwart's names to Stalwart, untouched. Everything else on port 80 is redirected to HTTPS |
Neither ever answers the other's challenge, and neither needs the other's key.
Stalwart's certificate covers the mail host plus `autoconfig`, `autodiscover`,
`mta-sts` and `ua-auto-config` under the domain, the names its own DNS zone
points at the mail host, and Caddy fronts all five so every challenge reaches
it.
Stalwart starts its order as soon as the ACME account exists, and renews by
itself from then on. If an order fails, usually because DNS isn't in place yet,
Stalwart doesn't try again by itself. `certs` starts a new order.
## Stalwart's automatic IP bans, behind a proxy
Stalwart bans an IP address that behaves like an attacker. For example, 30
requests in a day for scanner paths such as `/wp-admin.php` get an address
banned. That's good protection, but it counts by the address a connection comes
from, and two kinds of traffic reach Stalwart from a single shared address:
1. **Everything through Caddy** arrives from Caddy's address. Unchanged, one bot
scanning `https://mail.example.com` bans Caddy. That was reproduced on
0.16.22: after a scan, autoconfig answered `403` to everyone, and so would
calendar sync and certificate renewal.
**Fix:** Stalwart is told to take the client's address from the
`X-Forwarded-For` header Caddy sets. Re-tested after the change, the scanner
was banned and every other client still got through. This is safe only
because nothing untrusted can reach Stalwart's HTTP port: it's published on
loopback alone, and on the private network its only peers are Caddy, which
sets the header itself, and ihasmail, which sends none.
2. **Every webmail request** arrives from ihasmail's address: every sign-in, and
every push stream opened and dropped as people open and close tabs. A ban on
that one address would be a ban on everyone's webmail.
**Fix:** ihasmail's fixed address is added to Stalwart's allowed addresses.
ihasmail limits sign-in attempts per real client itself, so repeated wrong
passwords are still slowed down, just not by banning the webmail.
On 0.16.22 **neither setting takes effect until Stalwart restarts**. Applied to
a running server, a scan straight afterwards still banned Caddy. So the tool
restarts Stalwart after making both changes, before anything else depends on
them.
## The webmail's side
ihasmail is deployed the way its own documentation recommends for this
situation:
- **Immutable**: read-only root filesystem, no volume, sessions in memory. A
restart signs everyone out and loses nothing else, because everything durable,
each user's settings included, lives in Stalwart.
- **Private route to Stalwart** (`STALWART_URL=http://stalwart:8080`): the
credentials that travel on that leg never leave the host.
- **Push by subscription** (`PUSH_URL=https://webmail.example.com`): Stalwart
posts mailbox changes to ihasmail instead of holding a connection open for
every browser tab. If Stalwart can't reach that URL, every tab uses the
ordinary relay instead and nothing breaks. `/api/health` shows which is in use.
- **Behind a trusted proxy** (`TRUST_PROXY=1`): ihasmail believes Caddy's
forwarded headers, because Caddy is on a private network range, so it sets
secure cookies and attributes sign-in attempts to the real client.
+204
View File
@@ -0,0 +1,204 @@
# Reference
Everything an operator looks up: requirements, installing, each command and
flag, what the deployment directory holds, and running, upgrading, backing up
and removing a deployment. For a walk-through, see the
[guide on docs.ihasmail.org](https://docs.ihasmail.org/install/oneshot/). Back
to the [README](../README.md).
## Requirements
**On the host:**
- Linux, amd64 or arm64.
- Docker Engine with the compose plugin (`docker compose version` works), run
as a user allowed to use Docker.
**For a mail host, additionally:**
- A domain whose DNS you control.
- **Ports 25, 80, 443, 465, 993, 995 and 4190** free on the host and open in any
firewall or cloud security group in front of it.
- **Outbound port 25.** Many cloud and VPS providers block it by default and
unblock it on request. Without it you can receive mail but not send it.
- **Reverse DNS**: a PTR record for the host's IP address naming the mail host
(`mail.example.com`). This is set at your hosting provider, not in your DNS
zone. Many receiving servers reject mail from an address without one.
- A static public IP address.
**Resources:** ihasmail on its own needs 256 MiB of RAM at minimum. Stalwart's
needs grow with the mail it stores and the number of people using it; size the
host for Stalwart.
## Installing
### From a release
Download the archive for your architecture and its checksums, verify, and
unpack:
```bash
ARCH=amd64 # or arm64
curl -fsSLO https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest/download/ihasmail-oneshot-linux-$ARCH.tar.gz
curl -fsSLO https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest/download/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
tar -xzf ihasmail-oneshot-linux-$ARCH.tar.gz
sudo install -m 0755 ihasmail-oneshot /usr/local/bin/
ihasmail-oneshot version
```
Every release is built by the [release workflow](../.github/workflows/release.yml)
from a tagged commit on `main`, after the tests and a known-vulnerabilities
check pass. The archives are reproducible: `scripts/build-release.sh` builds the
same bytes from the same commit.
### From source
With Go 1.26.8 or newer:
```bash
git clone https://github.com/Coffey-Labs/ihasmail-oneshot.git
cd ihasmail-oneshot
go build -o ihasmail-oneshot ./cmd/ihasmail-oneshot
```
## Commands
### `deploy`
```text
ihasmail-oneshot deploy --domain example.com [flags]
ihasmail-oneshot deploy --local [flags]
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--domain` | required, or `example.test` with `--local` | The mail domain this server receives for |
| `--local` | off | The loopback-only shape: no mail ports, no Caddy, no certificates |
| `--mail-host` | `mail.DOMAIN` | Stalwart's hostname. Must be one label under the domain, e.g. `mx.example.com` |
| `--webmail-host` | `webmail.DOMAIN` | The webmail's hostname. Any name except Stalwart's |
| `--email` | `postmaster@DOMAIN` | ACME contact address for both Caddy and Stalwart |
| `--user NAME` | none | Create a mailbox `NAME@DOMAIN` with a generated password. Repeat for more |
| `--dir` | `./PROJECT` | Deployment directory to write. Must be new or empty |
| `--project` | `ihasmail-DOMAIN` (dots as dashes) | Compose project name, which prefixes containers, network and volumes |
| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | Stalwart image |
| `--ihasmail-image` | the newest release | ihasmail image. By default the tool looks up ihasmail's newest release and writes it into `compose.yaml` by its dated tag; name an image to use a particular one |
| `--caddy-image` | `caddy:2.11.4` | Caddy image |
| `--webmail-bind` | `127.0.0.1:8080` | Host address for ihasmail's own port, for reaching it without Caddy |
| `--stalwart-bind` | `127.0.0.1:8081` | Host address for Stalwart's plain-HTTP port. The tool configures Stalwart through it |
| `--subnet` | `172.31.253.0/24` | The stack's private network. Change it if it overlaps a network you already have |
| `--acme-directory` | Let's Encrypt | ACME directory URL of a private CA, for both Caddy and Stalwart |
| `--acme-ca-root` | none | PEM file of the root the private CA's HTTPS endpoint is signed by |
| `--yes` | off | Don't ask for confirmation. Required without a terminal |
Stalwart's and Caddy's defaults are the versions tested together for this release. ihasmail's default is its newest release, looked up when the tool runs and written into `compose.yaml` by its dated tag.
### `certs`
```text
ihasmail-oneshot certs --dir DIR
```
Starts a new certificate order in Stalwart for the deployment in `DIR` and waits
for it, typically after fixing DNS. If Stalwart already holds a valid
certificate for the mail host, it reports that and does nothing.
### `destroy`
```text
ihasmail-oneshot destroy --dir DIR [--yes]
```
Removes the deployment in `DIR` with all of its data. See
[Removing a deployment](#removing-a-deployment).
### `version`
Prints the tool's version.
## The deployment directory
The deploy writes a directory named after the domain, `./ihasmail-example-com`
by default (`--dir` to choose):
| File | Mode | What it is |
| --- | --- | --- |
| `compose.yaml` | 0644 | The whole deployment: three services, a private network, named volumes. Commented |
| `Caddyfile` | 0644 | Caddy's configuration: the webmail, Stalwart's web names, and port 80's challenge forwarding |
| `.env` | 0600 | `APP_SECRET`, which seals ihasmail's session cookies. Read by compose |
| `credentials.txt` | 0600 | The administrator's and each mailbox's generated password, plus what `certs` needs to reach Stalwart |
| `dns-records.zone` | 0644 | The DNS records to publish |
| `acme-ca-root.pem`, `ca-bundle.crt` | 0644 | Only with `--acme-ca-root`: the private CA, and the system roots with it added |
Data lives in Docker named volumes, prefixed with the project name:
| Volume | Holds |
| --- | --- |
| `…_stalwart-data` | All mail, accounts, calendars, contacts, files, settings, DKIM keys, Stalwart's certificates |
| `…_stalwart-etc` | Stalwart's store location file |
| `…_caddy-data` | Caddy's certificates and ACME account |
| `…_caddy-config` | Caddy's autosaved configuration |
ihasmail has no volume. It runs read-only and keeps sessions in memory.
## Running a deployment
The directory is a standard compose project. From inside it:
```bash
docker compose ps # what's running
docker compose logs -f stalwart # follow Stalwart's log
docker compose logs caddy | grep -i error
docker compose restart ihasmail # signs everyone out of the webmail; nothing else is lost
docker compose down # stop everything (data stays in the volumes)
docker compose up -d # start it again
```
The containers restart by themselves after a crash or a reboot
(`restart: unless-stopped`).
### Upgrading
Nothing upgrades on its own: every image in `compose.yaml` is a fixed version,
ihasmail included. To upgrade, change the image tag there and apply it:
```bash
docker compose pull && docker compose up -d
```
- **ihasmail** is safe to move to any newer release that supports your
Stalwart version. Its release notes say which. The newest is on
[ihasmail's releases](https://github.com/Coffey-Labs/ihasmail/releases); its
image tag is the version with `+` written as `-`, e.g. `2026.9.13-pr344`.
- **Stalwart**: read its upgrade notes before changing versions. Check that the
ihasmail version you run supports the new Stalwart release first, since
ihasmail validates against one Stalwart release at a time. Back up
`stalwart-data` first.
- **Caddy** minor releases are routine.
### Backing up
Everything that can't be recreated is in the `stalwart-data` volume. For a
consistent copy, stop Stalwart briefly:
```bash
docker compose stop stalwart
docker run --rm -v ihasmail-example-com_stalwart-data:/data -v "$PWD":/backup alpine \
tar -czf /backup/stalwart-data-$(date +%F).tar.gz -C /data .
docker compose start stalwart
```
Keep `caddy-data` too, or certificates are requested again on a rebuild. That's
harmless unless it happens often enough to meet Let's Encrypt's rate limits. Keep
the deployment directory itself, which is small and contains your secrets.
### Removing a deployment
```bash
ihasmail-oneshot destroy --dir ihasmail-example-com
```
This removes the containers, the network, **the volumes with every message and
account**, and the files the tool wrote. It asks first unless given `--yes`.
The directory goes too, unless it holds files you added yourself; those are left
in place, and so is the directory around them.
+44
View File
@@ -0,0 +1,44 @@
# Security model
What a deployment exposes to the internet, where its secrets live, and the
trust decisions it makes on your behalf. The reasoning behind those decisions is
in [How it works](how-it-works.md). To report a vulnerability, see
[SECURITY.md](../SECURITY.md). Back to the [README](../README.md).
**What's reachable from outside** (mail host shape):
| Port | Service |
| --- | --- |
| 25 | SMTP, Stalwart (receiving mail; STARTTLS) |
| 80 | Caddy: redirects to HTTPS, and ACME HTTP-01 challenges for Stalwart |
| 443 (TCP, UDP) | Caddy: the webmail, and Stalwart's web side |
| 465 | SMTP submission with TLS, Stalwart |
| 993 | IMAP with TLS, Stalwart |
| 995 | POP3 with TLS, Stalwart |
| 4190 | ManageSieve, Stalwart |
Stalwart's plain-HTTP port (8080) and ihasmail's port are published on
`127.0.0.1` only. In `--local` mode, those two loopback ports are all that's
published.
**Secrets:**
- `APP_SECRET` is 48 random bytes, in `.env` (0600). It seals ihasmail's session
cookies.
- Generated passwords come from the operating system's cryptographic random
source, letters and digits only, in `credentials.txt` (0600).
- The one-time bootstrap password is never written to disk, and Stalwart is
recreated without it once setup completes.
**Trust decisions the deployment makes**, each explained in [How it works](how-it-works.md):
- Stalwart believes `X-Forwarded-For` on its HTTP port, which only Caddy and
ihasmail can reach.
- ihasmail's address is exempt from Stalwart's automatic bans.
- ihasmail believes forwarded headers from private-range peers, which here is
Caddy.
**What the tool doesn't do:** configure a host firewall, harden the Docker
daemon, set up backups or monitoring, or turn on encryption at rest for
mailboxes. Encryption at rest can't be turned off again once on, which is not a
decision for a deploy tool to make.
+89
View File
@@ -0,0 +1,89 @@
# Troubleshooting and known limits
Problems by the message or symptom you see, what causes each, and what to do;
then the limits of what the tool does. Back to the [README](../README.md), or
see the [guide on docs.ihasmail.org](https://docs.ihasmail.org/install/oneshot/).
## Problems
**`port 443 is already in use on this host`**
Something else, often an existing web server, holds a port the mail host needs.
Free it, or use a separate host. With `--local`, move `--webmail-bind` or
`--stalwart-bind` instead.
**`project ihasmail-example-com already exists in Docker`**
An earlier run left containers or volumes behind. If it's a failed attempt you
want to discard: `ihasmail-oneshot destroy --dir ihasmail-example-com --yes`,
then deploy again. To keep it and deploy another, pass a different `--project`
and `--dir`.
**`... already has files in it; give --dir a new or empty directory`**
The tool never writes over an existing deployment. Choose another directory, or
destroy the old deployment first.
**A deploy stopped partway.**
The error says which step failed and shows the last lines of the relevant
container's log. The stack is left as it was, for you to inspect. To start
again from nothing: `ihasmail-oneshot destroy --dir DIR --yes`, fix the cause,
and deploy again.
**`Pool overlaps with other one on this address space`**
The default private network `172.31.253.0/24` collides with a Docker network you
already have. Destroy the partial deployment and deploy again with, for
example, `--subnet 172.31.200.0/24`.
**`Stalwart has no certificate yet`**
Usually DNS: the mail host or one of the `autoconfig`, `autodiscover`,
`mta-sts`, `ua-auto-config` names doesn't resolve to this host yet, or port 80
isn't reachable from the internet. Check with `dig +short mail.example.com` from
somewhere else, fix it, then run `ihasmail-oneshot certs --dir DIR`. Stalwart's
reasons are in its log:
```bash
docker compose logs stalwart | grep -i acme
```
**`Caddy has no certificate for webmail.example.com yet`**
Same causes. Caddy retries by itself with increasing delays. See
`docker compose logs caddy | grep -i error`.
**Mail arrives but sent mail never does.**
Outbound port 25 is almost always the cause. Find the mail server of a domain
you send to with `dig +short MX example.net`, then test from the host with
`nc -vz <that mail server> 25`. Stalwart's log names each delivery
failure (`docker compose logs stalwart`). Also check that reverse DNS for the host's address
names the mail host.
**Sent mail lands in spam.**
Check that the SPF, DKIM and DMARC records from `dns-records.zone` are
published exactly, and that reverse DNS is set. New addresses need a little time
to build a sending reputation.
**The webmail signs everyone out.**
Expected after `docker compose restart ihasmail`, any ihasmail upgrade, or a
reboot: sessions live in memory.
**`/api/health` shows push accounts `pending` that never become `verified`.**
Stalwart can't reach `https://webmail.example.com` from inside its container.
Mail still updates in real time over the relay. The usual causes are a host
firewall that drops traffic from Docker's bridge to the host's own public
address, or the webmail name not resolving from the host.
## Known limits
- **One mail domain per deploy.** More can be added afterwards in Stalwart or in
ihasmail's Administration. Their certificates and DNS records are then yours
to arrange.
- **Linux Docker hosts only.** The tool drives the local Docker daemon and
publishes ports on the host it runs on.
- **IPv4 on the private network.** Ports are also published on IPv6 wherever
Docker does so on the host.
- **Fresh deployments only.** It doesn't import an existing Stalwart, or adopt a
deployment it didn't write.
- **Stalwart doesn't retry a failed certificate order** by itself, on a
transient CA error either. `certs` starts a new one.
- **Push by subscription isn't covered by the end-to-end test**, whose lab has no
public DNS for Stalwart to resolve the webmail's name through. Its fallback,
the relay, is what the test exercises.
- Validated against **Stalwart 0.16.22**. Other Stalwart versions may change the
registry objects the setup uses.
+11 -1
View File
@@ -96,6 +96,16 @@ ok "deploy completed and signed in through ihasmail"
grep -q "certificate issued by CN=Pebble" "$WORK/deploy.log" || die "deploy did not report Stalwart's certificate" grep -q "certificate issued by CN=Pebble" "$WORK/deploy.log" || die "deploy did not report Stalwart's certificate"
ok "deploy reported Stalwart's certificate" ok "deploy reported Stalwart's certificate"
# No --ihasmail-image, so the deploy took the newest release -- and must have
# written it down as a dated tag, never the moving :latest.
image=$(sed -n 's/^ image: \(ghcr.io\/coffey-labs\/ihasmail[:@].*\)$/\1/p' "$DEPLOY/compose.yaml")
[[ "$image" =~ ^ghcr\.io/coffey-labs/ihasmail:[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}-(pr[0-9]+|g[0-9a-f]+)$ ]] \
|| die "compose.yaml does not pin ihasmail to a dated release: ${image:-none}"
ok "compose.yaml pins ihasmail to $image"
running=$(docker compose --project-directory "$DEPLOY" ps -q ihasmail | xargs docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^IHASMAIL_VERSION=//p')
[ "${running/+/-}" = "${image##*:}" ] || die "ihasmail runs $running, but compose.yaml says ${image##*:}"
ok "the running ihasmail is the release compose.yaml names ($running)"
# expect CODE curl-args...: retry for up to 30s until curl gets CODE. Caddy # expect CODE curl-args...: retry for up to 30s until curl gets CODE. Caddy
# obtains certificates for its names in parallel and in the background, so the # obtains certificates for its names in parallel and in the background, so the
# first handshake for any one of them can come a few seconds after deploy. # first handshake for any one of them can come a few seconds after deploy.
@@ -173,7 +183,7 @@ ok "dns-records.zone has the MX and DKIM records"
certs_out=$("$BIN" certs --dir "$DEPLOY" 2>&1 || true) certs_out=$("$BIN" certs --dir "$DEPLOY" 2>&1 || true)
grep -q "already holds a certificate for $MAIL" <<<"$certs_out" || die "certs did not see the existing certificate: $certs_out" grep -q "already holds a certificate for $MAIL" <<<"$certs_out" || die "certs did not see the existing certificate: $certs_out"
ok "certs recognises the certificate already issued" ok "certs recognizes the certificate already issued"
# --- the auto-ban ------------------------------------------------------------- # --- the auto-ban -------------------------------------------------------------
# Last, so nothing earlier can be affected by a ban. Scans come from throwaway # Last, so nothing earlier can be affected by a ban. Scans come from throwaway
+41 -10
View File
@@ -22,16 +22,47 @@ import (
"strings" "strings"
) )
// Versions this release was tested with, end to end. Stalwart is pinned // Stalwart is pinned to the release this version of the tool was tested with,
// because ihasmail validates against one Stalwart release at a time; the // because a Stalwart upgrade migrates its store with no way back and ihasmail
// ihasmail tag is the newest release at the time; Caddy is pinned so that a // validates against one Stalwart release at a time. Caddy is pinned so that a
// redeploy months from now renders the same proxy. // deploy months from now renders the same proxy.
//
// ihasmail is not pinned here. A pin went stale within days of each release,
// and ihasmail's image cleanup keeps ten releases, so an old default would in
// time stop pulling at all. The default is the newest release instead, looked
// up when the tool runs and written into compose.yaml as its dated tag -- see
// deploy.ResolveIhasmail -- so what a deployment runs is still recorded and
// nothing moves it afterwards.
const ( const (
DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22" DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22"
DefaultIhasmailImage = "ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328"
DefaultCaddyImage = "caddy:2.11.4" DefaultCaddyImage = "caddy:2.11.4"
IhasmailRepository = "ghcr.io/coffey-labs/ihasmail"
// NewestIhasmail is the default --ihasmail-image. Only full releases move
// this tag; prereleases never do.
NewestIhasmail = IhasmailRepository + ":latest"
) )
// ihasmail's versions are the date of a commit and where it came from --
// 2026.9.13+pr344, or 2026.9.13+g1fa6578 for a commit that arrived without a
// pull request -- and its image tags are the same with the "+" as "-", since a
// Docker tag may not contain "+".
var ihasmailVersionRE = regexp.MustCompile(`^\d{4}\.\d{1,2}\.\d{1,2}\+(?:pr\d+|g[0-9a-f]{7,40})$`)
// IhasmailTag is the image tag an ihasmail version is published under. A
// version that is not a release's -- empty, or the 0.0.0 of a build nobody
// gave a version to -- has none.
func IhasmailTag(version string) (string, bool) {
if !ihasmailVersionRE.MatchString(version) {
return "", false
}
return strings.Replace(version, "+", "-", 1), true
}
// FollowsNewestIhasmail reports whether the plan still asks for the newest
// ihasmail release rather than a particular image.
func (p Plan) FollowsNewestIhasmail() bool { return p.IhasmailImage == NewestIhasmail }
// Stalwart's ACME order covers these next to the mail host, all under the mail // 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 // 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. // Caddy has to answer for every one of them on port 80 or the order fails.
@@ -123,7 +154,7 @@ var (
imageRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*(?::[A-Za-z0-9._-]+)?(?:@sha256:[a-f0-9]{64})?$`) imageRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*(?::[A-Za-z0-9._-]+)?(?:@sha256:[a-f0-9]{64})?$`)
) )
func normaliseHost(s string) string { func normalizeHost(s string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".")
} }
@@ -136,7 +167,7 @@ func (o Options) Validate() (Plan, error) {
p := Plan{Local: o.Local} p := Plan{Local: o.Local}
p.Domain = normaliseHost(o.Domain) p.Domain = normalizeHost(o.Domain)
switch { switch {
case p.Domain == "" && o.Local: case p.Domain == "" && o.Local:
p.Domain = "example.test" p.Domain = "example.test"
@@ -153,7 +184,7 @@ func (o Options) Validate() (Plan, error) {
p.Domain = "domain.invalid" p.Domain = "domain.invalid"
} }
p.MailHost = normaliseHost(o.MailHost) p.MailHost = normalizeHost(o.MailHost)
if p.MailHost == "" { if p.MailHost == "" {
p.MailHost = "mail." + p.Domain p.MailHost = "mail." + p.Domain
} }
@@ -164,7 +195,7 @@ func (o Options) Validate() (Plan, error) {
fail("--mail-host %q must be one label under the domain, e.g. mail.%s", o.MailHost, p.Domain) fail("--mail-host %q must be one label under the domain, e.g. mail.%s", o.MailHost, p.Domain)
} }
p.WebmailHost = normaliseHost(o.WebmailHost) p.WebmailHost = normalizeHost(o.WebmailHost)
if p.WebmailHost == "" { if p.WebmailHost == "" {
p.WebmailHost = "webmail." + p.Domain p.WebmailHost = "webmail." + p.Domain
} }
@@ -225,7 +256,7 @@ func (o Options) Validate() (Plan, error) {
} }
p.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage) p.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage)
p.IhasmailImage = orDefault(o.IhasmailImage, DefaultIhasmailImage) p.IhasmailImage = orDefault(o.IhasmailImage, NewestIhasmail)
p.CaddyImage = orDefault(o.CaddyImage, DefaultCaddyImage) 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} { for flag, img := range map[string]string{"--stalwart-image": p.StalwartImage, "--ihasmail-image": p.IhasmailImage, "--caddy-image": p.CaddyImage} {
if !imageRE.MatchString(img) { if !imageRE.MatchString(img) {
+38
View File
@@ -98,3 +98,41 @@ func TestEveryProblemAtOnce(t *testing.T) {
t.Errorf("got %d errors, want 3: %v", len(lines), err) t.Errorf("got %d errors, want 3: %v", len(lines), err)
} }
} }
func TestIhasmailFollowsTheNewestReleaseUnlessNamed(t *testing.T) {
p, err := Options{Domain: "example.com"}.Validate()
if err != nil {
t.Fatal(err)
}
if !p.FollowsNewestIhasmail() || p.IhasmailImage != "ghcr.io/coffey-labs/ihasmail:latest" {
t.Errorf("default ihasmail image %q", p.IhasmailImage)
}
named, err := Options{Domain: "example.com", IhasmailImage: "ghcr.io/coffey-labs/ihasmail:2026.9.13-pr344"}.Validate()
if err != nil {
t.Fatal(err)
}
if named.FollowsNewestIhasmail() {
t.Error("a named image is treated as the newest release")
}
byDigest := "ghcr.io/coffey-labs/ihasmail@sha256:" + strings.Repeat("a", 64)
if _, err := (Options{Domain: "example.com", IhasmailImage: byDigest}).Validate(); err != nil {
t.Errorf("a by-digest image is refused: %v", err)
}
}
func TestIhasmailTag(t *testing.T) {
for version, want := range map[string]string{
"2026.9.13+pr344": "2026.9.13-pr344",
"2026.10.2+g1fa6578": "2026.10.2-g1fa6578",
"0.0.0": "",
"": "",
"2026.9.13": "",
"2026.9.13+pr344\n": "",
"latest": "",
} {
got, ok := IhasmailTag(version)
if got != want || ok != (want != "") {
t.Errorf("IhasmailTag(%q) = %q, %v; want %q", version, got, ok, want)
}
}
}
+44
View File
@@ -84,6 +84,50 @@ func Preflight(ctx context.Context, p config.Plan, log Log) (warnings []string,
return warnings, errors.Join(problems...) return warnings, errors.Join(problems...)
} }
// ResolveIhasmail turns "the newest ihasmail release" into the image that is
// the newest release right now, so compose.yaml records a version rather than
// a tag that moves. A plan that names its own image is returned as it is.
//
// The dated tag is taken from the version the image itself carries, and used
// only once the registry confirms that tag is the very same image; otherwise
// the image is pinned by digest, which is exact but says less to a person
// reading compose.yaml. Either way a later `docker compose pull` cannot move
// the deployment onto a release nobody chose.
func ResolveIhasmail(ctx context.Context, p config.Plan, log Log) (config.Plan, error) {
if !p.FollowsNewestIhasmail() {
return p, nil
}
log.Step("finding ihasmail's newest release")
if err := docker.Pull(ctx, config.NewestIhasmail); err != nil {
return p, fmt.Errorf("could not fetch ihasmail's newest release (%w); name an image with --ihasmail-image to use another", err)
}
newest, err := docker.ImageID(ctx, config.NewestIhasmail)
if err != nil {
return p, err
}
version, err := docker.ImageEnv(ctx, config.NewestIhasmail, "IHASMAIL_VERSION")
if err != nil {
return p, err
}
if tag, ok := config.IhasmailTag(version); ok {
dated := config.IhasmailRepository + ":" + tag
if docker.Pull(ctx, dated) == nil {
if id, err := docker.ImageID(ctx, dated); err == nil && id == newest {
p.IhasmailImage = dated
log.Info("ihasmail %s, recorded as %s", version, dated)
return p, nil
}
}
}
digest, err := docker.RepoDigest(ctx, config.NewestIhasmail, config.IhasmailRepository)
if err != nil {
return p, err
}
log.Warn("ihasmail's newest release (version %q) has no matching dated tag; recording it by digest", version)
p.IhasmailImage = digest
return p, nil
}
// portFree tries to bind an address. A permission error means an unprivileged // 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 // user asking about a low port, which says nothing about whether Docker can
// have it, so it is not reported. // have it, so it is not reported.
+49 -1
View File
@@ -33,6 +33,54 @@ func Output(ctx context.Context, args ...string) (string, error) {
return strings.TrimSpace(stdout.String()), nil return strings.TrimSpace(stdout.String()), nil
} }
// Pull pulls one image. Quiet, because it runs before the plan is confirmed
// and the step already says what it is fetching.
func Pull(ctx context.Context, image string) error {
_, err := Output(ctx, "pull", "--quiet", image)
return err
}
// ImageID is the local ID of an image, which is the same for two references
// only when they are the same image.
func ImageID(ctx context.Context, image string) (string, error) {
return Output(ctx, "image", "inspect", "--format", "{{.Id}}", image)
}
// ImageEnv is the value an image's configuration gives an environment
// variable, or "" when it sets none.
func ImageEnv(ctx context.Context, image, name string) (string, error) {
out, err := Output(ctx, "image", "inspect", "--format", "{{range .Config.Env}}{{println .}}{{end}}", image)
if err != nil {
return "", err
}
return envValue(out, name), nil
}
func envValue(env, name string) string {
for _, line := range strings.Split(env, "\n") {
if v, ok := strings.CutPrefix(line, name+"="); ok {
return v
}
}
return ""
}
// RepoDigest is an image's by-digest reference in one repository, e.g.
// ghcr.io/coffey-labs/ihasmail@sha256:..., which names exactly that image for
// as long as the registry keeps it.
func RepoDigest(ctx context.Context, image, repository string) (string, error) {
out, err := Output(ctx, "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", image)
if err != nil {
return "", err
}
for _, d := range strings.Fields(out) {
if strings.HasPrefix(d, repository+"@") {
return d, nil
}
}
return "", fmt.Errorf("%s has no digest from %s", image, repository)
}
// Versions returns the engine and compose versions, which is also the check // Versions returns the engine and compose versions, which is also the check
// that both are installed and this user may use them. // that both are installed and this user may use them.
func Versions(ctx context.Context) (engine, compose string, err error) { func Versions(ctx context.Context) (engine, compose string, err error) {
@@ -48,7 +96,7 @@ func Versions(ctx context.Context) (engine, compose string, err error) {
return engine, compose, nil return engine, compose, nil
} }
// ProjectLeftovers lists containers, volumes and networks already labelled // ProjectLeftovers lists containers, volumes and networks already labeled
// with a compose project name. Any at all means an earlier run of the same // 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. // project, and its volumes would hand a "fresh" deployment an old server.
func ProjectLeftovers(ctx context.Context, project string) ([]string, error) { func ProjectLeftovers(ctx context.Context, project string) ([]string, error) {
+21
View File
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
package docker
import "testing"
func TestEnvValue(t *testing.T) {
env := "PATH=/usr/local/bin:/usr/bin\nNODE_ENV=production\nIHASMAIL_VERSION=2026.9.13+pr344\nEMPTY=\n"
for name, want := range map[string]string{
"IHASMAIL_VERSION": "2026.9.13+pr344",
"NODE_ENV": "production",
"EMPTY": "",
"MISSING": "",
"IHASMAIL": "", // a prefix of a name is not the name
} {
if got := envValue(env, name); got != want {
t.Errorf("envValue(%q) = %q, want %q", name, got, want)
}
}
}
@@ -31,6 +31,8 @@ services:
ipv4_address: {{.Plan.StalwartIP}} ipv4_address: {{.Plan.StalwartIP}}
ihasmail: ihasmail:
# A fixed release: `docker compose pull` never moves it. To upgrade, change
# the tag here, then `docker compose pull && docker compose up -d`.
image: {{.Plan.IhasmailImage}} image: {{.Plan.IhasmailImage}}
restart: unless-stopped restart: unless-stopped
depends_on: [stalwart] depends_on: [stalwart]