Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed4556f01 | ||
|
|
3fd909cba3 | ||
|
|
d5b911f8b2 | ||
|
|
0f52bd498a | ||
|
|
98b120f3a5 | ||
|
|
7d3c4d4524 | ||
|
|
153077eab9 | ||
|
|
a4f0953353 | ||
|
|
4627b95ac9 | ||
|
|
2d64e30cb2 | ||
|
|
f874cb8651 | ||
|
|
bbd84da276 | ||
|
|
07ed6ea582 | ||
|
|
97995fdebb | ||
|
|
74fa9321f9 | ||
|
|
408fc20d7b | ||
|
|
c151df45d0 | ||
|
|
eb1ccc415e | ||
|
|
1765b31d5e | ||
|
|
c6e1d026d1 | ||
|
|
83ba353b9f |
@@ -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: light
|
||||||
|
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: light
|
||||||
|
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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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://git.coffeylabs.org/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.
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
# ihasmail-oneshot
|
# ihasmail-oneshot
|
||||||
|
|
||||||
[](https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest)
|
[](https://git.coffeylabs.org/coffey-labs/ihasmail-oneshot/releases/latest)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](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
|
||||||
fresh [ihasmail](https://github.com/Coffey-Labs/ihasmail) webmail, links them
|
fresh [ihasmail](https://git.coffeylabs.org/coffey-labs/ihasmail) webmail, links them
|
||||||
together, gets them certificates, and hands you the administrator password and
|
together, gets them certificates, and hands you the administrator password and
|
||||||
the DNS records to publish.
|
the DNS records to publish.
|
||||||
|
|
||||||
@@ -13,854 +14,80 @@ 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
|
||||||
curl -fsSLO https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest/download/ihasmail-oneshot-linux-$ARCH.tar.gz
|
curl -fsSLO https://git.coffeylabs.org/coffey-labs/ihasmail-oneshot/releases/download/latest/ihasmail-oneshot-linux-$ARCH.tar.gz
|
||||||
curl -fsSLO https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest/download/SHA256SUMS
|
curl -fsSLO https://git.coffeylabs.org/coffey-labs/ihasmail-oneshot/releases/download/latest/SHA256SUMS
|
||||||
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.
|
|
||||||
|
|||||||
+1
-1
@@ -39,5 +39,5 @@ Out of scope, and best reported upstream:
|
|||||||
|
|
||||||
- Vulnerabilities in Stalwart itself
|
- Vulnerabilities in Stalwart itself
|
||||||
- Vulnerabilities in ihasmail itself — see
|
- Vulnerabilities in ihasmail itself — see
|
||||||
[its security policy](https://github.com/Coffey-Labs/ihasmail/security/policy)
|
[its security policy](https://git.coffeylabs.org/coffey-labs/ihasmail/src/branch/main/SECURITY.md)
|
||||||
- Vulnerabilities in Caddy or Docker
|
- Vulnerabilities in Caddy or Docker
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.FollowsNewestIhasmail() {
|
||||||
|
log.Info("images %s, ihasmail's newest release", p.StalwartImage)
|
||||||
|
} else {
|
||||||
log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage)
|
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)
|
||||||
|
|||||||
@@ -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** | `registry.coffeylabs.org/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.
|
||||||
@@ -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://git.coffeylabs.org/coffey-labs/ihasmail-oneshot/releases/download/latest/ihasmail-oneshot-linux-$ARCH.tar.gz
|
||||||
|
curl -fsSLO https://git.coffeylabs.org/coffey-labs/ihasmail-oneshot/releases/download/latest/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://git.coffeylabs.org/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://git.coffeylabs.org/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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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 = "registry.coffeylabs.org/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) {
|
||||||
|
|||||||
@@ -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 != "registry.coffeylabs.org/coffey-labs/ihasmail:latest" {
|
||||||
|
t.Errorf("default ihasmail image %q", p.IhasmailImage)
|
||||||
|
}
|
||||||
|
named, err := Options{Domain: "example.com", IhasmailImage: "registry.coffeylabs.org/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 := "registry.coffeylabs.org/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
// registry.coffeylabs.org/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) {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user