diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cf1149..7bdcb92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Check installer + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + bash -n install.sh tests/install_test.sh + shellcheck -x install.sh tests/install_test.sh + bash tests/install_test.sh + - name: Setup pnpm uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c1ff3bb..dc6eb66 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -27,6 +27,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Check installer + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + bash -n install.sh tests/install_test.sh + shellcheck -x install.sh tests/install_test.sh + bash tests/install_test.sh + - name: Setup pnpm uses: pnpm/action-setup@v4 with: diff --git a/README.en.md b/README.en.md index fb4271f..eb3e89e 100644 --- a/README.en.md +++ b/README.en.md @@ -23,6 +23,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install. The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator. +During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters. + ## Update System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network. @@ -39,10 +41,12 @@ Useful commands: ```bash sudo newszxcn-email status sudo newszxcn-email logs +sudo newszxcn-email restart +sudo newszxcn-email certificate sudo newszxcn-email uninstall ``` -The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`. +The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`. ## Required ports diff --git a/README.md b/README.md index 9a263e8..71f7cf1 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后 curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash ``` +已使用 `root` 登录时,也可以使用: + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) +``` + 脚本会自动完成: - 安装或检查 Docker Engine 与 Docker Compose v2 -- 询问邮件域名、访问地址、管理员用户名和密码 +- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口 +- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 +- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 +- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 创建 `/opt/newszxcn-email` 持久化目录 - 拉取 GHCR 镜像并启动邮件服务 - 生成后台在线更新所需的内部鉴权令牌 @@ -67,10 +76,12 @@ sudo newszxcn-email rollback ```bash sudo newszxcn-email status sudo newszxcn-email logs +sudo newszxcn-email restart +sudo newszxcn-email certificate sudo newszxcn-email uninstall ``` -`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。 +`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。 ## DNS 与端口 @@ -104,10 +115,11 @@ sudo newszxcn-email uninstall |-- docker-compose.yml # 邮箱主服务与内部更新服务 |-- data/ # SQLite、附件和更新前备份 |-- mail/ # Maildir 邮件原文 -`-- dkim/ # DKIM 私钥 +|-- dkim/ # DKIM 私钥 +`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书 ``` -升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。 +升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。 ## 手动部署 diff --git a/README.zh-CN.md b/README.zh-CN.md index f493197..1654970 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -24,10 +24,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后 curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash ``` +已使用 `root` 登录时,也可以使用: + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh) +``` + 脚本会自动完成: - 安装或检查 Docker Engine 与 Docker Compose v2 -- 询问邮件域名、访问地址、管理员邮箱和密码 +- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口 +- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 +- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 +- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 创建 `/opt/newszxcn-email` 持久化目录 - 拉取 GHCR 镜像并启动邮件服务 - 生成后台在线更新所需的内部鉴权令牌 @@ -62,10 +71,12 @@ sudo newszxcn-email rollback ```bash sudo newszxcn-email status sudo newszxcn-email logs +sudo newszxcn-email restart +sudo newszxcn-email certificate sudo newszxcn-email uninstall ``` -`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。 +`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。 ## DNS 与端口 @@ -99,10 +110,11 @@ sudo newszxcn-email uninstall ├── docker-compose.yml # 邮箱主服务与内部更新服务 ├── data/ # SQLite、附件和更新前备份 ├── mail/ # Maildir 邮件原文 -└── dkim/ # DKIM 私钥 +├── dkim/ # DKIM 私钥 +└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书 ``` -升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。 +升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。 ## 手动部署 diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 6450519..a6beccd 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -143,8 +143,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { if role == "admin" { mailboxLimitOverride = nil } - if len(req.Password) < 8 { - badRequest(w, errors.New("password must be at least 8 characters")) + if !hasMinimumPasswordLength(req.Password) { + badRequest(w, errors.New("password must be at least 6 characters")) return } passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) @@ -344,8 +344,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } - if len(req.Password) < 8 { - badRequest(w, errors.New("password must be at least 8 characters")) + if !hasMinimumPasswordLength(req.Password) { + badRequest(w, errors.New("password must be at least 6 characters")) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) @@ -562,8 +562,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } - if len(req.Password) < 8 { - badRequest(w, errors.New("password must be at least 8 characters")) + if !hasMinimumPasswordLength(req.Password) { + badRequest(w, errors.New("password must be at least 6 characters")) return } role := req.Role diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go index 67d249f..1123885 100644 --- a/apps/api/internal/app/auth_handlers.go +++ b/apps/api/internal/app/auth_handlers.go @@ -112,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("邮箱地址无效")) return } - if len(req.Password) < 8 { - badRequest(w, errors.New("密码至少需要 8 个字符")) + if !hasMinimumPasswordLength(req.Password) { + badRequest(w, errors.New("密码至少需要 6 个字符")) return } displayName := strings.TrimSpace(req.DisplayName) @@ -245,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } - if len(req.NewPassword) < 8 { - badRequest(w, errors.New("新密码至少需要 8 个字符")) + if !hasMinimumPasswordLength(req.NewPassword) { + badRequest(w, errors.New("新密码至少需要 6 个字符")) return } row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID) diff --git a/apps/api/internal/app/open_api_handlers.go b/apps/api/internal/app/open_api_handlers.go index 8a45290..43f33c7 100644 --- a/apps/api/internal/app/open_api_handlers.go +++ b/apps/api/internal/app/open_api_handlers.go @@ -196,8 +196,8 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) badRequest(w, err) return } - if len(req.Password) < 8 { - badRequest(w, errors.New("password must be at least 8 characters")) + if !hasMinimumPasswordLength(req.Password) { + badRequest(w, errors.New("password must be at least 6 characters")) return } domain, err := a.domainByID(r.Context(), req.DomainID) @@ -371,8 +371,8 @@ func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.R badRequest(w, err) return } - if len(req.Password) < 8 { - badRequest(w, errors.New("password must be at least 8 characters")) + if !hasMinimumPasswordLength(req.Password) { + badRequest(w, errors.New("password must be at least 6 characters")) return } var userID string diff --git a/apps/api/internal/app/password_test.go b/apps/api/internal/app/password_test.go new file mode 100644 index 0000000..516279a --- /dev/null +++ b/apps/api/internal/app/password_test.go @@ -0,0 +1,23 @@ +package app + +import "testing" + +func TestHasMinimumPasswordLength(t *testing.T) { + tests := []struct { + name string + password string + want bool + }{ + {name: "five ASCII characters", password: "abc12", want: false}, + {name: "six ASCII characters", password: "abc123", want: true}, + {name: "six Unicode characters", password: "密码测试六位", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasMinimumPasswordLength(tt.password); got != tt.want { + t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want) + } + }) + } +} diff --git a/apps/api/internal/app/util.go b/apps/api/internal/app/util.go index f8fdd4f..c9941da 100644 --- a/apps/api/internal/app/util.go +++ b/apps/api/internal/app/util.go @@ -14,12 +14,19 @@ import ( "strings" "time" "unicode" + "unicode/utf8" "github.com/microcosm-cc/bluemonday" ) type HTMLPolicy struct{ policy *bluemonday.Policy } +const minimumPasswordLength = 6 + +func hasMinimumPasswordLength(password string) bool { + return utf8.RuneCountInString(password) >= minimumPasswordLength +} + func NewHTMLPolicy() *HTMLPolicy { p := bluemonday.UGCPolicy() p.AllowElements("html", "head", "body", "center", "font") diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 38c63c8..ee8daf7 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -1858,7 +1858,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr