12 KiB
Design: the route to Stalwart 1.0
Status: proposal, 2026-10-26. Nothing here is implemented yet. ARCHITECTURE.md stays the record for the 0.15 → 0.16 migration; this document adds the 1.0 route on top of the same engine.
1. Goal
One supported route from any Stalwart release this tool already handles to 1.0.x:
0.15.5 ──(existing major path)──▶ 0.16.27 ──(1.0 copy)──▶ 1.0.x
0.16.x ──(existing patch path)──▶ 0.16.27 ──(1.0 copy)──▶ 1.0.x
Stalwart 1.0 has a new database layout and can't be upgraded in place. The
data has to be copied into a new, empty store. Stalwart Labs publishes a tool
for that copy, stalwart-migrate (stalwartlabs/stalwart-migrate). It reads
v0.16 without writing to it, resumes after an interruption, verifies its
result and offers a rollback. This tool doesn't replace it. It does the work
around the copy: getting to 0.16.27, checking the server first, rehearsing on
a copy, running the copy and the cutover as one checkpointed run, and
validating the result the way it does for 0.16.
The name clash is unfortunate: our binary is also called stalwart-migrate.
In this document, upstream's tool means Stalwart Labs' copy tool and
this tool means stalwart-migrator.
2. Why build on upstream's tool
A rehearsal on 2026-10-26 took a seeded 0.16.27 server (two users, mail in
several folders, flags, a calendar event, a contact, an active Sieve script,
an app password) to 1.0.0 with upstream's tool. prepare asked four questions
and reported 48 warnings, all of them settings with no 1.0 equivalent. The
final run took 31 seconds with 0 errors, and upstream's verification passed. A
JMAP snapshot before and after was identical for account ids, mailboxes,
message ids, threads, keywords, events, contacts and Sieve scripts. The only
change was a keyword 1.0 adds to every message: $hasattachment or
$hasnoattachment.
A second rehearsal used PostgreSQL and more data: five accounts with 2,000
messages each (400 with attachments, four folders, threads, flags), 200
events, 300 contacts and five Sieve scripts. The first incremental run
copied all 10,000 messages in 3.5 seconds while 0.16.27 kept serving.
Changes made afterwards (new mail, flags, deletions) were picked up by a
second incremental run, and changes after that by run --final, which took
31 seconds, 30 of them spent confirming the source had stopped. Upstream's
verification passed, and the same per-account snapshot of 10,090 messages
matched apart from the keyword above.
A copy engine that already works, keeps ids, and has its own rollback is not something to rebuild. This tool runs it as a separate, unmodified program and never links or patches it.
3. What operators need that the copy doesn't cover
- Getting to 0.16.27. Upstream's tool accepts exactly 0.16.27. Older 0.16 releases need a patch bump first, and 0.15 needs the full 0.16 migration. Both already exist in this tool.
- Knowing what will change before anything does. Some things only show up late: OAuth clients that need registering, OIDC behavior, reverse-proxy headers, Enterprise-only settings on a Community build, the disk needed for a second copy.
- A rehearsal against their own data with no downtime (§6).
- A cutover: point the service at the new binary, configuration and store, for systemd and Docker, while keeping the 0.16 store untouched to fall back to.
- Validation they can read: the same per-account comparison this tool already runs after a 0.16 migration.
- Running unattended. Upstream's
prepareneeds a terminal. Unattended runs need its recorded answers.
4. Plan
internal/plan gains a 1.0 boundary. Crossing it adds two phases:
| Phase | What it does |
|---|---|
preflight |
Existing checks, plus the 1.0 checks (§5) |
hop |
Only when the source isn't 0.16.27: the existing patch or major path up to 0.16.27, as its own checkpointed sub-run |
backup |
Existing: settings and principals dumps, preserved binary and service definition. The 0.16 store itself is never written by the copy |
copy |
Upstream's tool: prepare (or a check of recorded answers), then incremental runs while 0.16 serves mail |
cutover |
Stop 0.16, upstream run --final, install 1.0, rewrite the service, start 1.0 |
validate |
Existing validation plus the 1.0 comparison (§8) |
By default the run stops after hop, so the operator can confirm 0.16.27
works before the 1.0 copy starts. --through 1.0 runs both hops in one go.
Two major boundaries in one unattended run is a choice the operator makes on
purpose, not a default.
5. Preflight additions
Read-only, like every preflight check. Each one names the setting and says what changes.
- Version: exactly 0.16.27 for the copy. Anything older plans a hop.
- Disk: room for a second data store. With an external blob store that 1.0 reuses, only the data store is duplicated. With blobs inside the data store, it's the whole thing.
- Store backend: RocksDB, SQLite, PostgreSQL and MySQL are in scope for
the first release. FoundationDB needs upstream's
-fdbbuild and is reported, not attempted. A SQLite store on NFS is refused while 0.16 runs, which matches upstream's documented limitation. - Cluster: single node only in the first release. Clusters are refused with an explanation; 1.0 also requires a Redis in-memory store for them.
- Edition: the official 1.0 images and binaries are built with the
enterprisefeature, and 1.0 stamps each store with the edition of the build that created it. A store made by the official build can't be opened by a build compiled withoutenterprise, and the reverse is also true. Preflight asks which 1.0 build the operator will run (official by default) and selects upstream's matching tool build (-ossfor builds withoutenterprise). It says plainly that this choice decides which builds can open the new store. - Enterprise-only settings on a Community target: listed by object, since
upstream's
-osstool can't store them. - Things that change behavior (upstream's
prepareasks about each): OIDC providers and client registration,useXForwarded,counter_getin expressions, the LLM spam classifier and AI models, SIG(0) DNS servers, Azure blob authentication, custom roles. Preflight lists which ones the server has, so the operator knows the questions before the window. - Web interface: 1.0 serves only web interface v2. A custom bundle URL is reported.
stalwart-cliplans: if the operator manages settings as code, they need converting (upstream shipscli-plan/convert_plan.py). Reported, not run.
6. Rehearsal with no downtime
stalwart-migrate rehearse --to 1.0 uses upstream's incremental mode, which
reads a running 0.16 server:
- Stage upstream's tool (§7) and a 1.0 binary into the rehearsal's work directory.
- Run upstream's
preparein a pseudo-terminal, accepting the recommended answers. Record every question and answer in the rehearsal report, so the operator sees the real questions in advance. - Run one incremental
runfrom the live 0.16 server into a scratch store in the work directory. - Start a throwaway 1.0 on the scratch store, bound to loopback on unused ports, with the recovery administrator set through the environment.
- Compare the live 0.16 server and the scratch 1.0 per account (§8). Report any differences.
- Stop the throwaway server and delete the scratch store, unless
--keep-artifacts.
Nothing in the rehearsal writes to the live store or touches the live service. It needs the same disk headroom as the real copy, and preflight checks that first.
7. Staging upstream's tool
- Download the pinned release archive (or pull the pinned image for Docker deployments) and check it against the release's published checksums; for images, verify the cosign signature upstream publishes.
- Record the exact version and digest in the run's checkpoint. A resumed run uses the same build or refuses.
- Pin per release of this tool. The supported pair is tested together.
- Never modify it, never pass undocumented flags, and treat its output and
reports as data: parse its exit codes and the documented
statusandreportoutput, and keep the raw logs in the run directory.
8. Validation for 1.0
The existing validate package compares a preflight snapshot with the
migrated server. For 1.0 it gains the comparison the rehearsal above used,
per account:
- mailbox names, roles, totals and unread counts
- message ids, subjects, keywords, thread ids and mailbox membership
- calendar event ids, contact card ids, Sieve script names and the active script
- principals, domains and DKIM keys at server level
Known, documented 1.0 differences are listed in one table in the code and
reported as information, not failures. The first entries are the
$hasattachment and $hasnoattachment keywords. Upstream's own verify result is included in the
report, and a mismatch in either fails the run.
After cutover the report prints what users will notice: OAuth clients sign in again, CalDAV, CardDAV and WebDAV clients fetch their data once more, and search results stay incomplete until the index rebuild finishes. Validation reports the progress of that rebuild.
9. Cutover and falling back
- systemd: install the 1.0 binary beside the old one, write the 1.0
config.jsonfor the new store, and rewrite the unit with the existinginternal/cutovercode. The 0.16 binary, configuration, unit and store stay as they are. - Docker: a new image tag and a new data volume mapped at the path the
target configuration names, using the existing container cutover and
upstream's
--target-path-mapwhere the paths differ.
Falling back before 1.0 has served users: restart 0.16 on its untouched
store. The report prints the exact commands. With a shared external blob
store, this holds only until upstream's final run has replaced the spam model
keys, and the report says so. After 1.0 has served users: upstream's
rollback into a new 0.16 store. This tool prints the command and does not
run it. That matches the existing non-goal: this tool isn't a recovery tool
(ARCHITECTURE.md §4.8).
10. Unattended runs
run accepts a recorded answers.toml (from a rehearsal or an earlier
prepare) and runs upstream's prepare --non-interactive to check it. If a
question is still open, the run stops at preflight and says which. Without
recorded answers and with a terminal attached, upstream's prepare runs
interactively in the operator's terminal.
11. Out of scope for the first release
- clusters and FoundationDB (reported by preflight, not attempted)
- switching an existing 1.0 store between editions
- Windows (upstream's tool doesn't support it natively)
- running upstream's
rollback(printed, never run) - the extra recovery boot on the 0.15 → 0.16 path, which is still waiting on field reports (issue #1). Chaining doesn't change it.
12. Milestones
plan: the 1.0 boundary, thehopandcopyphases,--through.preflight: the checks in §5.- Staging upstream's tool, with checksum and signature verification.
rehearse --to 1.0(§6), including the comparison in §8.runfor systemd: copy, cutover, validation.runfor Docker.- Documentation: the upgrade guide on docs.ihasmail.org, status.md and known problems, then a release.
13. Open questions
- Distribution of upstream's tool on systemd hosts: the release archive (no Docker needed) or the image (matches paths and ownership). Proposal: the archive for systemd, the image for Docker.
- Which 1.0 patch release to target when 1.0.1 and later ship: follow
upstream's latest by default, with
--targetto pin, as this tool does for 0.16.