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
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -204,31 +212,35 @@ jobs:
|
|||||||
image_base="${image_base,,}"
|
image_base="${image_base,,}"
|
||||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||||
generate_args=(-f "tag_name=${tag}")
|
version_notes=".github/release-notes/${tag}.md"
|
||||||
if [[ -n "${previous_tag}" ]]; then
|
|
||||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
if [[ -f "${version_notes}" ]]; then
|
||||||
{
|
cp "${version_notes}" generated-release-notes.md
|
||||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
else
|
||||||
echo
|
generate_args=(-f "tag_name=${tag}")
|
||||||
if [[ -n "${previous_tag}" ]]; then
|
if [[ -n "${previous_tag}" ]]; then
|
||||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||||
|
{
|
||||||
|
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
||||||
echo
|
echo
|
||||||
echo
|
if [[ -n "${previous_tag}" ]]; then
|
||||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
||||||
else
|
echo
|
||||||
echo "- 首个公开版本。"
|
echo
|
||||||
echo
|
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
||||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
else
|
||||||
fi
|
echo "- 首个公开版本。"
|
||||||
} > generated-release-notes.md
|
echo
|
||||||
|
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
||||||
|
fi
|
||||||
|
} > generated-release-notes.md
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > release-notes.md <<EOF
|
cat > release-notes.md <<EOF
|
||||||
# NewSzxcn Email ${tag}
|
|
||||||
|
|
||||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
自建邮箱 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.
|
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
|
## 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.
|
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
|
```bash
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
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
|
## Required ports
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
|||||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||||
[](LICENSE)
|
[](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
|
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
|
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||||
- 询问邮件域名、访问地址、管理员用户名和密码
|
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
|
||||||
|
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||||
|
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||||
|
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||||
- 拉取 GHCR 镜像并启动邮件服务
|
- 拉取 GHCR 镜像并启动邮件服务
|
||||||
- 生成后台在线更新所需的内部鉴权令牌
|
- 生成后台在线更新所需的内部鉴权令牌
|
||||||
@@ -48,6 +62,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
|||||||
|
|
||||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||||
|
|
||||||
|
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||||
|
|
||||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||||
|
|
||||||
### 命令行更新
|
### 命令行更新
|
||||||
@@ -56,21 +72,31 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
|||||||
sudo newszxcn-email update
|
sudo newszxcn-email update
|
||||||
```
|
```
|
||||||
|
|
||||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo newszxcn-email rollback
|
sudo newszxcn-email rollback
|
||||||
```
|
```
|
||||||
|
|
||||||
|
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
|
||||||
|
|
||||||
常用运维命令:
|
常用运维命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
sudo ns
|
||||||
|
sudo newszxcn-email guide
|
||||||
|
sudo newszxcn-email credentials
|
||||||
|
sudo newszxcn-email reset-password
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
sudo newszxcn-email uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||||
|
|
||||||
|
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||||
|
|
||||||
## DNS 与端口
|
## DNS 与端口
|
||||||
|
|
||||||
@@ -104,10 +130,11 @@ sudo newszxcn-email uninstall
|
|||||||
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
||||||
|-- data/ # SQLite、附件和更新前备份
|
|-- data/ # SQLite、附件和更新前备份
|
||||||
|-- mail/ # Maildir 邮件原文
|
|-- 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
|
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
|
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||||
|
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||||
|
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||||
|
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||||
- 拉取 GHCR 镜像并启动邮件服务
|
- 拉取 GHCR 镜像并启动邮件服务
|
||||||
- 生成后台在线更新所需的内部鉴权令牌
|
- 生成后台在线更新所需的内部鉴权令牌
|
||||||
@@ -62,10 +71,12 @@ sudo newszxcn-email rollback
|
|||||||
```bash
|
```bash
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
sudo newszxcn-email uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||||
|
|
||||||
## DNS 与端口
|
## DNS 与端口
|
||||||
|
|
||||||
@@ -99,10 +110,11 @@ sudo newszxcn-email uninstall
|
|||||||
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
||||||
├── data/ # SQLite、附件和更新前备份
|
├── data/ # SQLite、附件和更新前备份
|
||||||
├── mail/ # Maildir 邮件原文
|
├── 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" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
mailboxLimitOverride = nil
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
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)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
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)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role := req.Role
|
role := req.Role
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("邮箱地址无效"))
|
badRequest(w, errors.New("邮箱地址无效"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("密码至少需要 8 个字符"))
|
badRequest(w, errors.New("密码至少需要 6 个字符"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
displayName := strings.TrimSpace(req.DisplayName)
|
displayName := strings.TrimSpace(req.DisplayName)
|
||||||
@@ -245,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.NewPassword) < 8 {
|
if !hasMinimumPasswordLength(req.NewPassword) {
|
||||||
badRequest(w, errors.New("新密码至少需要 8 个字符"))
|
badRequest(w, errors.New("新密码至少需要 6 个字符"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
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)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
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)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var userID string
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||||
return
|
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)
|
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"targetVersion": info.LatestVersion,
|
"targetVersion": info.LatestVersion,
|
||||||
"message": "更新已启动,服务会在完成后自动恢复",
|
"message": "更新已启动,服务会在完成后自动恢复",
|
||||||
})
|
})
|
||||||
|
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
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))
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 10 * time.Minute,
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
@@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
|||||||
return nil
|
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) {
|
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||||
backupDir := filepath.Join(a.config().DataDir, "backups")
|
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||||
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
defer releaseServer.Close()
|
defer releaseServer.Close()
|
||||||
|
|
||||||
var updateRequests atomic.Int32
|
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) {
|
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
t.Errorf("update method = %s", r.Method)
|
t.Errorf("update method = %s", r.Method)
|
||||||
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Errorf("authorization = %q", got)
|
t.Errorf("authorization = %q", got)
|
||||||
}
|
}
|
||||||
updateRequests.Add(1)
|
updateRequests.Add(1)
|
||||||
|
updateStarted <- struct{}{}
|
||||||
|
<-releaseUpdate
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
defer updateServer.Close()
|
defer updateServer.Close()
|
||||||
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Fatalf("unexpected version response: %+v", version)
|
t.Fatalf("unexpected version response: %+v", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
var update map[string]any
|
type updateResponse struct {
|
||||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
code int
|
||||||
t.Fatalf("update code=%d response=%v", code, update)
|
err error
|
||||||
}
|
}
|
||||||
if updateRequests.Load() != 1 {
|
response := make(chan updateResponse, 1)
|
||||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
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"))
|
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||||
if err != nil || len(backups) != 1 {
|
if err != nil || len(backups) != 1 {
|
||||||
|
|||||||
@@ -14,12 +14,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||||
|
|
||||||
|
const minimumPasswordLength = 6
|
||||||
|
|
||||||
|
func hasMinimumPasswordLength(password string) bool {
|
||||||
|
return utf8.RuneCountInString(password) >= minimumPasswordLength
|
||||||
|
}
|
||||||
|
|
||||||
func NewHTMLPolicy() *HTMLPolicy {
|
func NewHTMLPolicy() *HTMLPolicy {
|
||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
p.AllowElements("html", "head", "body", "center", "font")
|
p.AllowElements("html", "head", "body", "center", "font")
|
||||||
|
|||||||
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
|||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
setUpdatePhase("starting")
|
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")
|
setUpdatePhase("restarting")
|
||||||
await waitForUpdatedService(result.targetVersion)
|
await waitForUpdatedService(result.targetVersion)
|
||||||
return result
|
return result
|
||||||
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
|
|||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
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: "No labels" },
|
||||||
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
||||||
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
"选择邮箱": { "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: "No mailboxes available" },
|
||||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||||
@@ -179,6 +181,12 @@ const exactTranslations: Record<string, Translation> = {
|
|||||||
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
|
"还没有可用邮箱": { "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: "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: "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: "No mailbox access" },
|
||||||
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
|
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
|
||||||
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
|
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
|||||||
import type { PermissionKey } from "@/lib/api-types"
|
import type { PermissionKey } from "@/lib/api-types"
|
||||||
|
|
||||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
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 }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||||
|
|
||||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
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 === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
{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>
|
</main>
|
||||||
</ScrollArea>
|
</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 me = useMe()
|
||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -1030,7 +1031,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
|||||||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
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 maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
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)) }}>
|
<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="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
|
||||||
<Field name="displayName" label="显示名称" placeholder="账号名称" />
|
<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">
|
<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={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
||||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
<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 }) {
|
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 }) })
|
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 }) {
|
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("")
|
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])
|
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 }) })
|
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[] }) {
|
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 Placeholder from "@tiptap/extension-placeholder"
|
||||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||||
import { useNavigate } from "react-router-dom"
|
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 { 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 { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
@@ -1186,9 +1186,9 @@ export function MailPage() {
|
|||||||
<MailboxSwitcher
|
<MailboxSwitcher
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
mailboxes={mailboxList.data?.items || []}
|
mailboxes={mailboxList.data?.items || []}
|
||||||
|
loading={mailboxList.isLoading}
|
||||||
selectedMailboxId={selectedMailboxId}
|
selectedMailboxId={selectedMailboxId}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
fallbackAddress={selectedMailbox?.address || me.data?.user.email || ""}
|
|
||||||
unreadCount={mailboxUnreadCount}
|
unreadCount={mailboxUnreadCount}
|
||||||
onSelect={switchMailbox}
|
onSelect={switchMailbox}
|
||||||
/>
|
/>
|
||||||
@@ -1468,7 +1468,7 @@ export function MailPage() {
|
|||||||
) : !canReadMail ? (
|
) : !canReadMail ? (
|
||||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||||
<NoMailboxState onOpenSettings={openSettings} />
|
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
||||||
) : mailView === "scheduled" && canScheduleMail ? (
|
) : mailView === "scheduled" && canScheduleMail ? (
|
||||||
<ScheduledSendView
|
<ScheduledSendView
|
||||||
compact={compactMailLayout}
|
compact={compactMailLayout}
|
||||||
@@ -2061,7 +2061,7 @@ function externalAccountSubtitle(account: ExternalImapAccount) {
|
|||||||
return [name, account.host, mode].filter(Boolean).join(" · ")
|
return [name, account.host, mode].filter(Boolean).join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
function NoMailboxState({ onManageMailboxes }: { onManageMailboxes: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-0 flex-1 place-items-center p-6">
|
<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">
|
<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" />
|
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold">还没有可用邮箱</div>
|
<div className="text-lg font-semibold">还没有可用邮箱</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。</div>
|
<div className="mt-2 text-sm text-muted-foreground">请前往邮箱管理,创建、申请或联系管理员分配邮箱。</div>
|
||||||
<Button className="mt-5" onClick={onOpenSettings}>
|
<Button className="mt-5" onClick={onManageMailboxes}>
|
||||||
<Settings className="h-4 w-4" />前往个人中心
|
<MailboxIcon className="h-4 w-4" />前往邮箱管理
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 [mailboxQuery, setMailboxQuery] = React.useState("")
|
||||||
const isAllSelected = selectedMailboxId === "all"
|
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 selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
|
||||||
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
||||||
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
|
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
|
||||||
@@ -3232,13 +3233,13 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
|
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{displayAddress}</span>
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{displayAddress}</span>
|
||||||
<UnreadBadge count={selectedUnreadCount} />
|
<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>
|
</Button>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function ProfilePage() {
|
|||||||
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
|
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
|
||||||
const canViewStats = hasPermission(user, "mail.stats.view")
|
const canViewStats = hasPermission(user, "mail.stats.view")
|
||||||
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
|
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
|
||||||
|
const canConfigureMailboxApply = hasPermission(user, "admin.settings.update")
|
||||||
const visibleTabKeys = tabKeys.filter((key) => {
|
const visibleTabKeys = tabKeys.filter((key) => {
|
||||||
if (key === "profile") return true
|
if (key === "profile") return true
|
||||||
if (key === "mailboxes") return canAccessMail || canApplyMailbox
|
if (key === "mailboxes") return canAccessMail || canApplyMailbox
|
||||||
@@ -453,6 +454,7 @@ export function ProfilePage() {
|
|||||||
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
|
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
|
||||||
applyOptions={mailboxApplyOptions.data}
|
applyOptions={mailboxApplyOptions.data}
|
||||||
applyPending={applyMailbox.isPending}
|
applyPending={applyMailbox.isPending}
|
||||||
|
canConfigureApply={canConfigureMailboxApply}
|
||||||
selectedMailboxId={mailboxId}
|
selectedMailboxId={mailboxId}
|
||||||
externalImapEnabled={externalImapEnabled}
|
externalImapEnabled={externalImapEnabled}
|
||||||
externalAccounts={externalImapAccounts.data?.items || []}
|
externalAccounts={externalImapAccounts.data?.items || []}
|
||||||
@@ -464,6 +466,7 @@ export function ProfilePage() {
|
|||||||
onSelect={setMailboxId}
|
onSelect={setMailboxId}
|
||||||
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
|
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
|
||||||
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
|
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
|
||||||
|
onConfigureApply={() => navigate("/admin?section=settings&settingsTab=mail")}
|
||||||
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
|
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
|
||||||
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
|
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
|
||||||
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
|
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
|
||||||
@@ -905,8 +908,8 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
|||||||
<SettingsCard title="密码管理">
|
<SettingsCard title="密码管理">
|
||||||
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
|
<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="currentPassword" required /></Field>
|
||||||
<Field label="新密码"><PasswordInput name="newPassword" minLength={8} required placeholder="输入新密码" /></Field>
|
<Field label="新密码"><PasswordInput name="newPassword" minLength={6} required placeholder="输入新密码" /></Field>
|
||||||
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={8} required placeholder="再次输入密码" /></Field>
|
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={6} required placeholder="再次输入密码" /></Field>
|
||||||
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
|
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
|
||||||
</form>
|
</form>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
@@ -1000,6 +1003,7 @@ function MailboxManagement({
|
|||||||
mailboxes,
|
mailboxes,
|
||||||
applyOptions,
|
applyOptions,
|
||||||
applyPending,
|
applyPending,
|
||||||
|
canConfigureApply,
|
||||||
selectedMailboxId,
|
selectedMailboxId,
|
||||||
externalImapEnabled,
|
externalImapEnabled,
|
||||||
externalAccounts,
|
externalAccounts,
|
||||||
@@ -1011,6 +1015,7 @@ function MailboxManagement({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onOpen,
|
onOpen,
|
||||||
onApply,
|
onApply,
|
||||||
|
onConfigureApply,
|
||||||
onCreateExternal,
|
onCreateExternal,
|
||||||
onStartExternalOAuth,
|
onStartExternalOAuth,
|
||||||
onUpdateExternal,
|
onUpdateExternal,
|
||||||
@@ -1022,6 +1027,7 @@ function MailboxManagement({
|
|||||||
mailboxes: Mailbox[]
|
mailboxes: Mailbox[]
|
||||||
applyOptions?: MailboxApplyOptions
|
applyOptions?: MailboxApplyOptions
|
||||||
applyPending: boolean
|
applyPending: boolean
|
||||||
|
canConfigureApply: boolean
|
||||||
selectedMailboxId: string
|
selectedMailboxId: string
|
||||||
externalImapEnabled: boolean
|
externalImapEnabled: boolean
|
||||||
externalAccounts: ExternalImapAccount[]
|
externalAccounts: ExternalImapAccount[]
|
||||||
@@ -1033,6 +1039,7 @@ function MailboxManagement({
|
|||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onOpen: (id: string) => void
|
onOpen: (id: string) => void
|
||||||
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
|
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
|
||||||
|
onConfigureApply: () => void
|
||||||
onCreateExternal: (payload: ExternalImapAccountPayload) => void
|
onCreateExternal: (payload: ExternalImapAccountPayload) => void
|
||||||
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
|
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
|
||||||
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
|
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
|
||||||
@@ -1208,9 +1215,16 @@ function MailboxManagement({
|
|||||||
</select>
|
</select>
|
||||||
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
|
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||||
{canApply ? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。" : "提示:当前账号暂不可创建新邮箱。"}
|
<span>{canApply
|
||||||
</p>
|
? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。"
|
||||||
|
: 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>
|
||||||
|
|
||||||
<section className="rounded-lg border bg-card">
|
<section className="rounded-lg border bg-card">
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ export function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword" className="text-sm font-medium">确认密码</Label>
|
<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>
|
</div>
|
||||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
<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
|
# 手动部署可执行:openssl rand -hex 24
|
||||||
LANQIN_UPDATE_TOKEN=
|
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_HTTP_BIND=80
|
||||||
LANQIN_HTTPS_BIND=443
|
|
||||||
LANQIN_SMTP_BIND=25
|
LANQIN_SMTP_BIND=25
|
||||||
LANQIN_SMTPS_BIND=465
|
LANQIN_SMTPS_BIND=465
|
||||||
LANQIN_SUBMISSION_BIND=587
|
LANQIN_SUBMISSION_BIND=587
|
||||||
|
|||||||
+11
-3
@@ -15,10 +15,19 @@ sudo newszxcn-email update
|
|||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
sudo newszxcn-email rollback
|
sudo newszxcn-email rollback
|
||||||
|
sudo newszxcn-email guide
|
||||||
|
sudo newszxcn-email credentials
|
||||||
|
sudo newszxcn-email reset-password
|
||||||
```
|
```
|
||||||
|
|
||||||
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
一键安装会把配置和数据放在 `/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` 即可。
|
服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。
|
||||||
@@ -158,10 +167,9 @@ Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
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 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||||
|
|
||||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||||
@@ -182,7 +190,7 @@ services:
|
|||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
- ./dkim:/var/lib/rspamd/dkim
|
- ./dkim:/var/lib/rspamd/dkim
|
||||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
- ./certs:/certs:ro
|
||||||
```
|
```
|
||||||
|
|
||||||
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ services:
|
|||||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||||
- "${LANQIN_HTTPS_BIND:-443}:443"
|
|
||||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||||
- "${LANQIN_SMTPS_BIND:-465}:465"
|
- "${LANQIN_SMTPS_BIND:-465}:465"
|
||||||
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
||||||
@@ -17,8 +16,7 @@ services:
|
|||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
- ./dkim:/var/lib/rspamd/dkim
|
- ./dkim:/var/lib/rspamd/dkim
|
||||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
|
- ./certs:/certs:ro
|
||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
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)
|
||||||
+1166
-75
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