Add migration scripts and other fixes

This commit is contained in:
2026-09-14 20:36:10 +03:00
parent 4cb65a6f52
commit 1cd38af60e
12 changed files with 604 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@ STALWART_SUBMISSION_PORT=587
STALWART_IMAPS_PORT=993 STALWART_IMAPS_PORT=993
STALWART_ADMIN_BIND=127.0.0.1 STALWART_ADMIN_BIND=127.0.0.1
STALWART_ADMIN_PORT=8081 STALWART_ADMIN_PORT=8081
STALWART_RECOVERY_ADMIN=
# Roundcube port bindings # Roundcube port bindings
ROUNDCUBE_BIND=127.0.0.1 ROUNDCUBE_BIND=127.0.0.1
+111
View File
@@ -0,0 +1,111 @@
# EternityMail
Docker Compose deployment for Stalwart Mail and Roundcube.
## Requirements
- Docker Engine with the Compose plugin
- A Linux host for the production deployment
- DNS records pointing the mail hostname to the server
- The `ep-roundcube-skin` repository available as a sibling directory if the custom skin is enabled
## Configuration
Create the real environment file from the template:
```bash
cp .env.example .env
```
Edit `.env` and set production values, especially:
- `MAIL_HOSTNAME` and `MAIL_DOMAIN`
- `RC_DB_PASSWORD`
- `RC_DB_ROOT_PASSWORD`
- `RC_DES_KEY`
- `STALWART_RECOVERY_ADMIN`
The real `.env` file is ignored by Git. Do not commit credentials or recovery settings.
## Generate a DES key
On Linux or macOS:
```bash
chmod +x scripts/generate-des-key.sh
scripts/generate-des-key.sh
```
On Windows PowerShell:
```powershell
.\scripts\generate-des-key.ps1
```
Put the generated 24-character value in `RC_DES_KEY` in `.env`.
## Prepare Linux data directories
The Stalwart container runs as uid/gid `2000`. Create the bind-mounted directories and set the required ownership before starting the stack:
```bash
chmod +x scripts/setup-data-dirs.sh
sudo ./scripts/setup-data-dirs.sh
```
The script uses `DATA_ROOT` from `.env` when it is exported in the shell; otherwise it defaults to `/opt/eternitymail`. To use the value from `.env` directly, run the setup command with it loaded or pass the path explicitly:
```bash
sudo DATA_ROOT=/opt/eternitymail ./scripts/setup-data-dirs.sh
```
## Custom Roundcube skin
The default configuration expects the skin at:
```text
../ep-roundcube-skin/eternity
```
relative to this repository. Override the location in `.env` with `ROUNDCUBE_SKIN_PATH` if needed. The skin directory is mounted read-only and selected with `ROUNDCUBE_SKIN_NAME`.
## Start and stop
Validate the rendered configuration:
```bash
docker compose config --quiet
```
Start the services:
```bash
docker compose up -d
```
View service status and logs:
```bash
docker compose ps
docker compose logs -f stalwart
```
Stop the services without deleting persistent data:
```bash
docker compose down
```
Persistent data is stored under `DATA_ROOT` in the `stalwart`, `roundcube-db`, and `roundcube-config` directories.
## Network ports
The host bindings are configurable in `.env`:
- SMTP: `STALWART_SMTP_PORT` (default `25`)
- Submission: `STALWART_SUBMISSION_PORT` (default `587`)
- IMAPS: `STALWART_IMAPS_PORT` (default `993`)
- Stalwart administration: `STALWART_ADMIN_BIND` and `STALWART_ADMIN_PORT` (defaults `127.0.0.1:8081`)
- Roundcube: `ROUNDCUBE_BIND` and `ROUNDCUBE_PORT` (defaults `127.0.0.1:8091`)
The Stalwart and Roundcube containers communicate over the private `eternitymail` Docker network.
+3
View File
@@ -13,6 +13,9 @@ services:
cap_add: cap_add:
- NET_BIND_SERVICE - NET_BIND_SERVICE
environment:
STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN}
ports: ports:
- "${STALWART_SMTP_PORT:-25}:25/tcp" - "${STALWART_SMTP_PORT:-25}:25/tcp"
- "${STALWART_SUBMISSION_PORT:-587}:587/tcp" - "${STALWART_SUBMISSION_PORT:-587}:587/tcp"
+71
View File
@@ -0,0 +1,71 @@
#requires -Version 5.1
<#
.SYNOPSIS
Generates a stalwart-cli apply NDJSON plan (Domain + Account/User + aliases)
from a migration accounts.csv file.
#>
param(
[Parameter(Mandatory = $true)][string]$CsvPath,
[Parameter(Mandatory = $true)][string]$Domain,
[Parameter(Mandatory = $true)][string]$OutFile
)
$rows = Import-Csv -Path $CsvPath
$missing = $rows | Where-Object { -not $_.new_password }
if ($missing) {
throw "accounts.csv has $($missing.Count) row(s) with no new_password set. Fill them in before generating the plan."
}
$lines = New-Object System.Collections.Generic.List[string]
# Domain is upserted so the plan works whether or not the wizard already created it.
$domainOp = [ordered]@{
"@type" = "upsert"
object = "Domain"
matchOn = @("name")
value = @{ "dom" = @{ name = $Domain } }
}
$lines.Add(($domainOp | ConvertTo-Json -Compress -Depth 10))
$accounts = [ordered]@{}
foreach ($row in $rows) {
$localPart = $row.email.Split('@')[0]
$clientId = "acct-$localPart"
$aliases = @{}
if ($row.aliases) {
foreach ($aliasLocal in ($row.aliases -split ';' | Where-Object { $_ })) {
$aliases["alias-$aliasLocal"] = @{ name = $aliasLocal; domainId = "#dom" }
}
}
$quotas = @{}
if ($row.quota_mb) {
$quotas["maxDiskQuota"] = [int64]$row.quota_mb * 1MB
}
$accounts[$clientId] = @{
"@type" = "User"
name = $localPart
domainId = "#dom"
credentials = @{ "pw" = @{ "@type" = "Password"; secret = $row.new_password } }
memberGroupIds = @{}
roles = @{ "@type" = "User" }
permissions = @{ "@type" = "Inherit" }
quotas = $quotas
aliases = $aliases
encryptionAtRest = @{ "@type" = "Disabled" }
}
}
$accountOp = [ordered]@{
"@type" = "upsert"
object = "Account"
matchOn = @("name")
value = $accounts
}
$lines.Add(($accountOp | ConvertTo-Json -Compress -Depth 10))
$lines | Set-Content -Path $OutFile -Encoding utf8
Write-Host "Wrote $($lines.Count) operation(s) covering $($rows.Count) account(s) to $OutFile"
+127
View File
@@ -0,0 +1,127 @@
# Mailbox migration: docker-mailserver → Stalwart
Tools to move accounts, aliases, and mail from a `docker-mailserver` instance into
this Stalwart deployment.
## Overview
| Step | Linux (bash) | Windows (PowerShell) | Purpose |
|---|---|---|---|
| 1 | `export-docker-mailserver.sh` | *(run on the Linux docker-mailserver host)* | Dumps accounts + aliases from docker-mailserver's config files into `accounts.csv` |
| 2 | *(manual)* | *(manual)* | Fill in `new_password` (and `old_password` if not already known) in `accounts.csv` |
| 3 | `generate-account-plan.sh` | `New-AccountPlan.ps1` | Turns `accounts.csv` into a `stalwart-cli apply` NDJSON plan that creates the domain, accounts, and aliases in Stalwart |
| 4 | `stalwart-cli apply` | `stalwart-cli apply` | Applies the generated plan to Stalwart |
| 5 | `sync-mailboxes.sh` | `Sync-Mailboxes.ps1` | Runs `imapsync` (via Docker) for every account, copying all folders/messages from docker-mailserver to Stalwart |
| 6 | *(manual)* | *(manual)* | Verify, then repoint MX/DNS and decommission docker-mailserver |
Both old and new mail servers run Linux, so the bash scripts are the primary path;
the PowerShell scripts are equivalent for driving the migration from a Windows
workstation. Make the bash scripts executable once: `chmod +x migration/*.sh`.
`generate-account-plan.sh` requires `jq` (`apt install jq` / `dnf install jq`).
docker-mailserver stores passwords as one-way hashes (`config/postfix-accounts.cf`),
so they cannot be reused directly in Stalwart. Each account gets a **new** password
during migration; `old_password` is only needed temporarily so `imapsync` can log in
to the source server to copy mail.
## 1. Export accounts and aliases from docker-mailserver
Run on the docker-mailserver host (or `docker exec` into its container), pointing at
its config directory:
```bash
./export-docker-mailserver.sh /path/to/docker-mailserver/config > accounts.csv
```
This produces `accounts.csv` with columns `email,new_password,old_password,aliases,quota_mb`,
`new_password` and `old_password` left blank for you to fill in.
## 2. Fill in passwords
Edit `accounts.csv`:
- `new_password`: the password the account will have on Stalwart. Generate strong
random values; users can change them later via the WebUI.
- `old_password`: the account's **current, plaintext** docker-mailserver password,
needed only so `imapsync` can authenticate to the source during copy. If you don't
have it, reset it temporarily on docker-mailserver with
`setup email update <email> <temp-password>` and record that value here.
- `aliases`: optional, `;`-separated list of extra local-parts on the same domain
that should deliver to this mailbox (e.g. `info;sales`).
- `quota_mb`: optional mailbox quota in MB (blank = unlimited).
Never commit a filled-in `accounts.csv`; it contains plaintext passwords.
## 3. Generate the Stalwart account plan
Linux:
```bash
./generate-account-plan.sh -c accounts.csv -d eternityproject.fi -o accounts-plan.ndjson
```
Windows:
```powershell
./New-AccountPlan.ps1 -CsvPath accounts.csv -Domain eternityproject.fi -OutFile accounts-plan.ndjson
```
This upserts the `Domain`, one `Account/User` per row (with a `Password` credential
set to `new_password` and any `aliases`), so re-running after edits is safe.
## 4. Apply the plan to Stalwart
Linux:
```bash
export STALWART_URL="https://mail.eternityproject.fi"
export STALWART_USER="admin"
export STALWART_PASSWORD="<admin password>"
stalwart-cli apply --file accounts-plan.ndjson --dry-run
stalwart-cli apply --file accounts-plan.ndjson
```
Windows:
```powershell
$env:STALWART_URL = "https://mail.eternityproject.fi"
$env:STALWART_USER = "admin"
$env:STALWART_PASSWORD = "<admin password>"
stalwart-cli apply --file accounts-plan.ndjson --dry-run
stalwart-cli apply --file accounts-plan.ndjson
```
## 5. Copy mail with imapsync
Linux:
```bash
./sync-mailboxes.sh -c accounts.csv -s mail.old-domain.example -t mail.eternityproject.fi -n
```
Windows:
```powershell
./Sync-Mailboxes.ps1 `
-CsvPath accounts.csv `
-SourceHost mail.old-domain.example `
-DestHost mail.eternityproject.fi `
-DryRun
```
(`-n` / `-DryRun` runs imapsync in dry-run mode first; drop it to actually copy messages.)
Review the dry-run output, then re-run without `-DryRun` to actually copy messages.
The script is safe to re-run: `imapsync` skips messages that already exist at the
destination.
## 6. Cut over
- Spot-check folder counts/message counts on a few mailboxes in the Stalwart WebUI.
- Run `Sync-Mailboxes.ps1` once more shortly before cutover to catch mail received
during the migration window.
- Update MX records to point at `mail.eternityproject.fi` (see the top-level
[README.md](../README.md)).
- Once confirmed, decommission docker-mailserver and rotate the temporary
`old_password` values you set in step 2.
+44
View File
@@ -0,0 +1,44 @@
#requires -Version 5.1
<#
.SYNOPSIS
Runs imapsync (via the dockerized imapsync/imapsync image) for every account
in a migration accounts.csv, copying mail from docker-mailserver to Stalwart.
#>
param(
[Parameter(Mandatory = $true)][string]$CsvPath,
[Parameter(Mandatory = $true)][string]$SourceHost,
[Parameter(Mandatory = $true)][string]$DestHost,
[int]$SourcePort = 993,
[int]$DestPort = 993,
[switch]$DryRun
)
$rows = Import-Csv -Path $CsvPath
$missing = $rows | Where-Object { -not $_.old_password -or -not $_.new_password }
if ($missing) {
throw "accounts.csv has $($missing.Count) row(s) missing old_password or new_password. Fill them in before syncing."
}
foreach ($row in $rows) {
Write-Host "==> Syncing $($row.email)"
$dockerArgs = @(
"run", "--rm", "imapsync/imapsync",
"--host1", $SourceHost, "--port1", $SourcePort, "--ssl1",
"--user1", $row.email, "--password1", $row.old_password,
"--host2", $DestHost, "--port2", $DestPort, "--ssl2",
"--user2", $row.email, "--password2", $row.new_password,
"--automap", "--syncinternaldates", "--skipcrossduplicates",
"--no-modulesversion"
)
if ($DryRun) {
$dockerArgs += "--dry"
}
docker @dockerArgs
if ($LASTEXITCODE -ne 0) {
Write-Warning "imapsync exited with code $LASTEXITCODE for $($row.email)"
}
}
+3
View File
@@ -0,0 +1,3 @@
email,new_password,old_password,aliases,quota_mb
alice@eternityproject.fi,,,,
bob@eternityproject.fi,,,info;sales,2048
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Dumps accounts + aliases from a docker-mailserver config directory into a
# migration CSV skeleton (email,new_password,old_password,aliases,quota_mb).
# Passwords are left blank: docker-mailserver only stores irreversible hashes.
set -euo pipefail
CONFIG_DIR="${1:?Usage: $0 /path/to/docker-mailserver/config}"
ACCOUNTS_FILE="$CONFIG_DIR/postfix-accounts.cf"
ALIASES_FILE="$CONFIG_DIR/postfix-virtual.cf"
if [[ ! -f "$ACCOUNTS_FILE" ]]; then
echo "postfix-accounts.cf not found under $CONFIG_DIR" >&2
exit 1
fi
echo "email,new_password,old_password,aliases,quota_mb"
while IFS='|' read -r email _hash; do
[[ -z "$email" || "$email" == \#* ]] && continue
aliases=""
if [[ -f "$ALIASES_FILE" ]]; then
# postfix-virtual.cf lines look like: alias@domain destination@domain
aliases=$(awk -v dest="$email" '$2 == dest { sub(/@.*/, "", $1); printf "%s;", $1 }' "$ALIASES_FILE")
aliases="${aliases%;}"
fi
echo "$email,,,${aliases},"
done < "$ACCOUNTS_FILE"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Generates a stalwart-cli apply NDJSON plan (Domain + Account/User + aliases)
# from a migration accounts.csv file. Requires jq.
set -euo pipefail
usage() { echo "Usage: $0 -c accounts.csv -d domain.tld -o accounts-plan.ndjson" >&2; exit 1; }
while getopts "c:d:o:" opt; do
case "$opt" in
c) CSV="$OPTARG" ;;
d) DOMAIN="$OPTARG" ;;
o) OUT="$OPTARG" ;;
*) usage ;;
esac
done
[[ -z "${CSV:-}" || -z "${DOMAIN:-}" || -z "${OUT:-}" ]] && usage
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
: > "$OUT"
jq -nc --arg domain "$DOMAIN" \
'{"@type":"upsert","object":"Domain","matchOn":["name"],"value":{"dom":{"name":$domain}}}' >> "$OUT"
accounts_file=$(mktemp)
trap 'rm -f "$accounts_file"' EXIT
# Process substitution keeps the loop in the current shell, so a missing
# password can abort the whole script instead of just a pipeline subshell.
while IFS=',' read -r email new_password old_password aliases quota_mb; do
[[ -z "$email" ]] && continue
if [[ -z "$new_password" ]]; then
echo "Missing new_password for $email" >&2
exit 1
fi
local_part="${email%%@*}"
aliases_json=$(jq -n --arg aliases "$aliases" '
($aliases | split(";") | map(select(length > 0))) as $names
| reduce $names[] as $n ({}; . + {("alias-" + $n): {name: $n, domainId: "#dom"}})')
quotas_json="{}"
if [[ -n "$quota_mb" ]]; then
quotas_json=$(jq -n --argjson mb "$quota_mb" '{"maxDiskQuota": ($mb * 1048576)}')
fi
jq -n --arg id "acct-$local_part" --arg name "$local_part" --arg secret "$new_password" \
--argjson aliases "$aliases_json" --argjson quotas "$quotas_json" '
{ ($id): {
"@type": "User",
name: $name,
domainId: "#dom",
credentials: { pw: { "@type": "Password", secret: $secret } },
memberGroupIds: {},
roles: { "@type": "User" },
permissions: { "@type": "Inherit" },
quotas: $quotas,
aliases: $aliases,
encryptionAtRest: { "@type": "Disabled" }
}
}' >> "$accounts_file"
done < <(tail -n +2 "$CSV")
accounts_value=$(jq -s 'add' "$accounts_file")
jq -nc --argjson value "$accounts_value" \
'{"@type":"upsert","object":"Account","matchOn":["name"],"value":$value}' >> "$OUT"
echo "Wrote plan to $OUT"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Runs imapsync (via the dockerized imapsync/imapsync image) for every account
# in a migration accounts.csv, copying mail from docker-mailserver to Stalwart.
set -euo pipefail
SOURCE_PORT=993
DEST_PORT=993
DRY_RUN=false
usage() {
echo "Usage: $0 -c accounts.csv -s source-host -t dest-host [-p source-port] [-q dest-port] [-n]" >&2
exit 1
}
while getopts "c:s:t:p:q:n" opt; do
case "$opt" in
c) CSV="$OPTARG" ;;
s) SOURCE_HOST="$OPTARG" ;;
t) DEST_HOST="$OPTARG" ;;
p) SOURCE_PORT="$OPTARG" ;;
q) DEST_PORT="$OPTARG" ;;
n) DRY_RUN=true ;;
*) usage ;;
esac
done
[[ -z "${CSV:-}" || -z "${SOURCE_HOST:-}" || -z "${DEST_HOST:-}" ]] && usage
while IFS=',' read -r email new_password old_password aliases quota_mb; do
[[ -z "$email" ]] && continue
if [[ -z "$old_password" || -z "$new_password" ]]; then
echo "Missing old_password or new_password for $email" >&2
exit 1
fi
echo "==> Syncing $email"
args=(run --rm imapsync/imapsync
--host1 "$SOURCE_HOST" --port1 "$SOURCE_PORT" --ssl1 --user1 "$email" --password1 "$old_password"
--host2 "$DEST_HOST" --port2 "$DEST_PORT" --ssl2 --user2 "$email" --password2 "$new_password"
--automap --syncinternaldates --skipcrossduplicates --no-modulesversion)
[[ "$DRY_RUN" == true ]] && args+=(--dry)
if ! docker "${args[@]}"; then
echo "warning: imapsync exited non-zero for $email" >&2
fi
done < <(tail -n +2 "$CSV")
+81
View File
@@ -0,0 +1,81 @@
# Utility Scripts
These scripts support the EternityMail Docker Compose deployment. Run them from the repository root.
## `generate-des-key.sh`
Generates a random 24-character alphanumeric value for Roundcube's `RC_DES_KEY` setting.
Requirements:
- Linux, macOS, or another Unix-like shell
- `/dev/urandom`
Usage:
```bash
chmod +x scripts/generate-des-key.sh
scripts/generate-des-key.sh
```
Copy the printed value into `.env`:
```dotenv
RC_DES_KEY=<generated-value>
```
Keep this value stable after Roundcube has been deployed. Changing it can make existing encrypted session data unreadable.
## `generate-des-key.ps1`
Generates the same type of 24-character key using PowerShell.
Requirements:
- PowerShell 5.1 or PowerShell 7+
Usage from PowerShell:
```powershell
.\scripts\generate-des-key.ps1
```
Copy the printed value into `RC_DES_KEY` in `.env`.
## `setup-data-dirs.sh`
Creates the host directories used by the Stalwart and Roundcube containers and assigns ownership of the Stalwart directories to uid/gid `2000`, which is the user used by the Stalwart image.
Requirements:
- Linux or another Unix-like host
- Permission to create and change ownership under `DATA_ROOT`
The script does not read `.env` automatically. Set `DATA_ROOT` explicitly when running it:
```bash
chmod +x scripts/setup-data-dirs.sh
sudo DATA_ROOT=/opt/eternitymail ./scripts/setup-data-dirs.sh
```
When `DATA_ROOT` is omitted, the script uses `/opt/eternitymail`.
The created directories are:
- `$DATA_ROOT/stalwart/etc`
- `$DATA_ROOT/stalwart/data`
- `$DATA_ROOT/stalwart/certs`
- `$DATA_ROOT/roundcube-db`
- `$DATA_ROOT/roundcube-config`
Only `$DATA_ROOT/stalwart` is changed to uid/gid `2000:2000`; the MariaDB container manages its own data-directory permissions.
## Typical order
```bash
cp .env.example .env
scripts/generate-des-key.sh
sudo DATA_ROOT=/opt/eternitymail ./scripts/setup-data-dirs.sh
docker compose config --quiet
docker compose up -d
```
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Creates the host data directories for stalwart/roundcube and sets ownership
# expected by each container (stalwart runs as uid/gid 2000).
set -euo pipefail
DATA_ROOT="${DATA_ROOT:-/opt/eternitymail}"
mkdir -p \
"$DATA_ROOT/stalwart/etc" \
"$DATA_ROOT/stalwart/data" \
"$DATA_ROOT/stalwart/certs" \
"$DATA_ROOT/roundcube-db" \
"$DATA_ROOT/roundcube-config"
chown -R 2000:2000 "$DATA_ROOT/stalwart"
echo "Data directories ready under $DATA_ROOT"