From af64c96b9f94dc7085dba6664047477950f5c880 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:36:22 +0100 Subject: [PATCH] feat: add interactive setup script for JMAP Webmail configuration --- setup.sh | 973 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 973 insertions(+) create mode 100644 setup.sh diff --git a/setup.sh b/setup.sh new file mode 100644 index 00000000..9017e7d5 --- /dev/null +++ b/setup.sh @@ -0,0 +1,973 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# JMAP Webmail — Interactive Setup Script +# Fullscreen terminal installer for configuring and deploying JMAP Webmail +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +# ── Colors & Symbols ──────────────────────────────────────────────────────── +BOLD='\033[1m' +DIM='\033[2m' +UNDERLINE='\033[4m' +RESET='\033[0m' +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +WHITE='\033[1;37m' +BG_BLUE='\033[44m' +BG_GREEN='\033[42m' +BG_RED='\033[41m' +BG_MAGENTA='\033[45m' +BG_CYAN='\033[46m' +BG_DARK='\033[40m' + +CHECKMARK="${GREEN}✓${RESET}" +CROSSMARK="${RED}✗${RESET}" +ARROW="${CYAN}➜${RESET}" +DOT="${DIM}·${RESET}" +BULLET="${BLUE}●${RESET}" + +# ── State ─────────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env.local" + +# Config values (defaults) +CFG_APP_NAME="JMAP Webmail" +CFG_JMAP_SERVER_URL="" +CFG_STALWART_FEATURES="true" +CFG_OAUTH_ENABLED="false" +CFG_OAUTH_ONLY="false" +CFG_OAUTH_CLIENT_ID="" +CFG_OAUTH_CLIENT_SECRET="" +CFG_OAUTH_ISSUER_URL="" +CFG_SESSION_SECRET="" +CFG_SETTINGS_SYNC_ENABLED="false" +CFG_SETTINGS_DATA_DIR="./data/settings" +CFG_LOG_FORMAT="text" +CFG_LOG_LEVEL="info" +CFG_LOGIN_COMPANY_NAME="" +CFG_LOGIN_IMPRINT_URL="" +CFG_LOGIN_PRIVACY_POLICY_URL="" +CFG_LOGIN_WEBSITE_URL="" +CFG_DEPLOY_METHOD="" # "docker" | "node" | "compose" +CFG_PORT="3000" + +# Navigation +CURRENT_STEP=0 +TOTAL_STEPS=7 + +# ── Terminal Utilities ────────────────────────────────────────────────────── +get_term_size() { + TERM_COLS=$(tput cols 2>/dev/null || echo 80) + TERM_ROWS=$(tput lines 2>/dev/null || echo 24) +} + +clear_screen() { + printf '\033[2J\033[H' +} + +hide_cursor() { + printf '\033[?25l' +} + +show_cursor() { + printf '\033[?25h' +} + +move_to() { + printf '\033[%d;%dH' "$1" "$2" +} + +# Print centered text +print_center() { + local text="$1" + local clean_text + clean_text=$(echo -e "$text" | sed 's/\x1b\[[0-9;]*m//g') + local padding=$(( (TERM_COLS - ${#clean_text}) / 2 )) + [[ $padding -lt 0 ]] && padding=0 + printf "%${padding}s" "" + echo -e "$text" +} + +# Print a horizontal rule +print_hr() { + local char="${1:-─}" + local color="${2:-$DIM}" + echo -e "${color}$(printf '%*s' "$TERM_COLS" '' | tr ' ' "$char")${RESET}" +} + +# Print text right-aligned +print_right() { + local text="$1" + local clean_text + clean_text=$(echo -e "$text" | sed 's/\x1b\[[0-9;]*m//g') + local padding=$(( TERM_COLS - ${#clean_text} )) + [[ $padding -lt 0 ]] && padding=0 + printf "%${padding}s" "" + echo -e "$text" +} + +# Read a single key press +read_key() { + local key + IFS= read -rsn1 key + # Handle escape sequences (arrow keys, etc.) + if [[ "$key" == $'\x1b' ]]; then + read -rsn2 -t 0.1 key2 || true + key="${key}${key2}" + fi + echo "$key" +} + +# Prompt with default value +prompt_value() { + local label="$1" + local default="$2" + local var_name="$3" + local required="${4:-false}" + + show_cursor + if [[ -n "$default" ]]; then + echo -ne " ${BOLD}${label}${RESET} ${DIM}[${default}]${RESET}: " + else + echo -ne " ${BOLD}${label}${RESET}: " + fi + + local input + read -r input + input="${input:-$default}" + + if [[ "$required" == "true" && -z "$input" ]]; then + echo -e " ${RED}This field is required.${RESET}" + prompt_value "$label" "$default" "$var_name" "$required" + return + fi + + eval "$var_name='$input'" + hide_cursor +} + +# Yes/No selector +prompt_yesno() { + local label="$1" + local default="$2" # "true" or "false" + local var_name="$3" + + local yn_display + if [[ "$default" == "true" ]]; then + yn_display="${GREEN}${BOLD}Y${RESET}${DIM}/n${RESET}" + else + yn_display="${DIM}y/${RESET}${GREEN}${BOLD}N${RESET}" + fi + + show_cursor + echo -ne " ${BOLD}${label}${RESET} [${yn_display}]: " + + local input + read -r input + input=$(echo "$input" | tr '[:upper:]' '[:lower:]') + + case "$input" in + y|yes) eval "$var_name='true'" ;; + n|no) eval "$var_name='false'" ;; + "") eval "$var_name='$default'" ;; + *) eval "$var_name='$default'" ;; + esac + hide_cursor +} + +# Interactive menu selector +menu_select() { + local label="$1" + shift + local options=("$@") + local selected=0 + local count=${#options[@]} + + hide_cursor + echo -e " ${BOLD}${label}${RESET}" + echo "" + + # Save cursor position + local start_row + start_row=$(get_cursor_row) + + while true; do + # Move back to the start + for (( i=0; i/dev/null; do + echo -ne "\r ${CYAN}${frames[$i]}${RESET} ${message}" + i=$(( (i + 1) % ${#frames[@]} )) + sleep 0.08 + done + echo -ne "\r ${CHECKMARK} ${message} \n" + show_cursor +} + +# ── Screen Rendering ──────────────────────────────────────────────────────── + +draw_header() { + get_term_size + clear_screen + + echo "" + print_center "${BG_BLUE}${WHITE}${BOLD} ${RESET}" + print_center "${BG_BLUE}${WHITE}${BOLD} JMAP WEBMAIL SETUP ${RESET}" + print_center "${BG_BLUE}${WHITE}${BOLD} ${RESET}" + echo "" + + if [[ $CURRENT_STEP -gt 0 ]]; then + draw_progress "$CURRENT_STEP" "$TOTAL_STEPS" + echo "" + fi + print_hr "─" + echo "" +} + +draw_footer() { + echo "" + print_hr "─" + echo "" + if [[ $CURRENT_STEP -eq 0 ]]; then + print_center "${DIM}Press Enter to begin${RESET}" + elif [[ $CURRENT_STEP -le $TOTAL_STEPS ]]; then + print_center "${DIM}Press Enter to continue · Ctrl+C to abort${RESET}" + fi +} + +# ── Screens ───────────────────────────────────────────────────────────────── + +screen_welcome() { + CURRENT_STEP=0 + draw_header + + local version + version=$(cat "${SCRIPT_DIR}/VERSION" 2>/dev/null || echo "unknown") + + print_center "${BOLD}Welcome to the JMAP Webmail installer${RESET}" + echo "" + print_center "${DIM}Version ${version}${RESET}" + echo "" + echo "" + print_center "This script will guide you through:" + echo "" + print_center "${BULLET} Configuring your JMAP mail server connection" + print_center "${BULLET} Setting up authentication (Basic / OAuth2)" + print_center "${BULLET} Configuring security & session settings" + print_center "${BULLET} Customizing the login page" + print_center "${BULLET} Choosing a deployment method" + print_center "${BULLET} Installing dependencies & building" + echo "" + echo "" + + # System checks + print_center "${BOLD}System Check${RESET}" + echo "" + + local all_ok=true + + # Node.js + if command -v node &>/dev/null; then + local node_ver + node_ver=$(node --version) + print_center "${CHECKMARK} Node.js ${node_ver}" + else + print_center "${CROSSMARK} Node.js not found" + all_ok=false + fi + + # npm + if command -v npm &>/dev/null; then + local npm_ver + npm_ver=$(npm --version) + print_center "${CHECKMARK} npm v${npm_ver}" + else + print_center "${CROSSMARK} npm not found" + all_ok=false + fi + + # Docker + if command -v docker &>/dev/null; then + local docker_ver + docker_ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "installed") + print_center "${CHECKMARK} Docker v${docker_ver}" + else + print_center "${DIM}${DOT} Docker not found (optional)${RESET}" + fi + + # Git + if command -v git &>/dev/null; then + local git_ver + git_ver=$(git --version | grep -oP '\d+\.\d+\.\d+' || echo "installed") + print_center "${CHECKMARK} Git v${git_ver}" + else + print_center "${DIM}${DOT} Git not found (optional)${RESET}" + fi + + # openssl + if command -v openssl &>/dev/null; then + print_center "${CHECKMARK} OpenSSL available" + else + print_center "${DIM}${DOT} OpenSSL not found (can generate secrets manually)${RESET}" + fi + + echo "" + + if [[ "$all_ok" == false ]]; then + echo "" + print_center "${YELLOW}⚠ Some required tools are missing.${RESET}" + print_center "${YELLOW} Install Node.js 18+ before continuing.${RESET}" + fi + + draw_footer + read -r +} + +screen_server_config() { + CURRENT_STEP=1 + draw_header + + print_center "${BOLD}${CYAN}Step 1: Server Configuration${RESET}" + echo "" + echo "" + + echo -e " ${BOLD}Configure your JMAP mail server connection.${RESET}" + echo -e " ${DIM}This is the URL of your Stalwart (or compatible) mail server.${RESET}" + echo "" + + prompt_value "Application name" "$CFG_APP_NAME" "CFG_APP_NAME" + echo "" + prompt_value "JMAP Server URL" "$CFG_JMAP_SERVER_URL" "CFG_JMAP_SERVER_URL" "true" + echo "" + + # Validate URL format + if [[ "$CFG_JMAP_SERVER_URL" =~ ^https?:// ]]; then + echo -e " ${CHECKMARK} URL format looks valid" + else + echo -e " ${YELLOW}⚠ URL should start with https:// or http://${RESET}" + prompt_value "JMAP Server URL" "https://${CFG_JMAP_SERVER_URL}" "CFG_JMAP_SERVER_URL" "true" + fi + + echo "" + prompt_yesno "Enable Stalwart-specific features? (password change, sieve filters)" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES" + + echo "" + echo -e " ${DIM}Select the port to run on:${RESET}" + prompt_value "Port" "$CFG_PORT" "CFG_PORT" + + draw_footer + read -r +} + +screen_auth_config() { + CURRENT_STEP=2 + draw_header + + print_center "${BOLD}${CYAN}Step 2: Authentication${RESET}" + echo "" + echo "" + + echo -e " ${BOLD}Configure how users authenticate.${RESET}" + echo -e " ${DIM}Basic Auth is always available. You can add OAuth2/OIDC for SSO.${RESET}" + echo "" + + prompt_yesno "Enable OAuth2/OIDC?" "$CFG_OAUTH_ENABLED" "CFG_OAUTH_ENABLED" + + if [[ "$CFG_OAUTH_ENABLED" == "true" ]]; then + echo "" + echo -e " ${ARROW} ${BOLD}OAuth2 Configuration${RESET}" + echo "" + + prompt_yesno "OAuth-only mode? (hides password form)" "$CFG_OAUTH_ONLY" "CFG_OAUTH_ONLY" + echo "" + prompt_value "OAuth Client ID" "$CFG_OAUTH_CLIENT_ID" "CFG_OAUTH_CLIENT_ID" "true" + echo "" + prompt_value "OAuth Client Secret" "$CFG_OAUTH_CLIENT_SECRET" "CFG_OAUTH_CLIENT_SECRET" + echo -e " ${DIM}Leave empty for public clients (PKCE-only).${RESET}" + echo "" + prompt_value "OAuth Issuer URL" "$CFG_OAUTH_ISSUER_URL" "CFG_OAUTH_ISSUER_URL" + echo -e " ${DIM}Set this for external IdPs (Keycloak, Authentik). Leave empty for Stalwart's built-in OAuth.${RESET}" + fi + + draw_footer + read -r +} + +screen_security_config() { + CURRENT_STEP=3 + draw_header + + print_center "${BOLD}${CYAN}Step 3: Security & Sessions${RESET}" + echo "" + echo "" + + echo -e " ${BOLD}Configure session security and \"Remember me\" functionality.${RESET}" + echo -e " ${DIM}A session secret enables encrypted persistent sessions and settings sync.${RESET}" + echo "" + + local generate_secret="false" + + if [[ -z "$CFG_SESSION_SECRET" ]]; then + prompt_yesno "Generate a secure session secret?" "true" "generate_secret" + + if [[ "$generate_secret" == "true" ]]; then + if command -v openssl &>/dev/null; then + CFG_SESSION_SECRET=$(openssl rand -base64 32) + echo -e " ${CHECKMARK} Generated session secret" + else + CFG_SESSION_SECRET=$(head -c 32 /dev/urandom | base64) + echo -e " ${CHECKMARK} Generated session secret (via /dev/urandom)" + fi + else + prompt_value "Session secret" "" "CFG_SESSION_SECRET" + fi + else + echo -e " ${CHECKMARK} Session secret is already set" + prompt_yesno "Regenerate session secret?" "false" "generate_secret" + if [[ "$generate_secret" == "true" ]]; then + if command -v openssl &>/dev/null; then + CFG_SESSION_SECRET=$(openssl rand -base64 32) + else + CFG_SESSION_SECRET=$(head -c 32 /dev/urandom | base64) + fi + echo -e " ${CHECKMARK} Regenerated session secret" + fi + fi + + echo "" + + if [[ -n "$CFG_SESSION_SECRET" ]]; then + echo -e " ${BULLET} \"Remember me\" checkbox will appear on login" + echo -e " ${BULLET} Credentials encrypted with AES-256-GCM" + echo "" + + prompt_yesno "Enable settings sync across devices?" "$CFG_SETTINGS_SYNC_ENABLED" "CFG_SETTINGS_SYNC_ENABLED" + + if [[ "$CFG_SETTINGS_SYNC_ENABLED" == "true" ]]; then + echo "" + prompt_value "Settings data directory" "$CFG_SETTINGS_DATA_DIR" "CFG_SETTINGS_DATA_DIR" + fi + else + echo -e " ${DIM}Without a session secret, \"Remember me\" and settings sync are disabled.${RESET}" + fi + + draw_footer + read -r +} + +screen_logging_config() { + CURRENT_STEP=4 + draw_header + + print_center "${BOLD}${CYAN}Step 4: Logging${RESET}" + echo "" + echo "" + + echo -e " ${BOLD}Configure application logging.${RESET}" + echo "" + + menu_select "Log format:" "text (colored, human-readable)" "json (structured, for log aggregation)" + case $MENU_RESULT in + 0) CFG_LOG_FORMAT="text" ;; + 1) CFG_LOG_FORMAT="json" ;; + esac + + echo "" + + menu_select "Log level:" "error (errors only)" "warn (errors + warnings)" "info (standard — recommended)" "debug (verbose, for development)" + case $MENU_RESULT in + 0) CFG_LOG_LEVEL="error" ;; + 1) CFG_LOG_LEVEL="warn" ;; + 2) CFG_LOG_LEVEL="info" ;; + 3) CFG_LOG_LEVEL="debug" ;; + esac + + draw_footer + read -r +} + +screen_login_customization() { + CURRENT_STEP=5 + draw_header + + print_center "${BOLD}${CYAN}Step 5: Login Page Customization${RESET}" + echo "" + echo "" + + echo -e " ${BOLD}Customize the login page appearance.${RESET}" + echo -e " ${DIM}All fields are optional. Leave blank to skip.${RESET}" + echo "" + + prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME" + echo "" + prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL" + echo "" + prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL" + echo "" + prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL" + + draw_footer + read -r +} + +screen_deployment() { + CURRENT_STEP=6 + draw_header + + print_center "${BOLD}${CYAN}Step 6: Deployment Method${RESET}" + echo "" + echo "" + + echo -e " ${BOLD}How would you like to deploy JMAP Webmail?${RESET}" + echo "" + + local docker_available=false + command -v docker &>/dev/null && docker_available=true + + menu_select "Choose deployment method:" \ + "Node.js (npm install → npm run build → npm start)" \ + "Docker (pull pre-built image and run)" \ + "Docker Compose (use docker-compose.yml)" + + case $MENU_RESULT in + 0) CFG_DEPLOY_METHOD="node" ;; + 1) CFG_DEPLOY_METHOD="docker" ;; + 2) CFG_DEPLOY_METHOD="compose" ;; + esac + + if [[ "$CFG_DEPLOY_METHOD" != "node" && "$docker_available" == false ]]; then + echo "" + echo -e " ${YELLOW}⚠ Docker is not installed. You can still generate the config${RESET}" + echo -e " ${YELLOW} and run Docker commands later.${RESET}" + fi + + draw_footer + read -r +} + +screen_summary() { + CURRENT_STEP=7 + draw_header + + print_center "${BOLD}${CYAN}Step 7: Review & Confirm${RESET}" + echo "" + echo "" + + echo -e " ${BOLD}Configuration Summary${RESET}" + echo "" + print_hr "─" + echo "" + + # Server + echo -e " ${UNDERLINE}Server${RESET}" + echo -e " App Name ${BOLD}${CFG_APP_NAME}${RESET}" + echo -e " JMAP Server URL ${BOLD}${CFG_JMAP_SERVER_URL}${RESET}" + echo -e " Stalwart Features ${BOLD}${CFG_STALWART_FEATURES}${RESET}" + echo -e " Port ${BOLD}${CFG_PORT}${RESET}" + echo "" + + # Auth + echo -e " ${UNDERLINE}Authentication${RESET}" + if [[ "$CFG_OAUTH_ENABLED" == "true" ]]; then + echo -e " OAuth2/OIDC ${GREEN}Enabled${RESET}" + echo -e " OAuth-only ${BOLD}${CFG_OAUTH_ONLY}${RESET}" + echo -e " Client ID ${BOLD}${CFG_OAUTH_CLIENT_ID}${RESET}" + [[ -n "$CFG_OAUTH_CLIENT_SECRET" ]] && echo -e " Client Secret ${BOLD}••••••••${RESET}" + [[ -n "$CFG_OAUTH_ISSUER_URL" ]] && echo -e " Issuer URL ${BOLD}${CFG_OAUTH_ISSUER_URL}${RESET}" + else + echo -e " OAuth2/OIDC ${DIM}Disabled (Basic Auth only)${RESET}" + fi + echo "" + + # Security + echo -e " ${UNDERLINE}Security${RESET}" + if [[ -n "$CFG_SESSION_SECRET" ]]; then + echo -e " Session Secret ${GREEN}Set${RESET} ${DIM}(Remember me enabled)${RESET}" + echo -e " Settings Sync ${BOLD}${CFG_SETTINGS_SYNC_ENABLED}${RESET}" + [[ "$CFG_SETTINGS_SYNC_ENABLED" == "true" ]] && \ + echo -e " Settings Dir ${BOLD}${CFG_SETTINGS_DATA_DIR}${RESET}" + else + echo -e " Session Secret ${DIM}Not set${RESET}" + fi + echo "" + + # Logging + echo -e " ${UNDERLINE}Logging${RESET}" + echo -e " Format ${BOLD}${CFG_LOG_FORMAT}${RESET}" + echo -e " Level ${BOLD}${CFG_LOG_LEVEL}${RESET}" + echo "" + + # Login page + echo -e " ${UNDERLINE}Login Page${RESET}" + [[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo -e " Company Name ${BOLD}${CFG_LOGIN_COMPANY_NAME}${RESET}" + [[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && echo -e " Website URL ${BOLD}${CFG_LOGIN_WEBSITE_URL}${RESET}" + [[ -n "$CFG_LOGIN_IMPRINT_URL" ]] && echo -e " Imprint URL ${BOLD}${CFG_LOGIN_IMPRINT_URL}${RESET}" + [[ -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]] && echo -e " Privacy Policy ${BOLD}${CFG_LOGIN_PRIVACY_POLICY_URL}${RESET}" + if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then + echo -e " ${DIM}(defaults)${RESET}" + fi + echo "" + + # Deployment + echo -e " ${UNDERLINE}Deployment${RESET}" + case "$CFG_DEPLOY_METHOD" in + "node") echo -e " Method ${BOLD}Node.js (local build)${RESET}" ;; + "docker") echo -e " Method ${BOLD}Docker (pre-built image)${RESET}" ;; + "compose") echo -e " Method ${BOLD}Docker Compose${RESET}" ;; + esac + + echo "" + print_hr "─" + echo "" + + local confirm + prompt_yesno "Apply this configuration and proceed?" "true" "confirm" + + if [[ "$confirm" != "true" ]]; then + echo "" + echo -e " ${YELLOW}Setup cancelled. No files were modified.${RESET}" + echo "" + show_cursor + exit 0 + fi +} + +# ── Write Configuration ──────────────────────────────────────────────────── + +write_env_file() { + # Backup existing if present + if [[ -f "$ENV_FILE" ]]; then + local backup="${ENV_FILE}.backup.$(date +%Y%m%d_%H%M%S)" + cp "$ENV_FILE" "$backup" + echo -e " ${CHECKMARK} Backed up existing .env.local → ${DIM}$(basename "$backup")${RESET}" + fi + + cat > "$ENV_FILE" << ENVEOF +# ───────────────────────────────────────────────────────────────────────────── +# JMAP Webmail — Configuration +# Generated by setup.sh on $(date '+%Y-%m-%d %H:%M:%S') +# ───────────────────────────────────────────────────────────────────────────── + +# ============================================================================= +# JMAP Server (required) +# ============================================================================= + +APP_NAME=${CFG_APP_NAME} +JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL} + +# ============================================================================= +# Stalwart Mail Server Integration +# ============================================================================= + +STALWART_FEATURES=${CFG_STALWART_FEATURES} + +# ============================================================================= +# OAuth / OpenID Connect +# ============================================================================= + +OAUTH_ENABLED=${CFG_OAUTH_ENABLED} +ENVEOF + + if [[ "$CFG_OAUTH_ENABLED" == "true" ]]; then + cat >> "$ENV_FILE" << ENVEOF +OAUTH_ONLY=${CFG_OAUTH_ONLY} +OAUTH_CLIENT_ID=${CFG_OAUTH_CLIENT_ID} +ENVEOF + [[ -n "$CFG_OAUTH_CLIENT_SECRET" ]] && echo "OAUTH_CLIENT_SECRET=${CFG_OAUTH_CLIENT_SECRET}" >> "$ENV_FILE" + [[ -n "$CFG_OAUTH_ISSUER_URL" ]] && echo "OAUTH_ISSUER_URL=${CFG_OAUTH_ISSUER_URL}" >> "$ENV_FILE" + fi + + cat >> "$ENV_FILE" << ENVEOF + +# ============================================================================= +# Session & Security +# ============================================================================= + +ENVEOF + + if [[ -n "$CFG_SESSION_SECRET" ]]; then + echo "SESSION_SECRET=${CFG_SESSION_SECRET}" >> "$ENV_FILE" + else + echo "# SESSION_SECRET=" >> "$ENV_FILE" + fi + + cat >> "$ENV_FILE" << ENVEOF + +# ============================================================================= +# Settings Sync +# ============================================================================= + +SETTINGS_SYNC_ENABLED=${CFG_SETTINGS_SYNC_ENABLED} +ENVEOF + + if [[ "$CFG_SETTINGS_SYNC_ENABLED" == "true" ]]; then + echo "SETTINGS_DATA_DIR=${CFG_SETTINGS_DATA_DIR}" >> "$ENV_FILE" + fi + + cat >> "$ENV_FILE" << ENVEOF + +# ============================================================================= +# Logging +# ============================================================================= + +LOG_FORMAT=${CFG_LOG_FORMAT} +LOG_LEVEL=${CFG_LOG_LEVEL} + +# ============================================================================= +# Login Page Customization +# ============================================================================= + +ENVEOF + + [[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE" + [[ -n "$CFG_LOGIN_IMPRINT_URL" ]] && echo "LOGIN_IMPRINT_URL=${CFG_LOGIN_IMPRINT_URL}" >> "$ENV_FILE" + [[ -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]] && echo "LOGIN_PRIVACY_POLICY_URL=${CFG_LOGIN_PRIVACY_POLICY_URL}" >> "$ENV_FILE" + [[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && echo "LOGIN_WEBSITE_URL=${CFG_LOGIN_WEBSITE_URL}" >> "$ENV_FILE" + + echo -e " ${CHECKMARK} Written ${BOLD}.env.local${RESET}" +} + +update_docker_compose_port() { + if [[ "$CFG_PORT" != "3000" && -f "${SCRIPT_DIR}/docker-compose.yml" ]]; then + sed -i.bak "s/\"3000:3000\"/\"${CFG_PORT}:3000\"/" "${SCRIPT_DIR}/docker-compose.yml" 2>/dev/null || true + echo -e " ${CHECKMARK} Updated docker-compose.yml port mapping" + fi +} + +run_deployment() { + echo "" + + case "$CFG_DEPLOY_METHOD" in + "node") + echo -e " ${ARROW} Installing dependencies..." + (cd "$SCRIPT_DIR" && npm install --loglevel=warn) & + spinner $! "Installing npm packages" + + echo -e " ${ARROW} Building application..." + (cd "$SCRIPT_DIR" && npm run build 2>&1 | tail -5) & + spinner $! "Building Next.js application" + + echo "" + echo -e " ${CHECKMARK} Build complete!" + echo "" + echo -e " ${BOLD}Start the application:${RESET}" + echo "" + echo -e " ${CYAN}cd ${SCRIPT_DIR}${RESET}" + if [[ "$CFG_PORT" != "3000" ]]; then + echo -e " ${CYAN}PORT=${CFG_PORT} npm start${RESET}" + else + echo -e " ${CYAN}npm start${RESET}" + fi + ;; + + "docker") + echo -e " ${ARROW} Pulling Docker image..." + (docker pull ghcr.io/root-fr/jmap-webmail:latest 2>&1) & + spinner $! "Pulling ghcr.io/root-fr/jmap-webmail:latest" + + echo "" + echo -e " ${CHECKMARK} Image pulled!" + echo "" + echo -e " ${BOLD}Run the container:${RESET}" + echo "" + echo -e " ${CYAN}docker run -d \\" + echo -e " --name jmap-webmail \\" + echo -e " -p ${CFG_PORT}:3000 \\" + echo -e " --env-file .env.local \\" + echo -e " --restart unless-stopped \\" + echo -e " ghcr.io/root-fr/jmap-webmail:latest${RESET}" + ;; + + "compose") + update_docker_compose_port + + echo -e " ${ARROW} Starting with Docker Compose..." + (cd "$SCRIPT_DIR" && docker compose up -d 2>&1) & + spinner $! "Starting Docker Compose services" + + echo "" + echo -e " ${CHECKMARK} Services started!" + echo "" + echo -e " ${BOLD}Manage with:${RESET}" + echo "" + echo -e " ${CYAN}docker compose logs -f${RESET} ${DIM}# View logs${RESET}" + echo -e " ${CYAN}docker compose restart${RESET} ${DIM}# Restart${RESET}" + echo -e " ${CYAN}docker compose down${RESET} ${DIM}# Stop${RESET}" + ;; + esac +} + +# ── Completion Screen ─────────────────────────────────────────────────────── + +screen_complete() { + draw_header + + echo "" + print_center "${BG_GREEN}${WHITE}${BOLD} ${RESET}" + print_center "${BG_GREEN}${WHITE}${BOLD} SETUP COMPLETE! ${RESET}" + print_center "${BG_GREEN}${WHITE}${BOLD} ${RESET}" + echo "" + echo "" + + write_env_file + echo "" + + run_deployment + + echo "" + echo "" + print_hr "─" + echo "" + + local url="http://localhost:${CFG_PORT}" + print_center "${BOLD}Access your webmail at:${RESET}" + echo "" + print_center "${UNDERLINE}${CYAN}${url}${RESET}" + echo "" + echo "" + + print_center "${DIM}Configuration: .env.local${RESET}" + print_center "${DIM}Documentation: README.md${RESET}" + echo "" + print_hr "─" + echo "" + print_center "${DIM}Thank you for using JMAP Webmail!${RESET}" + echo "" +} + +# ── Signal Handling ───────────────────────────────────────────────────────── + +cleanup() { + show_cursor + clear_screen + echo -e "\n ${YELLOW}Setup interrupted. No changes were made.${RESET}\n" + exit 1 +} + +trap cleanup INT TERM + +# ── Main ──────────────────────────────────────────────────────────────────── + +main() { + # Check for minimum terminal size + get_term_size + if [[ $TERM_COLS -lt 60 || $TERM_ROWS -lt 20 ]]; then + echo "Terminal too small. Minimum: 60x20 (current: ${TERM_COLS}x${TERM_ROWS})" + exit 1 + fi + + # Load existing .env.local values if present + if [[ -f "$ENV_FILE" ]]; then + load_existing_config + fi + + hide_cursor + + screen_welcome + screen_server_config + screen_auth_config + screen_security_config + screen_logging_config + screen_login_customization + screen_deployment + screen_summary + screen_complete + + show_cursor +} + +load_existing_config() { + local val + get_env_val() { + local key="$1" + val=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d'=' -f2-) + val="${val#\"}" ; val="${val%\"}" # strip quotes + val="${val#\'}" ; val="${val%\'}" + } + + get_env_val "APP_NAME"; [[ -n "$val" ]] && CFG_APP_NAME="$val" + get_env_val "JMAP_SERVER_URL"; [[ -n "$val" ]] && CFG_JMAP_SERVER_URL="$val" + get_env_val "STALWART_FEATURES"; [[ -n "$val" ]] && CFG_STALWART_FEATURES="$val" + get_env_val "OAUTH_ENABLED"; [[ -n "$val" ]] && CFG_OAUTH_ENABLED="$val" + get_env_val "OAUTH_ONLY"; [[ -n "$val" ]] && CFG_OAUTH_ONLY="$val" + get_env_val "OAUTH_CLIENT_ID"; [[ -n "$val" ]] && CFG_OAUTH_CLIENT_ID="$val" + get_env_val "OAUTH_CLIENT_SECRET"; [[ -n "$val" ]] && CFG_OAUTH_CLIENT_SECRET="$val" + get_env_val "OAUTH_ISSUER_URL"; [[ -n "$val" ]] && CFG_OAUTH_ISSUER_URL="$val" + get_env_val "SESSION_SECRET"; [[ -n "$val" ]] && CFG_SESSION_SECRET="$val" + get_env_val "SETTINGS_SYNC_ENABLED"; [[ -n "$val" ]] && CFG_SETTINGS_SYNC_ENABLED="$val" + get_env_val "SETTINGS_DATA_DIR"; [[ -n "$val" ]] && CFG_SETTINGS_DATA_DIR="$val" + get_env_val "LOG_FORMAT"; [[ -n "$val" ]] && CFG_LOG_FORMAT="$val" + get_env_val "LOG_LEVEL"; [[ -n "$val" ]] && CFG_LOG_LEVEL="$val" + get_env_val "LOGIN_COMPANY_NAME"; [[ -n "$val" ]] && CFG_LOGIN_COMPANY_NAME="$val" + get_env_val "LOGIN_IMPRINT_URL"; [[ -n "$val" ]] && CFG_LOGIN_IMPRINT_URL="$val" + get_env_val "LOGIN_PRIVACY_POLICY_URL"; [[ -n "$val" ]] && CFG_LOGIN_PRIVACY_POLICY_URL="$val" + get_env_val "LOGIN_WEBSITE_URL"; [[ -n "$val" ]] && CFG_LOGIN_WEBSITE_URL="$val" +} + +main "$@"