From da888234b9cec534d438f9919e47f94aedae742f Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:39:51 +0800 Subject: [PATCH] feat: add guided mail server installer --- .github/workflows/ci.yml | 8 + .github/workflows/docker.yml | 8 + README.en.md | 6 +- README.md | 20 +- README.zh-CN.md | 20 +- apps/api/internal/app/admin_handlers.go | 12 +- apps/api/internal/app/auth_handlers.go | 8 +- apps/api/internal/app/open_api_handlers.go | 8 +- apps/api/internal/app/password_test.go | 23 + apps/api/internal/app/util.go | 7 + apps/web/src/pages/admin.tsx | 6 +- apps/web/src/pages/profile.tsx | 4 +- apps/web/src/pages/register.tsx | 4 +- deploy/.env.example | 7 +- deploy/README.md | 7 +- deploy/docker-compose.yml | 4 +- install.sh | 483 +++++++++++++++++++-- tests/install_test.sh | 121 ++++++ 18 files changed, 689 insertions(+), 67 deletions(-) create mode 100644 apps/api/internal/app/password_test.go create mode 100644 tests/install_test.sh 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
{ event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}> - +
setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} /> @@ -1928,7 +1928,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) { const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) }) - return 重置密码 { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}> + return 重置密码
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
} function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) { @@ -1948,7 +1948,7 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("") React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId]) const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) - return 创建邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
{ownerMode === "existing" ? !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : }
+ return 创建邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
{ownerMode === "existing" ? !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : }
} function CreateAliasDialog({ domains }: { domains: Domain[] }) { diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index f3a6e67..d97ced9 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -905,8 +905,8 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
{ e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}> - - + +
diff --git a/apps/web/src/pages/register.tsx b/apps/web/src/pages/register.tsx index 05d4856..953680e 100644 --- a/apps/web/src/pages/register.tsx +++ b/apps/web/src/pages/register.tsx @@ -113,11 +113,11 @@ export function RegisterPage() {
- +
- +
{turnstileRequired && }