Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 397ce51800 | |||
| e789cf9b14 | |||
| 7e480795fc | |||
| a2eb1fbc52 | |||
| cab71c0539 | |||
| 6301182cfc | |||
| 6d0147b639 | |||
| 497aefb903 | |||
| 1af4b7250e | |||
| 1ce75ef241 | |||
| 99b8b7dee8 | |||
| 697ed236cc | |||
| b55c298ef4 | |||
| 800d482c77 | |||
| ccd5c4efd5 | |||
| da888234b9 |
@@ -0,0 +1,33 @@
|
||||
## 本次更新
|
||||
|
||||
### 一键部署与运维
|
||||
|
||||
- 新增统一管理菜单,支持一键安装、更新、修复、查看状态、重启服务、查看日志、配置 SSL、版本回滚和卸载。
|
||||
- 空白服务器进入安装流程,检测到已有安装时可直接更新或修复,减少重复操作。
|
||||
- 重新安装前自动完整备份旧安装目录,避免误覆盖现有配置和数据。
|
||||
- 更新前自动备份 SQLite 数据库,更新失败时支持回滚。
|
||||
- 完善交互式安装引导,可选择防火墙策略,并依次设置邮件服务器域名、管理员用户名、管理员密码以及 Nginx 和 SSL。
|
||||
- 管理员用户名默认使用 `admin`;管理员密码可回车自动生成 12 位随机密码,也可输入不少于 6 位的自定义密码。
|
||||
|
||||
### 邮箱与账号体验
|
||||
|
||||
- 修复管理员登录名 `admin` 被误显示为邮箱地址的问题。
|
||||
- 邮箱列表加载时显示“加载邮箱...”,账号尚未创建邮箱时显示“未创建邮箱”。
|
||||
- 统一空邮箱状态下的操作按钮为“前往邮箱管理”。
|
||||
- 管理员及具备邮箱管理权限的用户跳转至后台邮箱管理,普通用户跳转至个人中心的邮箱申请页面。
|
||||
|
||||
### 自助申请邮箱
|
||||
|
||||
- 在未创建邮箱页面明确标注开关位置:`后台管理 -> 系统设置 -> 邮件 -> 账号自助申请邮箱`。
|
||||
- 区分“未开启自助申请”和“未选择开放域名”两种状态,并给出对应处理提示。
|
||||
- 管理员可通过“前往设置”直接进入后台邮件设置页。
|
||||
- 普通用户无法自行申请时会提示联系管理员处理。
|
||||
- 修复通过链接进入系统设置时未自动切换到“邮件”设置标签的问题。
|
||||
- 补充简体中文、繁体中文和英文界面文案。
|
||||
|
||||
### 兼容与验证
|
||||
|
||||
- 更新和修复流程保留现有端口、反向代理、邮件数据、证书及数据库配置。
|
||||
- 已通过 ShellCheck、安装脚本测试、Go 后端测试和前端生产构建检查。
|
||||
|
||||
**完整更新日志**:[v1.2.4...v1.2.5](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.4...v1.2.5)
|
||||
@@ -0,0 +1,36 @@
|
||||
## 本次更新
|
||||
|
||||
### 邮箱管理跳转
|
||||
|
||||
- 账号尚未创建邮箱时,点击“前往邮箱管理”统一进入前端个人邮箱管理页。
|
||||
- 管理员和普通用户使用相同入口,可在前端创建、申请或查看自己的邮箱,不再误跳到后台邮箱列表。
|
||||
|
||||
### 管理员密码恢复
|
||||
|
||||
- 管理菜单新增“查看管理员登录信息”,可查看安装时或最近一次命令行重置后记录的管理员用户名和密码。
|
||||
- 新增“重置管理员统一登录密码”,支持回车自动生成 12 位密码,或输入不少于 6 位的自定义密码。
|
||||
- 重置前自动备份并校验 SQLite 数据库;只更新配置管理员账号,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不影响普通用户及其邮箱。
|
||||
- 数据库中的密码使用 bcrypt 哈希,无法反向解密;若密码曾在网页中修改,命令行显示的旧记录可能已经失效,可直接使用重置功能设置新密码。
|
||||
|
||||
### 一键安装与运维安全
|
||||
|
||||
- 管理菜单扩展为安装、更新、修复、状态、重启、日志、SSL、回滚、邮箱指南、管理员密码恢复和卸载等完整操作。
|
||||
- 更新前创建完整回滚快照,包含数据库、镜像、Compose、环境配置、安装脚本、Nginx 和证书;更新失败或健康检查失败时自动恢复。
|
||||
- 修复“更新前先覆盖 Compose 和管理脚本”的风险,改为临时下载、语法与 Compose 校验通过后再原子替换。
|
||||
- 容器停止时也会离线备份数据库;备份为空或完整性检查失败时终止更新。
|
||||
- 重新安装会完整保存旧安装,安装失败时自动恢复原目录、Nginx 和旧容器。
|
||||
- 移除“开放全部端口”危险选项,不再清空服务器现有防火墙规则。
|
||||
- 卸载时可单独停止当前域名的证书自动续期,不影响 acme.sh 管理的其他域名。
|
||||
|
||||
### 邮箱指南与快捷命令
|
||||
|
||||
- 新增 `ns` 快捷命令,可随时打开服务器管理菜单。
|
||||
- 新增 `newszxcn-email guide`,动态显示前后台地址、管理员用户名、证书到期时间和自动续期状态。
|
||||
- 新增完整的 NewSzxcn 邮箱指南,覆盖域名 DNS、自助申请邮箱、未知收件、SSL 自动续期、更新、回滚与日常运维。
|
||||
|
||||
### 兼容与验证
|
||||
|
||||
- 本次更新不删除现有邮件、域名、账号、证书和配置。
|
||||
- 已通过 ShellCheck、安装脚本测试、Go API 测试、shadcn/ui 检查和前端生产构建。
|
||||
|
||||
**完整更新日志**:[v1.2.5...v1.2.6](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.5...v1.2.6)
|
||||
@@ -0,0 +1,21 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复网页更新出现 502
|
||||
|
||||
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
|
||||
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
|
||||
- 前端遇到更新期间的 `502`、`503`、`504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
|
||||
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
|
||||
|
||||
### 更新说明
|
||||
|
||||
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
|
||||
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
|
||||
- 从 `v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
|
||||
|
||||
### 验证
|
||||
|
||||
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
|
||||
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
|
||||
@@ -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 sqlite3
|
||||
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:
|
||||
|
||||
@@ -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 sqlite3
|
||||
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:
|
||||
@@ -204,6 +212,11 @@ jobs:
|
||||
image_base="${image_base,,}"
|
||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
|
||||
if [[ -f "${version_notes}" ]]; then
|
||||
cp "${version_notes}" generated-release-notes.md
|
||||
else
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
@@ -225,10 +238,9 @@ jobs:
|
||||
fi
|
||||
} > generated-release-notes.md
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# NewSzxcn Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -29,10 +29,24 @@ 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)
|
||||
```
|
||||
|
||||
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
|
||||
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
|
||||
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
|
||||
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 询问邮件域名、访问地址、管理员用户名和密码
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
@@ -48,6 +62,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
@@ -56,21 +72,31 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo ns
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
@@ -104,10 +130,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`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
|
||||
+16
-4
@@ -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`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||
return
|
||||
}
|
||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
||||
a.log.Error("trigger system update", "error", err)
|
||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
||||
return
|
||||
}
|
||||
|
||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"ok": true,
|
||||
@@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
"targetVersion": info.LatestVersion,
|
||||
"message": "更新已启动,服务会在完成后自动恢复",
|
||||
})
|
||||
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||
}
|
||||
|
||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||
@@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: 10 * time.Minute,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
@@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
|
||||
go func() {
|
||||
// Let the accepted response reach the browser before Watchtower replaces this container.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := a.triggerUpdateService(ctx); err != nil {
|
||||
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
defer releaseServer.Close()
|
||||
|
||||
var updateRequests atomic.Int32
|
||||
updateStarted := make(chan struct{}, 1)
|
||||
releaseUpdate := make(chan struct{})
|
||||
var releaseUpdateOnce sync.Once
|
||||
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
|
||||
defer releaseBlockedUpdate()
|
||||
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("update method = %s", r.Method)
|
||||
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
t.Errorf("authorization = %q", got)
|
||||
}
|
||||
updateRequests.Add(1)
|
||||
updateStarted <- struct{}{}
|
||||
<-releaseUpdate
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer updateServer.Close()
|
||||
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
t.Fatalf("unexpected version response: %+v", version)
|
||||
}
|
||||
|
||||
var update map[string]any
|
||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
||||
t.Fatalf("update code=%d response=%v", code, update)
|
||||
type updateResponse struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
if updateRequests.Load() != 1 {
|
||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
||||
response := make(chan updateResponse, 1)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
req.AddCookie(admin.cookie)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
response <- updateResponse{code: resp.StatusCode}
|
||||
}()
|
||||
select {
|
||||
case result := <-response:
|
||||
if result.err != nil || result.code != http.StatusAccepted {
|
||||
t.Fatalf("update response=%+v", result)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
releaseBlockedUpdate()
|
||||
t.Fatal("update response waited for container replacement")
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("scheduled update request did not start")
|
||||
}
|
||||
releaseBlockedUpdate()
|
||||
if got := updateRequests.Load(); got != 1 {
|
||||
t.Fatalf("update requests=%d", got)
|
||||
}
|
||||
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||
if err != nil || len(backups) != 1 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
setUpdatePhase("starting")
|
||||
const result = await api.updateSystem()
|
||||
const targetVersion = version.data?.latestVersion
|
||||
let result: Awaited<ReturnType<typeof api.updateSystem>>
|
||||
try {
|
||||
result = await api.updateSystem()
|
||||
} catch (error) {
|
||||
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
|
||||
result = {
|
||||
ok: true,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
message: "更新请求已发送,正在等待服务恢复",
|
||||
}
|
||||
}
|
||||
setUpdatePhase("restarting")
|
||||
await waitForUpdatedService(result.targetVersion)
|
||||
return result
|
||||
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isUpdateConnectionInterruption(error: unknown) {
|
||||
if (!(error instanceof Error)) return false
|
||||
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
|
||||
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
||||
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
||||
"加载邮箱...": { "zh-TW": "載入信箱...", en: "Loading mailboxes..." },
|
||||
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
|
||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||
@@ -179,6 +181,12 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
|
||||
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
|
||||
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
|
||||
"请前往邮箱管理,创建、申请或联系管理员分配邮箱。": { "zh-TW": "請前往信箱管理,建立、申請或聯絡管理員分配信箱。", en: "Open mailbox management to create, request, or ask an administrator to assign a mailbox." },
|
||||
"前往邮箱管理": { "zh-TW": "前往信箱管理", en: "Go to mailbox management" },
|
||||
"提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。": { "zh-TW": "提示:尚未選擇開放網域。請在「後台管理 → 系統設定 → 郵件」中至少勾選一個已啟用網域。", en: "No domain is open for mailbox requests. Open Admin → System settings → Mail and select at least one active domain." },
|
||||
"提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。": { "zh-TW": "提示:帳號自助申請信箱尚未開啟。請在「後台管理 → 系統設定 → 郵件」中開啟,並勾選開放網域。", en: "Mailbox self-service is disabled. Enable it under Admin → System settings → Mail, then select the available domains." },
|
||||
"提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。": { "zh-TW": "提示:目前帳號暫時無法建立新信箱,請聯絡管理員開啟帳號自助申請信箱。", en: "This account cannot create a mailbox. Ask an administrator to enable mailbox self-service." },
|
||||
"前往设置": { "zh-TW": "前往設定", en: "Open settings" },
|
||||
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
|
||||
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
|
||||
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
|
||||
|
||||
@@ -27,6 +27,7 @@ import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
@@ -132,7 +133,7 @@ export function AdminPage() {
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} initialTab={params.get("settingsTab")} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -1018,7 +1019,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
function SystemSettingsSection({ settings, domains, initialTab }: { settings?: SystemSettings; domains: Domain[]; initialTab?: string | null }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
@@ -1030,7 +1031,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base")
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -1858,7 +1860,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
||||
<Field name="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
|
||||
<Field name="displayName" label="显示名称" placeholder="账号名称" />
|
||||
<Field name="password" label="初始密码" type="password" minLength={8} />
|
||||
<Field name="password" label="初始密码" type="password" minLength={6} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
||||
@@ -1928,7 +1930,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 <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
@@ -1948,7 +1950,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 <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 6 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||
|
||||
+12
-11
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
||||
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
@@ -1186,9 +1186,9 @@ export function MailPage() {
|
||||
<MailboxSwitcher
|
||||
collapsed={sidebarCollapsed}
|
||||
mailboxes={mailboxList.data?.items || []}
|
||||
loading={mailboxList.isLoading}
|
||||
selectedMailboxId={selectedMailboxId}
|
||||
selectedMailbox={selectedMailbox}
|
||||
fallbackAddress={selectedMailbox?.address || me.data?.user.email || ""}
|
||||
unreadCount={mailboxUnreadCount}
|
||||
onSelect={switchMailbox}
|
||||
/>
|
||||
@@ -1468,7 +1468,7 @@ export function MailPage() {
|
||||
) : !canReadMail ? (
|
||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||
<NoMailboxState onOpenSettings={openSettings} />
|
||||
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
||||
) : mailView === "scheduled" && canScheduleMail ? (
|
||||
<ScheduledSendView
|
||||
compact={compactMailLayout}
|
||||
@@ -2061,7 +2061,7 @@ function externalAccountSubtitle(account: ExternalImapAccount) {
|
||||
return [name, account.host, mode].filter(Boolean).join(" · ")
|
||||
}
|
||||
|
||||
function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
function NoMailboxState({ onManageMailboxes }: { onManageMailboxes: () => void }) {
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center p-6">
|
||||
<div className="w-full max-w-md rounded-lg border border-dashed p-8 text-center">
|
||||
@@ -2069,9 +2069,9 @@ function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-lg font-semibold">还没有可用邮箱</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。</div>
|
||||
<Button className="mt-5" onClick={onOpenSettings}>
|
||||
<Settings className="h-4 w-4" />前往个人中心
|
||||
<div className="mt-2 text-sm text-muted-foreground">请前往邮箱管理,创建、申请或联系管理员分配邮箱。</div>
|
||||
<Button className="mt-5" onClick={onManageMailboxes}>
|
||||
<MailboxIcon className="h-4 w-4" />前往邮箱管理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3215,10 +3215,11 @@ function UnreadBadge({ count, tone = "danger" }: { count?: number; tone?: "dange
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMailbox, fallbackAddress, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailboxId: string; selectedMailbox?: Mailbox; fallbackAddress?: string; unreadCount: number; onSelect: (mailboxId: string) => void }) {
|
||||
function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, selectedMailbox, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; loading: boolean; selectedMailboxId: string; selectedMailbox?: Mailbox; unreadCount: number; onSelect: (mailboxId: string) => void }) {
|
||||
const [mailboxQuery, setMailboxQuery] = React.useState("")
|
||||
const isAllSelected = selectedMailboxId === "all"
|
||||
const displayAddress = isAllSelected ? "全部邮箱" : selectedMailbox?.address || fallbackAddress || "选择邮箱"
|
||||
const mailboxUnavailable = loading || mailboxes.length === 0
|
||||
const displayAddress = loading ? "加载邮箱..." : mailboxes.length === 0 ? "未创建邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
|
||||
const selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
|
||||
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
||||
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
|
||||
@@ -3232,13 +3233,13 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
|
||||
return (
|
||||
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className={cn("h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
|
||||
<Button disabled={mailboxUnavailable} variant="outline" className={cn("h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{displayAddress}</span>
|
||||
<UnreadBadge count={selectedUnreadCount} />
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{!mailboxUnavailable && <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -82,6 +82,7 @@ export function ProfilePage() {
|
||||
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
|
||||
const canViewStats = hasPermission(user, "mail.stats.view")
|
||||
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
|
||||
const canConfigureMailboxApply = hasPermission(user, "admin.settings.update")
|
||||
const visibleTabKeys = tabKeys.filter((key) => {
|
||||
if (key === "profile") return true
|
||||
if (key === "mailboxes") return canAccessMail || canApplyMailbox
|
||||
@@ -453,6 +454,7 @@ export function ProfilePage() {
|
||||
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
|
||||
applyOptions={mailboxApplyOptions.data}
|
||||
applyPending={applyMailbox.isPending}
|
||||
canConfigureApply={canConfigureMailboxApply}
|
||||
selectedMailboxId={mailboxId}
|
||||
externalImapEnabled={externalImapEnabled}
|
||||
externalAccounts={externalImapAccounts.data?.items || []}
|
||||
@@ -464,6 +466,7 @@ export function ProfilePage() {
|
||||
onSelect={setMailboxId}
|
||||
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
|
||||
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
|
||||
onConfigureApply={() => navigate("/admin?section=settings&settingsTab=mail")}
|
||||
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
|
||||
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
|
||||
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
|
||||
@@ -905,8 +908,8 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
||||
<SettingsCard title="密码管理">
|
||||
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
|
||||
<Field label="当前密码"><PasswordInput name="currentPassword" required /></Field>
|
||||
<Field label="新密码"><PasswordInput name="newPassword" minLength={8} required placeholder="输入新密码" /></Field>
|
||||
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={8} required placeholder="再次输入密码" /></Field>
|
||||
<Field label="新密码"><PasswordInput name="newPassword" minLength={6} required placeholder="输入新密码" /></Field>
|
||||
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={6} required placeholder="再次输入密码" /></Field>
|
||||
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
@@ -1000,6 +1003,7 @@ function MailboxManagement({
|
||||
mailboxes,
|
||||
applyOptions,
|
||||
applyPending,
|
||||
canConfigureApply,
|
||||
selectedMailboxId,
|
||||
externalImapEnabled,
|
||||
externalAccounts,
|
||||
@@ -1011,6 +1015,7 @@ function MailboxManagement({
|
||||
onSelect,
|
||||
onOpen,
|
||||
onApply,
|
||||
onConfigureApply,
|
||||
onCreateExternal,
|
||||
onStartExternalOAuth,
|
||||
onUpdateExternal,
|
||||
@@ -1022,6 +1027,7 @@ function MailboxManagement({
|
||||
mailboxes: Mailbox[]
|
||||
applyOptions?: MailboxApplyOptions
|
||||
applyPending: boolean
|
||||
canConfigureApply: boolean
|
||||
selectedMailboxId: string
|
||||
externalImapEnabled: boolean
|
||||
externalAccounts: ExternalImapAccount[]
|
||||
@@ -1033,6 +1039,7 @@ function MailboxManagement({
|
||||
onSelect: (id: string) => void
|
||||
onOpen: (id: string) => void
|
||||
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
|
||||
onConfigureApply: () => void
|
||||
onCreateExternal: (payload: ExternalImapAccountPayload) => void
|
||||
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
|
||||
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
|
||||
@@ -1208,9 +1215,16 @@ function MailboxManagement({
|
||||
</select>
|
||||
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{canApply ? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。" : "提示:当前账号暂不可创建新邮箱。"}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
<span>{canApply
|
||||
? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。"
|
||||
: canConfigureApply && applyOptions?.enabled
|
||||
? "提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。"
|
||||
: canConfigureApply
|
||||
? "提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。"
|
||||
: "提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。"}</span>
|
||||
{!canApply && canConfigureApply && <Button type="button" variant="link" className="h-auto p-0 text-sm" onClick={onConfigureApply}>前往设置</Button>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border bg-card">
|
||||
|
||||
@@ -113,11 +113,11 @@ export function RegisterPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-sm font-medium">确认密码</Label>
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
||||
|
||||
+5
-2
@@ -20,9 +20,12 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
|
||||
# 手动部署可执行:openssl rand -hex 24
|
||||
LANQIN_UPDATE_TOKEN=
|
||||
|
||||
# 可选端口绑定。使用宿主机反向代理时,可将 HTTP 设为 127.0.0.1:8088。
|
||||
# 一键安装器记录的部署方式。手动部署时可以留空。
|
||||
LANQIN_INSTALL_FIREWALL_MODE=
|
||||
LANQIN_INSTALL_WEB_MODE=
|
||||
|
||||
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
|
||||
LANQIN_HTTP_BIND=80
|
||||
LANQIN_HTTPS_BIND=443
|
||||
LANQIN_SMTP_BIND=25
|
||||
LANQIN_SMTPS_BIND=465
|
||||
LANQIN_SUBMISSION_BIND=587
|
||||
|
||||
+11
-3
@@ -15,10 +15,19 @@ sudo newszxcn-email update
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email rollback
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
```
|
||||
|
||||
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
||||
|
||||
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
|
||||
|
||||
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
|
||||
|
||||
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。
|
||||
|
||||
## 最简单部署:单容器镜像版
|
||||
|
||||
服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。
|
||||
@@ -158,10 +167,9 @@ Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/
|
||||
|
||||
```dotenv
|
||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
||||
LANQIN_HTTPS_BIND=127.0.0.1:8443
|
||||
```
|
||||
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。不使用宿主机反向代理时保留默认的 `80` 与 `443` 即可。
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
@@ -182,7 +190,7 @@ services:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./certs:/certs:ro
|
||||
```
|
||||
|
||||
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
||||
|
||||
@@ -7,7 +7,6 @@ services:
|
||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||
ports:
|
||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||
- "${LANQIN_HTTPS_BIND:-443}:443"
|
||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||
- "${LANQIN_SMTPS_BIND:-465}:465"
|
||||
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
||||
@@ -17,8 +16,7 @@ services:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./certs:/certs:ro
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
# NewSzxcn 邮箱指南
|
||||
|
||||
本指南介绍 NewSzxcn Email 的安装入口、首次配置、邮箱申请、无人收件、SSL 证书和日常更新。管理员密码等敏感信息不会保存在本文档中。
|
||||
|
||||
## 一键安装
|
||||
|
||||
建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
安装脚本会依次询问防火墙配置、邮件服务器域名、管理员用户名和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
|
||||
|
||||
安装完成后,请记录终端中显示的访问地址、管理员用户名和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
|
||||
|
||||
## 登录入口
|
||||
|
||||
假设安装时填写的邮件服务器域名为 `mail.example.com`:
|
||||
|
||||
| 入口 | 地址 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 邮箱前台 | `https://mail.example.com/` | 收发邮件、申请邮箱和账号设置 |
|
||||
| 管理后台 | `https://mail.example.com/admin` | 管理域名、账号、邮箱、DNS 和系统设置 |
|
||||
|
||||
管理员账号是安装时填写的用户名,默认为 `admin`。管理员用户名不是邮箱地址。
|
||||
|
||||
## 首次配置
|
||||
|
||||
### 1. 添加邮件域名
|
||||
|
||||
1. 登录 NewSzxcn Email 管理后台。
|
||||
2. 进入“域名管理”,点击“添加域名”。
|
||||
3. 填写需要收发邮件的域名并保存。
|
||||
4. 点击该域名右侧的“DNS”,查看系统生成的记录。
|
||||
5. 前往域名服务商的 DNS 管理页面,逐项添加 MX、SPF、DKIM 和 DMARC 记录。
|
||||
6. 返回管理后台,点击“检测”。
|
||||
7. 所有记录检测通过后,即可使用该域名创建邮箱。
|
||||
|
||||
DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能代替你修改域名服务商的 DNS。
|
||||
|
||||
### 2. 开启账号自助申请邮箱
|
||||
|
||||
1. 进入“管理后台 -> 系统设置 -> 邮件”。
|
||||
2. 开启“账号自助申请邮箱”。
|
||||
3. 在“开放域名”中勾选允许用户申请邮箱的域名。
|
||||
4. 保存设置。
|
||||
|
||||
开启后,用户登录邮箱前台,进入“设置 -> 邮箱管理”,即可在账号配额范围内自行申请邮箱,无需管理员逐个分配。
|
||||
|
||||
如果账号还没有邮箱,邮箱前台会显示“还没有可用邮箱”。此时应点击“前往邮箱管理”,进入个人中心申请邮箱。
|
||||
|
||||
### 3. 开启无人收件
|
||||
|
||||
1. 进入“管理后台 -> 系统设置 -> 邮件”。
|
||||
2. 开启“无人收件”并保存。
|
||||
|
||||
开启后,对于系统中已经添加并启用的邮件域名,即使收件地址尚未注册,服务器仍会接收邮件。例如已经启用 `example.com` 后,发送到 `111@example.com` 的邮件也会被保留。
|
||||
|
||||
无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。
|
||||
|
||||
## SSL 证书与自动续期
|
||||
|
||||
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
|
||||
|
||||
查看当前域名的证书和续期信息:
|
||||
|
||||
```bash
|
||||
/root/.acme.sh/acme.sh --info --domain mail.example.com --ecc
|
||||
```
|
||||
|
||||
查看证书实际到期时间:
|
||||
|
||||
```bash
|
||||
openssl x509 -in /opt/newszxcn-email/certs/fullchain.pem -noout -enddate
|
||||
```
|
||||
|
||||
手动申请、检查或重新安装证书:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email certificate
|
||||
```
|
||||
|
||||
证书续期计划由 `acme.sh` 和证书颁发机构动态决定,不应把预计续期日期写死在配置或文档中。
|
||||
|
||||
## 更新与运维
|
||||
|
||||
重新打开安装与运维菜单:
|
||||
|
||||
```bash
|
||||
sudo ns
|
||||
```
|
||||
|
||||
也可以执行 `sudo newszxcn-email menu`,或重新运行一键安装命令。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email rollback
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
```
|
||||
|
||||
命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除。
|
||||
|
||||
`guide` 命令会读取当前安装地址、管理员用户名、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`。
|
||||
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库只保存 bcrypt 密码哈希,无法反向查看真实密码;若管理员后来在网页修改过密码,记录值可能已经失效。忘记密码时执行 `reset-password`,脚本会先备份并校验数据库,然后重置配置管理员的统一登录密码,同时同步该管理员名下邮箱的 SMTP/IMAP 密码。该操作不会修改普通用户或其邮箱。
|
||||
|
||||
超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。
|
||||
|
||||
## 必要端口
|
||||
|
||||
请同时检查服务器防火墙和云服务商安全组:
|
||||
|
||||
| 端口 | 用途 |
|
||||
| --- | --- |
|
||||
| `25/TCP` | 邮件服务器之间收发邮件 |
|
||||
| `80/TCP` | HTTP 跳转和证书签发验证 |
|
||||
| `443/TCP` | 邮箱前台和管理后台 |
|
||||
| `465/TCP` | SMTP SSL 发信 |
|
||||
| `587/TCP` | SMTP Submission 发信 |
|
||||
| `993/TCP` | IMAP SSL 收信 |
|
||||
| `995/TCP` | POP3 SSL 收信 |
|
||||
|
||||
部分云服务商默认封锁出站 `25/TCP`。网页可以正常打开并不代表公网邮件一定能够成功投递。
|
||||
|
||||
## 数据与备份
|
||||
|
||||
默认数据目录为 `/opt/newszxcn-email`。重要数据包括:
|
||||
|
||||
```text
|
||||
/opt/newszxcn-email/
|
||||
|-- .env
|
||||
|-- data/
|
||||
|-- mail/
|
||||
|-- dkim/
|
||||
`-- certs/
|
||||
```
|
||||
|
||||
执行服务器快照或异地备份时,应同时保存这些目录。不要公开 `.env`、证书私钥、数据库备份或管理员登录信息。
|
||||
|
||||
## 更多文档
|
||||
|
||||
- [项目说明](../README.md)
|
||||
- [Docker 部署说明](../deploy/README.md)
|
||||
- [API 文档](API.md)
|
||||
- [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases)
|
||||
+1165
-74
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,527 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export LANQIN_SOURCE_ONLY=true
|
||||
# shellcheck source=install.sh
|
||||
source "${ROOT_DIR}/install.sh"
|
||||
|
||||
fail_test() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local want="$1" got="$2" label="$3"
|
||||
[[ "${got}" == "${want}" ]] || fail_test "${label}: got '${got}', want '${want}'"
|
||||
}
|
||||
|
||||
test_hostname_validation() {
|
||||
valid_hostname "mail.example.com" || fail_test "valid hostname rejected"
|
||||
valid_hostname "mx-1.example.co.uk" || fail_test "valid multi-label hostname rejected"
|
||||
! valid_hostname "mail_example.com" || fail_test "hostname with underscore accepted"
|
||||
! valid_hostname "localhost" || fail_test "single-label hostname accepted"
|
||||
! valid_hostname "-mail.example.com" || fail_test "hostname with leading hyphen accepted"
|
||||
}
|
||||
|
||||
test_password_validation() {
|
||||
LANQIN_ADMIN_PASSWORD="abc123"
|
||||
assert_eq "abc123" "$(prompt_admin_password)" "six-character password"
|
||||
if (LANQIN_ADMIN_PASSWORD="abc12" prompt_admin_password >/dev/null 2>&1); then
|
||||
fail_test "five-character password accepted"
|
||||
fi
|
||||
if (LANQIN_ADMIN_PASSWORD="abc\$123" prompt_admin_password >/dev/null 2>&1); then
|
||||
fail_test "unsafe env-file password accepted"
|
||||
fi
|
||||
if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then
|
||||
fail_test "password beginning with an env-file comment marker accepted"
|
||||
fi
|
||||
LANQIN_RESET_PASSWORD="reset1"
|
||||
assert_eq "reset1" "$(prompt_reset_password)" "six-character reset password"
|
||||
if (LANQIN_RESET_PASSWORD="reset" prompt_reset_password >/dev/null 2>&1); then
|
||||
fail_test "five-character reset password accepted"
|
||||
fi
|
||||
}
|
||||
|
||||
test_install_configuration() {
|
||||
local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5"
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env.example"
|
||||
|
||||
export INSTALL_DIR="${temp_dir}"
|
||||
export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}"
|
||||
export LANQIN_PUBLIC_HOSTNAME="mail.example.com"
|
||||
export LANQIN_ADMIN_USERNAME="admin"
|
||||
export LANQIN_ADMIN_PASSWORD="abc123"
|
||||
export LANQIN_INSTALL_WEB_MODE="${web_mode}"
|
||||
configure_first_install
|
||||
configure_runtime_bindings
|
||||
|
||||
assert_eq "${firewall_mode}" "$(env_value LANQIN_INSTALL_FIREWALL_MODE)" "firewall mode"
|
||||
assert_eq "${web_mode}" "$(env_value LANQIN_INSTALL_WEB_MODE)" "web mode"
|
||||
assert_eq "${want_bind}" "$(env_value LANQIN_HTTP_BIND)" "HTTP bind"
|
||||
assert_eq "${want_url}" "$(env_value LANQIN_PUBLIC_BASE_URL)" "public URL"
|
||||
assert_eq "${want_insecure}" "$(env_value LANQIN_ALLOW_INSECURE_HTTP)" "insecure HTTP flag"
|
||||
assert_eq "abc123" "$(env_value LANQIN_ADMIN_PASSWORD)" "administrator password"
|
||||
}
|
||||
|
||||
test_nginx_configuration() {
|
||||
local temp_dir old_path
|
||||
temp_dir="$(mktemp -d)"
|
||||
old_path="${PATH}"
|
||||
mkdir -p "${temp_dir}/bin" "${temp_dir}/install" "${temp_dir}/certs" "${temp_dir}/acme"
|
||||
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/nginx"
|
||||
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/systemctl"
|
||||
chmod 0755 "${temp_dir}/bin/nginx" "${temp_dir}/bin/systemctl"
|
||||
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/install/.env"
|
||||
|
||||
export PATH="${temp_dir}/bin:${PATH}"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
|
||||
ACME_WEBROOT="${temp_dir}/acme"
|
||||
CERT_DIR="${temp_dir}/certs"
|
||||
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
|
||||
|
||||
write_nginx_http_config
|
||||
grep -Fq 'proxy_pass http://127.0.0.1:8088;' "${NGINX_CONFIG}" || fail_test "HTTP proxy target missing"
|
||||
grep -Fq 'root '"${ACME_WEBROOT}"';' "${NGINX_CONFIG}" || fail_test "ACME webroot missing"
|
||||
|
||||
write_nginx_https_config
|
||||
grep -Fq 'listen 443 ssl http2;' "${NGINX_CONFIG}" || fail_test "HTTPS listener missing"
|
||||
# shellcheck disable=SC2016
|
||||
grep -Fq 'return 301 https://$host$request_uri;' "${NGINX_CONFIG}" || fail_test "HTTPS redirect missing"
|
||||
grep -Fq "ssl_certificate ${CERT_DIR}/fullchain.pem;" "${NGINX_CONFIG}" || fail_test "certificate path missing"
|
||||
PATH="${old_path}"
|
||||
}
|
||||
|
||||
test_compose_configuration() {
|
||||
# shellcheck disable=SC2016
|
||||
grep -Fq '${LANQIN_HTTP_BIND:-80}:80' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "HTTP port mapping missing"
|
||||
! grep -Fq 'LANQIN_HTTPS_BIND' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "dead container HTTPS mapping remains"
|
||||
grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing"
|
||||
}
|
||||
|
||||
test_legacy_configuration_is_preserved() {
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env"
|
||||
export INSTALL_DIR="${temp_dir}"
|
||||
set_env LANQIN_INSTALL_WEB_MODE ""
|
||||
set_env LANQIN_HTTP_BIND "127.0.0.1:9090"
|
||||
configure_first_install
|
||||
configure_runtime_bindings
|
||||
assert_eq "127.0.0.1:9090" "$(env_value LANQIN_HTTP_BIND)" "legacy HTTP bind"
|
||||
}
|
||||
|
||||
test_menu_choice() {
|
||||
export LANQIN_MENU_ACTION=0
|
||||
assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action"
|
||||
export LANQIN_MENU_ACTION=1
|
||||
assert_eq "1" "$(prompt_menu_choice 2)" "menu install action"
|
||||
export LANQIN_MENU_ACTION=12
|
||||
assert_eq "12" "$(prompt_menu_choice 1 12)" "menu uninstall action"
|
||||
if (has_tty() { return 1; }; LANQIN_MENU_ACTION=13 prompt_menu_choice 1 12 >/dev/null 2>&1); then
|
||||
fail_test "out-of-range menu action accepted"
|
||||
fi
|
||||
unset LANQIN_MENU_ACTION
|
||||
}
|
||||
|
||||
test_admin_credentials() (
|
||||
local temp_dir output
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
cat > "${INSTALL_DIR}/.env" <<'EOF'
|
||||
LANQIN_PUBLIC_BASE_URL=https://mail.example.com
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
LANQIN_ADMIN_PASSWORD=recorded-password
|
||||
EOF
|
||||
output="$(do_show_admin_credentials 2>&1)"
|
||||
[[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing"
|
||||
[[ "${output}" == *'管理员用户名:admin'* ]] || fail_test "administrator username missing"
|
||||
[[ "${output}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing"
|
||||
[[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning missing"
|
||||
)
|
||||
|
||||
test_admin_password_hash_parsing() (
|
||||
compose() {
|
||||
# shellcheck disable=SC2016
|
||||
printf '{BLF-CRYPT}$2y$10$123456789012345678901u1234567890123456789012345678901\n'
|
||||
}
|
||||
# shellcheck disable=SC2016
|
||||
assert_eq '$2y$10$123456789012345678901u1234567890123456789012345678901' "$(generate_admin_password_hash 'unused')" "Dovecot bcrypt hash parsing"
|
||||
)
|
||||
|
||||
test_admin_password_reset_only_updates_admin_account() (
|
||||
local temp_dir compose_calls backup_path
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
compose_calls="${temp_dir}/compose-calls"
|
||||
mkdir -p "${INSTALL_DIR}/data/backups"
|
||||
cat > "${INSTALL_DIR}/.env" <<'EOF'
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
LANQIN_ADMIN_PASSWORD=old-password
|
||||
EOF
|
||||
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
|
||||
|
||||
ensure_docker() { return 0; }
|
||||
current_image_id() { printf 'sha256:test-image\n'; }
|
||||
backup_database() {
|
||||
backup_path="$1"
|
||||
printf 'backup\n' > "${backup_path}"
|
||||
}
|
||||
prompt_reset_password() { printf 'new-password'; }
|
||||
# shellcheck disable=SC2016
|
||||
generate_admin_password_hash() { printf '$2y$10$123456789012345678901u1234567890123456789012345678901'; }
|
||||
compose() {
|
||||
printf '%s\n' "$*" >> "${compose_calls}"
|
||||
if [[ "$*" == *'SELECT id FROM users'* ]]; then
|
||||
printf 'admin-user-id\n'
|
||||
elif [[ "$*" == *'UPDATE users SET password_hash'* ]]; then
|
||||
printf 'user=1\nmailboxes=2\n'
|
||||
fi
|
||||
}
|
||||
|
||||
do_reset_admin_password >/dev/null
|
||||
assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password"
|
||||
[[ -s "${backup_path}" ]] || fail_test "password reset database backup missing"
|
||||
grep -Fq "login_name='admin' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not role restricted"
|
||||
grep -Fq "UPDATE users SET password_hash=" "${compose_calls}" || fail_test "administrator user password was not updated"
|
||||
grep -Fq "UPDATE mailboxes SET password_hash=" "${compose_calls}" || fail_test "administrator mailbox passwords were not synchronized"
|
||||
grep -Fq "WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "mailbox password update is not restricted to the administrator"
|
||||
)
|
||||
|
||||
test_offline_database_backup() (
|
||||
local temp_dir destination
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
mkdir -p "${INSTALL_DIR}/data/backups"
|
||||
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO test_items(value) VALUES ("saved");'
|
||||
compose() { return 0; }
|
||||
destination="${INSTALL_DIR}/data/backups/offline.db"
|
||||
backup_database "${destination}" "unused-image"
|
||||
[[ -s "${destination}" ]] || fail_test "offline database backup missing"
|
||||
assert_eq "saved" "$(sqlite3 "${destination}" 'SELECT value FROM test_items LIMIT 1;')" "offline database content"
|
||||
)
|
||||
|
||||
test_guide_generation() (
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
CERT_DIR="${INSTALL_DIR}/certs"
|
||||
GUIDE_FILE="${temp_dir}/guide.txt"
|
||||
mkdir -p "${CERT_DIR}"
|
||||
cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env"
|
||||
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
|
||||
set_env LANQIN_PUBLIC_BASE_URL "https://mail.example.com"
|
||||
set_env LANQIN_ADMIN_USERNAME "admin"
|
||||
generate_guide
|
||||
grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing"
|
||||
grep -Fq '管理后台:https://mail.example.com/admin' "${GUIDE_FILE}" || fail_test "guide admin URL missing"
|
||||
grep -Fq '管理员密码:仅在安装完成时显示' "${GUIDE_FILE}" || fail_test "guide password safety text missing"
|
||||
[[ "$(stat -c '%a' "${GUIDE_FILE}" 2>/dev/null || stat -f '%Lp' "${GUIDE_FILE}")" == "600" ]] || fail_test "guide permissions are not 600"
|
||||
)
|
||||
|
||||
test_acme_cron_detection() (
|
||||
crontab() {
|
||||
printf '49 0,6,12,18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null\n'
|
||||
}
|
||||
acme_cron_enabled || fail_test "quoted acme.sh Cron entry was not detected"
|
||||
)
|
||||
|
||||
test_cli_alias_safety() (
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email"
|
||||
CLI_ALIAS_PATH="${temp_dir}/ns"
|
||||
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
|
||||
chmod 0755 "${CLI_PATH}"
|
||||
ensure_cli_alias
|
||||
[[ -L "${CLI_ALIAS_PATH}" ]] || fail_test "ns alias was not created"
|
||||
assert_eq "${CLI_PATH}" "$(readlink "${CLI_ALIAS_PATH}")" "ns alias target"
|
||||
rm -f "${CLI_ALIAS_PATH}"
|
||||
printf 'occupied\n' > "${CLI_ALIAS_PATH}"
|
||||
ensure_cli_alias
|
||||
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
|
||||
)
|
||||
|
||||
test_compose_runtime_image_pin() (
|
||||
local temp_dir calls
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
|
||||
calls="${temp_dir}/docker-calls"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'sha256:rollback-image\n' > "${RUNTIME_IMAGE_PIN}"
|
||||
docker() {
|
||||
printf '%s|%s\n' "${LANQIN_IMAGE:-}" "$*" >> "${calls}"
|
||||
}
|
||||
|
||||
compose ps
|
||||
grep -Fq 'sha256:rollback-image|compose ' "${calls}" || fail_test "rollback image pin was not applied to Compose"
|
||||
clear_runtime_image_pin
|
||||
compose ps
|
||||
[[ "$(tail -n 1 "${calls}" | cut -d '|' -f 1)" == "" ]] || fail_test "cleared image pin still affected Compose"
|
||||
)
|
||||
|
||||
test_update_snapshot_restore() (
|
||||
local temp_dir snapshot
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
CERT_DIR="${INSTALL_DIR}/certs"
|
||||
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email-cli"
|
||||
CLI_ALIAS_PATH="${temp_dir}/ns"
|
||||
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
|
||||
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
|
||||
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
|
||||
mkdir -p "${INSTALL_DIR}/data/backups" "${CERT_DIR}"
|
||||
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\nOLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
|
||||
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
|
||||
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
|
||||
chmod 0755 "${CLI_PATH}"
|
||||
printf 'old-nginx\n' > "${NGINX_CONFIG}"
|
||||
printf 'old-certificate\n' > "${CERT_DIR}/fullchain.pem"
|
||||
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("before-update");'
|
||||
|
||||
current_image_id() { printf 'sha256:old-image\n'; }
|
||||
docker() {
|
||||
if [[ "$*" == *'org.opencontainers.image.version'* ]]; then
|
||||
printf '1.2.4\n'
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
compose() {
|
||||
if [[ "${1:-}" == "up" ]]; then
|
||||
grep -Fq 'sha256:old-image' "${RUNTIME_IMAGE_PIN}" || fail_test "restore started without image pin"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
nginx() { return 0; }
|
||||
systemctl() { return 0; }
|
||||
wait_for_health() { return 0; }
|
||||
ensure_cli_alias() { return 0; }
|
||||
|
||||
create_update_snapshot
|
||||
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
|
||||
[[ -s "${snapshot}/rollback-manifest.json" ]] || fail_test "rollback manifest missing"
|
||||
|
||||
printf 'new-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'NEW_ENV=yes\n' > "${INSTALL_DIR}/.env"
|
||||
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
|
||||
printf '#!/bin/sh\necho new-installer\n' > "${CLI_PATH}"
|
||||
printf 'new-nginx\n' > "${NGINX_CONFIG}"
|
||||
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
|
||||
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'DELETE FROM test_items; INSERT INTO test_items VALUES ("after-update");'
|
||||
|
||||
restore_update_snapshot "${snapshot}"
|
||||
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "Compose file was not restored"
|
||||
grep -Fq 'OLD_ENV=yes' "${INSTALL_DIR}/.env" || fail_test "environment file was not restored"
|
||||
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "environment example was not restored"
|
||||
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "installer was not restored"
|
||||
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored"
|
||||
grep -Fq 'old-certificate' "${CERT_DIR}/fullchain.pem" || fail_test "certificate was not restored"
|
||||
assert_eq "before-update" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT value FROM test_items;')" "restored database content"
|
||||
assert_eq "sha256:old-image" "$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")" "restored runtime image pin"
|
||||
)
|
||||
|
||||
test_snapshot_restores_absent_optional_files() (
|
||||
local temp_dir snapshot
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
CERT_DIR="${INSTALL_DIR}/certs"
|
||||
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email-cli"
|
||||
CLI_ALIAS_PATH="${temp_dir}/ns"
|
||||
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
|
||||
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
|
||||
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
|
||||
mkdir -p "${INSTALL_DIR}/data/backups"
|
||||
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
|
||||
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("saved");'
|
||||
|
||||
current_image_id() { printf 'sha256:old-image\n'; }
|
||||
docker() { return 0; }
|
||||
compose() { return 0; }
|
||||
nginx() { return 0; }
|
||||
systemctl() { return 0; }
|
||||
wait_for_health() { return 0; }
|
||||
ensure_cli_alias() { return 0; }
|
||||
|
||||
create_update_snapshot
|
||||
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
|
||||
[[ -f "${snapshot}/env-example.absent" ]] || fail_test "missing env example marker"
|
||||
[[ -f "${snapshot}/installer.absent" ]] || fail_test "missing installer marker"
|
||||
[[ -f "${snapshot}/nginx.absent" ]] || fail_test "missing Nginx marker"
|
||||
[[ -f "${snapshot}/certs.absent" ]] || fail_test "missing certificate marker"
|
||||
|
||||
mkdir -p "${CERT_DIR}"
|
||||
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
|
||||
printf '#!/bin/sh\n' > "${CLI_PATH}"
|
||||
printf 'new-nginx\n' > "${NGINX_CONFIG}"
|
||||
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
|
||||
restore_update_snapshot "${snapshot}"
|
||||
[[ ! -e "${INSTALL_DIR}/.env.example" ]] || fail_test "new env example survived rollback"
|
||||
[[ ! -e "${CLI_PATH}" ]] || fail_test "new installer survived rollback"
|
||||
[[ ! -e "${NGINX_CONFIG}" ]] || fail_test "new Nginx configuration survived rollback"
|
||||
[[ ! -e "${CERT_DIR}" ]] || fail_test "new certificate directory survived rollback"
|
||||
)
|
||||
|
||||
test_pre_start_restore_preserves_current_database() (
|
||||
local temp_dir snapshot
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
CERT_DIR="${INSTALL_DIR}/certs"
|
||||
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email-cli"
|
||||
CLI_ALIAS_PATH="${temp_dir}/ns"
|
||||
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
|
||||
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
|
||||
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
|
||||
mkdir -p "${INSTALL_DIR}/data/backups"
|
||||
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
|
||||
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE received_mail (subject TEXT); INSERT INTO received_mail VALUES ("before-snapshot");'
|
||||
|
||||
current_image_id() { printf 'sha256:old-image\n'; }
|
||||
docker() { return 0; }
|
||||
compose() { return 0; }
|
||||
reload_nginx() { return 0; }
|
||||
wait_for_health() { return 0; }
|
||||
ensure_cli_alias() { return 0; }
|
||||
|
||||
create_update_snapshot
|
||||
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
|
||||
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'INSERT INTO received_mail VALUES ("received-during-pull");'
|
||||
restore_update_snapshot "${snapshot}" false
|
||||
assert_eq "2" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT COUNT(*) FROM received_mail;')" "database preserved before new container start"
|
||||
assert_eq "received-during-pull" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT subject FROM received_mail ORDER BY rowid DESC LIMIT 1;')" "mail received during pull"
|
||||
)
|
||||
|
||||
test_failed_asset_validation_preserves_production() (
|
||||
local temp_dir source_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
source_dir="${temp_dir}/source"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email-cli"
|
||||
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
|
||||
mkdir -p "${source_dir}/deploy" "${INSTALL_DIR}"
|
||||
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'OLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
|
||||
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
|
||||
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
|
||||
printf 'sha256:pinned-image\n' > "${RUNTIME_IMAGE_PIN}"
|
||||
printf 'invalid compose\n' > "${source_dir}/deploy/docker-compose.yml"
|
||||
cp "${ROOT_DIR}/deploy/.env.example" "${source_dir}/deploy/.env.example"
|
||||
cp "${ROOT_DIR}/install.sh" "${source_dir}/install.sh"
|
||||
|
||||
script_dir() { printf '%s\n' "${source_dir}"; }
|
||||
docker() { return 1; }
|
||||
if (stage_assets >/dev/null 2>&1); then
|
||||
fail_test "invalid Compose file passed staging validation"
|
||||
fi
|
||||
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "production Compose changed after failed validation"
|
||||
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "production env example changed after failed validation"
|
||||
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "production installer changed after failed validation"
|
||||
grep -Fq 'sha256:pinned-image' "${RUNTIME_IMAGE_PIN}" || fail_test "runtime image pin changed after failed validation"
|
||||
)
|
||||
|
||||
test_backup_reinstall_restores_on_failure() (
|
||||
local temp_dir failed_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/newszxcn-email"
|
||||
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email-cli"
|
||||
CLI_ALIAS_PATH="${temp_dir}/ns"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
|
||||
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'old-nginx\n' > "${NGINX_CONFIG}"
|
||||
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
|
||||
chmod 0755 "${CLI_PATH}"
|
||||
|
||||
ensure_docker() { return 0; }
|
||||
current_image_id() { printf 'sha256:old-image\n'; }
|
||||
compose() { return 0; }
|
||||
nginx() { return 0; }
|
||||
systemctl() { return 0; }
|
||||
wait_for_health() { return 0; }
|
||||
ensure_cli_alias() { return 0; }
|
||||
do_install() {
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
printf 'failed-install\n' > "${INSTALL_DIR}/failed-marker"
|
||||
return 1
|
||||
}
|
||||
|
||||
if (do_backup_reinstall); then
|
||||
fail_test "failed reinstall unexpectedly succeeded"
|
||||
fi
|
||||
grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install directory was not restored"
|
||||
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "old Nginx configuration was not restored"
|
||||
failed_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.failed-*' -print -quit)"
|
||||
[[ -n "${failed_dir}" ]] || fail_test "failed reinstall directory was not preserved"
|
||||
)
|
||||
|
||||
test_backup_reinstall_recovers_from_nginx_reload_failure() (
|
||||
local temp_dir compose_calls reload_count_file
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/newszxcn-email"
|
||||
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email-cli"
|
||||
CLI_ALIAS_PATH="${temp_dir}/ns"
|
||||
compose_calls="${temp_dir}/compose-calls"
|
||||
reload_count_file="${temp_dir}/reload-count"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
|
||||
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||
printf 'old-nginx\n' > "${NGINX_CONFIG}"
|
||||
printf '0\n' > "${reload_count_file}"
|
||||
|
||||
ensure_docker() { return 0; }
|
||||
current_image_id() { printf 'sha256:old-image\n'; }
|
||||
compose() { printf '%s\n' "$*" >> "${compose_calls}"; return 0; }
|
||||
reload_nginx() {
|
||||
local count
|
||||
count="$(cat "${reload_count_file}")"
|
||||
printf '%s\n' "$((count + 1))" > "${reload_count_file}"
|
||||
[[ "${count}" -gt 0 ]]
|
||||
}
|
||||
wait_for_health() { return 0; }
|
||||
do_install() { fail_test "fresh install started after Nginx reload failure"; }
|
||||
|
||||
if (do_backup_reinstall >/dev/null 2>&1); then
|
||||
fail_test "reinstall continued after Nginx reload failure"
|
||||
fi
|
||||
grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install changed after Nginx reload failure"
|
||||
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored after reload failure"
|
||||
grep -Fq 'up -d --remove-orphans --force-recreate' "${compose_calls}" || fail_test "old containers were not restarted after Nginx reload failure"
|
||||
)
|
||||
|
||||
test_hostname_validation
|
||||
test_password_validation
|
||||
test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false"
|
||||
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
|
||||
test_nginx_configuration
|
||||
test_compose_configuration
|
||||
test_legacy_configuration_is_preserved
|
||||
test_menu_choice
|
||||
test_admin_credentials
|
||||
test_admin_password_hash_parsing
|
||||
test_admin_password_reset_only_updates_admin_account
|
||||
test_offline_database_backup
|
||||
test_guide_generation
|
||||
test_acme_cron_detection
|
||||
test_cli_alias_safety
|
||||
test_compose_runtime_image_pin
|
||||
test_update_snapshot_restore
|
||||
test_snapshot_restores_absent_optional_files
|
||||
test_pre_start_restore_preserves_current_database
|
||||
test_failed_asset_validation_preserves_production
|
||||
test_backup_reinstall_restores_on_failure
|
||||
test_backup_reinstall_recovers_from_nginx_reload_failure
|
||||
|
||||
printf 'install.sh tests passed\n'
|
||||
Reference in New Issue
Block a user