diff --git a/.env.example b/.env.example index 5256420..ed5bf50 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ STALWART_SUBMISSION_PORT=587 STALWART_IMAPS_PORT=993 STALWART_ADMIN_BIND=127.0.0.1 STALWART_ADMIN_PORT=8081 +STALWART_RECOVERY_ADMIN= # Roundcube port bindings ROUNDCUBE_BIND=127.0.0.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f38d4a --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 50e1cdb..4dee341 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,9 @@ services: cap_add: - NET_BIND_SERVICE + environment: + STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN} + ports: - "${STALWART_SMTP_PORT:-25}:25/tcp" - "${STALWART_SUBMISSION_PORT:-587}:587/tcp" diff --git a/migration/New-AccountPlan.ps1 b/migration/New-AccountPlan.ps1 new file mode 100644 index 0000000..9a25b51 --- /dev/null +++ b/migration/New-AccountPlan.ps1 @@ -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" diff --git a/migration/README.md b/migration/README.md new file mode 100644 index 0000000..4ebaf4e --- /dev/null +++ b/migration/README.md @@ -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 ` 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="" + +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 = "" + +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. diff --git a/migration/Sync-Mailboxes.ps1 b/migration/Sync-Mailboxes.ps1 new file mode 100644 index 0000000..cf5a2cd --- /dev/null +++ b/migration/Sync-Mailboxes.ps1 @@ -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)" + } +} diff --git a/migration/accounts.csv.example b/migration/accounts.csv.example new file mode 100644 index 0000000..ebd4846 --- /dev/null +++ b/migration/accounts.csv.example @@ -0,0 +1,3 @@ +email,new_password,old_password,aliases,quota_mb +alice@eternityproject.fi,,,, +bob@eternityproject.fi,,,info;sales,2048 diff --git a/migration/export-docker-mailserver.sh b/migration/export-docker-mailserver.sh new file mode 100644 index 0000000..b96a5d6 --- /dev/null +++ b/migration/export-docker-mailserver.sh @@ -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" diff --git a/migration/generate-account-plan.sh b/migration/generate-account-plan.sh new file mode 100644 index 0000000..301035d --- /dev/null +++ b/migration/generate-account-plan.sh @@ -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" diff --git a/migration/sync-mailboxes.sh b/migration/sync-mailboxes.sh new file mode 100644 index 0000000..cd75645 --- /dev/null +++ b/migration/sync-mailboxes.sh @@ -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") diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..7c965f9 --- /dev/null +++ b/scripts/README.md @@ -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= +``` + +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 +``` diff --git a/scripts/setup-data-dirs.sh b/scripts/setup-data-dirs.sh new file mode 100644 index 0000000..ef2452e --- /dev/null +++ b/scripts/setup-data-dirs.sh @@ -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"