install.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
#
# RayMail installer
# ─────────────────
# curl -fsSL https://raw.githubusercontent.com/DeveloperSarim/raymail/main/install.sh | bash
#
# Brings up a working mail stack on a fresh VPS or a local machine:
# audits ports, writes configuration, starts the containers, completes the
# Stalwart setup handshake, and prints the DNS records you still have to add.
#
# Safe by design: it binds only ports it has verified are free, never touches
# an existing web server, and refuses to overwrite an installation it finds.
#
set -euo pipefail
REPO="https://github.com/DeveloperSarim/raymail.git"
DIR="${RAYMAIL_DIR:-$PWD/raymail}"
IMAGE_UID_STALWART=2000 # the stalwart image runs unprivileged as 2000
IMAGE_UID_WEB=1001 # the web image runs unprivileged as 1001
# ── output ───────────────────────────────────────────────────────────────────
if [ -t 1 ]; then
B=$'\033[1m'; DIM=$'\033[2m'; R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; C=$'\033[36m'; N=$'\033[0m'
else
B=""; DIM=""; R=""; G=""; Y=""; C=""; N=""
fi
step() { printf '\n%s==>%s %s%s%s\n' "$C" "$N" "$B" "$*" "$N"; }
ok() { printf ' %s✓%s %s\n' "$G" "$N" "$*"; }
warn() { printf ' %s!%s %s\n' "$Y" "$N" "$*"; }
die() { printf '\n %s✗ %s%s\n\n' "$R" "$*" "$N" >&2; exit 1; }
banner() {
cat <<'ART'
___ __ __ _ _
| _ \__ _ _ _ ___ | \/ |__ _ (_) |
| / _` | || |(_-< | |\/| / _` || | |
|_|_\__,_|\_, |/__/ |_| |_\__,_||_|_|
|__/ self-hosted mail, telemetry included
ART
}
# ── prerequisites ────────────────────────────────────────────────────────────
need_docker() {
command -v docker >/dev/null 2>&1 || die "Docker is not installed. See https://docs.docker.com/engine/install/"
docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required (docker compose version)."
docker info >/dev/null 2>&1 || die "Cannot talk to the Docker daemon. Is it running, and are you in the docker group?"
ok "docker $(docker --version | awk '{print $3}' | tr -d ,) with compose v2"
}
port_free() {
# Returns 0 when nothing is listening on $1.
if command -v ss >/dev/null 2>&1; then
! ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
elif command -v lsof >/dev/null 2>&1; then
! lsof -iTCP:"$1" -sTCP:LISTEN -n -P >/dev/null 2>&1
else
return 0 # cannot check; assume free and let docker fail loudly
fi
}
# ── main ─────────────────────────────────────────────────────────────────────
banner
step "Checking prerequisites"
need_docker
for t in curl openssl; do
command -v "$t" >/dev/null 2>&1 || die "$t is required but not installed."
done
ok "curl and openssl present"
step "Auditing ports"
BLOCKED=""
for p in 25 465 587 993; do
if port_free "$p"; then ok "port $p free"
else warn "port $p is already in use"; BLOCKED="$BLOCKED $p"; fi
done
[ -n "$BLOCKED" ] && die "Ports in use:$BLOCKED — stop whatever owns them, or run RayMail on another host."
WEB_PORT=3880; ADMIN_PORT=3881
while ! port_free "$WEB_PORT"; do WEB_PORT=$((WEB_PORT+1)); done
while ! port_free "$ADMIN_PORT"; do ADMIN_PORT=$((ADMIN_PORT+1)); done
ok "webmail will bind 127.0.0.1:$WEB_PORT, admin 127.0.0.1:$ADMIN_PORT"
step "Testing outbound SMTP"
# Many providers (Hostinger, DigitalOcean, Oracle, Azure…) block outbound 25.
# If they do, direct MX delivery is impossible and a relay is mandatory — far
# better to learn that now than after mail silently queues for a day.
RELAY_REQUIRED=no
if timeout 8 bash -c 'exec 3<>/dev/tcp/gmail-smtp-in.l.google.com/25' 2>/dev/null; then
ok "outbound port 25 is open — direct delivery to MX is possible"
else
RELAY_REQUIRED=yes
warn "outbound port 25 is BLOCKED by your provider"
warn "outbound mail must go through a relay (Resend, SendGrid, Postmark, SES…)"
fi
# ── collect settings ─────────────────────────────────────────────────────────
step "Configuration"
if [ -t 0 ]; then
read -rp " Mail domain (e.g. mail.example.com): " MAIL_DOMAIN
else
MAIL_DOMAIN="${MAIL_DOMAIN:-}"
fi
[ -n "${MAIL_DOMAIN:-}" ] || die "A mail domain is required. Re-run interactively, or set MAIL_DOMAIN=…"
echo "$MAIL_DOMAIN" | grep -qE '^[a-z0-9.-]+\.[a-z]{2,}$' || die "'$MAIL_DOMAIN' does not look like a domain."
PUBLIC_IP="$(curl -fsS --max-time 10 https://api.ipify.org || echo '')"
[ -n "$PUBLIC_IP" ] && ok "public IP $PUBLIC_IP" || warn "could not determine the public IP"
# ── fetch ────────────────────────────────────────────────────────────────────
step "Installing to $DIR"
if [ -d "$DIR/.git" ]; then
ok "existing checkout found — leaving it alone"
elif [ -e "$DIR" ] && [ -n "$(ls -A "$DIR" 2>/dev/null)" ]; then
die "$DIR exists and is not empty. Move it aside or set RAYMAIL_DIR=…"
else
command -v git >/dev/null 2>&1 || die "git is required to fetch RayMail."
git clone --depth 1 "$REPO" "$DIR" >/dev/null 2>&1 || die "Could not clone $REPO"
ok "cloned"
fi
cd "$DIR"
# ── environment ──────────────────────────────────────────────────────────────
step "Writing configuration"
if [ -f .env ]; then
ok ".env already exists — keeping your settings"
else
ADMIN_PW="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-28)"
TELE="$(openssl rand -hex 32)"
cat > .env <<ENV
MAIL_DOMAIN=$MAIL_DOMAIN
MAIL_HOSTNAME=$MAIL_DOMAIN
APP_URL=https://$MAIL_DOMAIN
STALWART_ADMIN_USER=admin@$MAIL_DOMAIN
STALWART_ADMIN_PASSWORD=$ADMIN_PW
# Outbound relay. Required when your provider blocks port 25.
RELAY_HOST=
RELAY_PORT=587
RELAY_USERNAME=
RELAY_PASSWORD=
RELAY_SPF_INCLUDE=
TELEMETRY_SECRET=$TELE
# Optional AI assistant (drafting, summaries, inbox overview).
DEEPSEEK_API_KEY=
DEEPSEEK_MODEL=deepseek-chat
ENV
chmod 600 .env
ok "generated .env with fresh secrets"
fi
# Bind ports the audit actually cleared, instead of assuming the defaults.
if [ "$WEB_PORT" != "3880" ] || [ "$ADMIN_PORT" != "3881" ]; then
sed -i.bak -e "s|127.0.0.1:3880:3000|127.0.0.1:$WEB_PORT:3000|" \
-e "s|127.0.0.1:3881:8080|127.0.0.1:$ADMIN_PORT:8080|" docker-compose.yml
rm -f docker-compose.yml.bak
ok "compose ports adjusted"
fi
step "Preparing data directories"
# Both images run unprivileged. Root-owned bind mounts are the single most
# common first-boot failure: Stalwart dies with "Permission denied" on its
# RocksDB, and the web container cannot create its SQLite file.
mkdir -p stalwart/etc stalwart/data/logs data
if [ "$(id -u)" = "0" ]; then
chown -R "$IMAGE_UID_STALWART:$IMAGE_UID_STALWART" stalwart/etc stalwart/data
chown -R "$IMAGE_UID_WEB:$IMAGE_UID_WEB" data
ok "ownership set (stalwart $IMAGE_UID_STALWART, web $IMAGE_UID_WEB)"
else
warn "not running as root — if the containers fail to start, run:"
warn " sudo chown -R $IMAGE_UID_STALWART:$IMAGE_UID_STALWART stalwart/etc stalwart/data"
warn " sudo chown -R $IMAGE_UID_WEB:$IMAGE_UID_WEB data"
fi
step "Building and starting"
docker compose build web >/dev/null 2>&1 || die "Web image build failed. Run 'docker compose build web' to see why."
ok "web image built"
docker compose up -d >/dev/null 2>&1 || die "Could not start the stack. Try 'docker compose up' to see the error."
ok "containers started"
printf ' waiting for the mail server'
for _ in $(seq 1 30); do
curl -fsS --max-time 2 "http://127.0.0.1:$ADMIN_PORT/.well-known/jmap" >/dev/null 2>&1 && break
printf '.'; sleep 2
done
echo
# ── first-run setup ──────────────────────────────────────────────────────────
step "Completing mail server setup"
python3 - "$ADMIN_PORT" "$MAIL_DOMAIN" <<'PY' || die "Setup handshake failed. See 'docker compose logs stalwart'."
import base64, json, os, sys, time, urllib.error, urllib.request
port, domain = sys.argv[1], sys.argv[2]
env = {}
for line in open(".env"):
if "=" in line and not line.startswith("#"):
k, v = line.strip().split("=", 1); env[k] = v
pw = env["STALWART_ADMIN_PASSWORD"]
def call(user, secret, method, args):
auth = base64.b64encode(f"{user}:{secret}".encode()).decode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/jmap/",
data=json.dumps({"using": ["urn:stalwart:jmap"],
"methodCalls": [[method, args, "0"]]}).encode(),
headers={"Authorization": "Basic " + auth, "Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req, timeout=90))["methodResponses"][0][1]
# Already configured? Then this is a re-run; leave everything alone.
try:
if call(f"admin@{domain}", pw, "x:Domain/get", {"ids": None}).get("list"):
print(" already configured - skipping setup"); raise SystemExit(0)
except SystemExit:
raise
except Exception:
pass
# Ask the server for its own defaults and patch only what differs, rather than
# hand-composing the nested store objects.
boot = call("admin", pw, "x:Bootstrap/get", {"ids": None})["list"][0]
for k in ("id",):
boot.pop(k, None)
boot.update({
"serverHostname": domain,
"defaultDomain": domain,
# ACME needs :80/:443; those usually belong to an existing web server, so
# certificates are installed from disk instead (see setup-tls.sh).
"requestTlsCertificate": False,
"generateDkimKeys": True,
"tracer": {**boot["tracer"], "path": "/var/lib/stalwart/logs/", "ansi": False},
})
boot.pop("username", None); boot.pop("secret", None) # server-set
r = call("admin", pw, "x:Bootstrap/set", {"update": {"singleton": boot}})
if r.get("notUpdated"):
print(" bootstrap rejected:", json.dumps(r["notUpdated"])[:300]); sys.exit(1)
creds = r["updated"]["singleton"]
print(f" administrator: {creds['username']}")
# Persist the permanent credential the server just generated.
lines = open(".env").read().splitlines()
out = []
for l in lines:
if l.startswith("STALWART_ADMIN_PASSWORD="): out.append("STALWART_ADMIN_PASSWORD=" + creds["secret"])
elif l.startswith("STALWART_ADMIN_USER="): out.append("STALWART_ADMIN_USER=" + creds["username"])
else: out.append(l)
open(".env", "w").write("\n".join(out) + "\n")
time.sleep(3)
admin_user, admin_pw = creds["username"], creds["secret"]
# Stalwart ships smtp/465/993/995/4190 but no submission listener; Outlook and
# Apple Mail default to 587, so create it.
listeners = call(admin_user, admin_pw, "x:NetworkListener/get", {"ids": None}).get("list", [])
if not any("587" in k for l in listeners for k in l.get("bind", {})):
base = next((l for l in listeners if l.get("name") == "smtp"), None)
if base:
sub = {k: v for k, v in base.items() if k != "id"}
sub.update({"name": "submission", "bind": {"[::]:587": True}, "tlsImplicit": False})
call(admin_user, admin_pw, "x:NetworkListener/set", {"create": {"s": sub}})
print(" created submission listener on :587")
PY
ok "mail server configured"
docker compose restart stalwart >/dev/null 2>&1 || true
sleep 8
# ── done ─────────────────────────────────────────────────────────────────────
ADMIN_USER="$(grep '^STALWART_ADMIN_USER=' .env | cut -d= -f2-)"
ADMIN_PASS="$(grep '^STALWART_ADMIN_PASSWORD=' .env | cut -d= -f2-)"
step "Installed"
cat <<EOF
${B}Webmail${N} http://127.0.0.1:$WEB_PORT ${DIM}(put a reverse proxy in front)${N}
${B}Administrator${N} $ADMIN_USER
${B}Password${N} $ADMIN_PASS
${DIM}Saved in $DIR/.env — keep it out of version control.${N}
${B}Next steps${N}
1. DNS — print the exact records, including your live DKIM keys:
cd $DIR && ./deploy/dns-records.py
2. TLS + reverse proxy (Apache shown; adapt for nginx/Caddy):
sudo ./deploy/setup-tls.sh
EOF
if [ "$RELAY_REQUIRED" = "yes" ]; then
cat <<EOF
3. ${Y}Outbound relay is REQUIRED on this host${N} — port 25 is blocked.
Put your provider's SMTP credentials in .env, then:
./deploy/configure-relay.py && docker compose restart stalwart
EOF
fi
cat <<EOF
Health check at any time:
./deploy/verify.sh
EOF