Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a11e1cd2f1 | |||
| 94efc2c62b | |||
| cefbee4f7f | |||
| 06388465a2 | |||
| e6504ef492 | |||
| 39ff9ce01d | |||
| df50f8b3ef | |||
| 397ce51800 | |||
| e789cf9b14 | |||
| 7e480795fc | |||
| a2eb1fbc52 | |||
| cab71c0539 | |||
| 6301182cfc | |||
| 6d0147b639 | |||
| 497aefb903 | |||
| 1af4b7250e | |||
| 1ce75ef241 | |||
| 99b8b7dee8 | |||
| 697ed236cc | |||
| b55c298ef4 | |||
| 800d482c77 | |||
| ccd5c4efd5 | |||
| da888234b9 |
@@ -0,0 +1,13 @@
|
||||
## 本次更新
|
||||
|
||||
### 优化邮箱选择器动态宽度
|
||||
|
||||
- 选择“全部邮箱”、正在加载或尚未注册邮箱时,邮箱选择框占满侧栏整行,不再为隐藏按钮预留空白。
|
||||
- 选择具体邮箱后,选择框自动向左收窄,并在右侧显示复制邮箱地址按钮。
|
||||
- 邮箱下拉菜单始终与当前选择框等宽,避免展开前后宽度不一致。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.9...v1.2.10](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.9...v1.2.10)
|
||||
@@ -0,0 +1,13 @@
|
||||
## 本次更新
|
||||
|
||||
### 移除“加载邮箱”过渡文案
|
||||
|
||||
- 刷新邮箱页面时,邮箱选择器从第一帧开始显示“全部邮箱”。
|
||||
- 邮箱列表在后台加载,不再显示不存在的“加载邮箱...”状态。
|
||||
- 接口确认当前账号没有邮箱后,选择器才显示“未注册邮箱”。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.10...v1.2.11](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.10...v1.2.11)
|
||||
@@ -0,0 +1,33 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复文件夹管理
|
||||
|
||||
- “全部邮箱”模式现在可以新建文件夹,并在账号下每个邮箱中创建同名文件夹。
|
||||
- “全部邮箱”模式可以删除自定义文件夹,原有邮件会分别移回各自邮箱的收件箱,不会删除邮件。
|
||||
- 文件夹排序仍限定在单个邮箱中,避免不同邮箱之间出现错误顺序。
|
||||
|
||||
### 修复导入邮件的存储显示
|
||||
|
||||
- 账号设置中的存储容量改为统计“全部邮箱”,不再因为默认选中空邮箱而显示 `0 B`。
|
||||
- 数据统计默认显示“全部邮箱”,并新增邮箱选择器,可查看单个邮箱的数据。
|
||||
- 已导入邮件及附件继续保留原始数据,现有邮件无需重新导入。
|
||||
|
||||
### 改进邮件下载
|
||||
|
||||
- 下载邮件改为浏览器直接流式下载,不再等整个压缩包载入页面内存后才开始保存。
|
||||
- 点击后立即显示下载提示,大邮箱可直接查看浏览器下载进度。
|
||||
- 下载格式为 ZIP,压缩包内每封邮件均为标准 EML 文件,保留邮件原始内容和历史时间。
|
||||
|
||||
### 优化设置与后台切换
|
||||
|
||||
- 邮箱页面空闲时预加载设置和后台页面,减少首次点击齿轮时的等待。
|
||||
- 设置页只加载当前栏目需要的数据,降低无关接口并发请求。
|
||||
- 管理员可从邮箱页面和设置侧栏直接进入后台管理,普通用户不会显示该入口。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
- 已通过安装脚本语法和自动化回归测试。
|
||||
- 已在桌面端和移动端实测统计页、文件夹创建/删除、ZIP 下载和管理员后台跳转。
|
||||
|
||||
**完整更新日志**:[v1.2.11...v1.2.12](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.11...v1.2.12)
|
||||
@@ -0,0 +1,30 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复选中邮件下载
|
||||
|
||||
- 勾选邮件后点击下载,只导出当前选中的邮件;未勾选时仍导出当前邮箱视图。
|
||||
- 下载接口继续校验邮箱归属和当前视图,不能通过邮件 ID 下载其他账号的邮件。
|
||||
|
||||
### 改进 EML 文件信息
|
||||
|
||||
- 压缩包内文件名改为“邮件标题 (接收日期).eml”,保留中文标题,不再出现乱码式名称。
|
||||
- EML 文件修改时间使用邮件接收时间,与邮件详情中的“接收时间”保持一致。
|
||||
- 邮件原始正文、附件和邮件头保持不变;同名文件会自动添加序号。
|
||||
|
||||
### 调整邮箱与设置交互
|
||||
|
||||
- 移除邮箱页面头部的后台管理图标,后台管理入口仅保留在设置页面。
|
||||
- 设置页面提前加载并在加载完成后切换,避免首次点击齿轮时出现整页加载闪烁。
|
||||
- 邮箱页面不再预加载后台管理代码,减少无用网络请求和解析开销。
|
||||
|
||||
### 修复最后一个邮箱删除
|
||||
|
||||
- 管理员现在可以删除账号的最后一个邮箱,账号本身和登录状态不会被删除。
|
||||
- 删除邮箱流程的错误提示改为简体中文。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
- 已实测管理员删除最后一个邮箱、设置页面切换和后台入口显示。
|
||||
|
||||
**完整更新日志**:[v1.2.12...v1.2.13](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.12...v1.2.13)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,18 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复邮箱选择器默认状态
|
||||
|
||||
- 没有注册邮箱时,邮箱选择器明确显示“未注册邮箱”。
|
||||
- 已有邮箱时,每次打开或刷新邮箱页面默认进入“全部邮箱”的“收件箱”,不再恢复上次选择的单个邮箱。
|
||||
- 用户仍可在当前页面正常切换全部邮箱或单个邮箱,切换后统一返回收件箱。
|
||||
|
||||
### 修复邮箱下拉菜单宽度
|
||||
|
||||
- 邮箱下拉菜单现在与上方选择框等宽,不再向右多出一截。
|
||||
- 搜索框、全部邮箱和邮箱地址均在相同宽度内对齐显示。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.7...v1.2.8](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.7...v1.2.8)
|
||||
@@ -0,0 +1,25 @@
|
||||
## 本次更新
|
||||
|
||||
### 修复邮件导入 413
|
||||
|
||||
- 修复 all-in-one 和多容器部署中,内部 Nginx 使用默认 `1 MB` 上传限制,导致单封稍大的 EML 邮件也导入失败的问题。
|
||||
- API 上传入口现在允许最多 `50 MB` 的单批请求;单封邮件仍遵循系统设置中的邮件大小限制。
|
||||
- 遇到 `413 Request Entity Too Large` 时改为显示明确的中文提示。
|
||||
|
||||
### 支持大批量导入
|
||||
|
||||
- 一次选择多封 EML/MBOX 后,网页会按最多 20 个文件、约 `32 MB` 自动分批上传,无需用户手动拆分文件。
|
||||
- 每批成功后立即保存邮件;后续批次失败不会删除已经成功导入的邮件。
|
||||
- 导入结束后统一显示成功和跳过数量,并刷新当前邮件列表。
|
||||
|
||||
### 保留历史邮件时间
|
||||
|
||||
- 导入时继续保留邮件头中的原始 `Date` 时间。
|
||||
- 收件箱按邮件历史时间倒序显示,最新邮件排列在最前。
|
||||
- 新增回归测试,验证批量导入后不受文件选择或处理顺序影响。
|
||||
|
||||
### 验证
|
||||
|
||||
- 已通过 Go API 全量测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||
|
||||
**完整更新日志**:[v1.2.8...v1.2.9](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.8...v1.2.9)
|
||||
@@ -40,6 +40,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh
|
||||
bash tests/install_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
||||
@@ -27,6 +27,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck sqlite3
|
||||
bash -n install.sh tests/install_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh
|
||||
bash tests/install_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -197,67 +205,39 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
version="${{ needs.release.outputs.version }}"
|
||||
repo="${GITHUB_REPOSITORY}"
|
||||
repo_url="https://github.com/${repo}"
|
||||
image_base="${REGISTRY}/${repo}"
|
||||
image_base="${image_base,,}"
|
||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
|
||||
if [[ -f "${version_notes}" ]]; then
|
||||
cp "${version_notes}" generated-release-notes.md
|
||||
else
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
fi
|
||||
|
||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||
{
|
||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
||||
echo
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
||||
echo
|
||||
echo
|
||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
||||
else
|
||||
echo "- 首个公开版本。"
|
||||
echo
|
||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
||||
fi
|
||||
} > generated-release-notes.md
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||
{
|
||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
||||
echo
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
||||
echo
|
||||
echo
|
||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
||||
else
|
||||
echo "- 首个公开版本。"
|
||||
echo
|
||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
||||
fi
|
||||
} > generated-release-notes.md
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# NewSzxcn Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
|
||||
|
||||
## 使用文档
|
||||
|
||||
- [项目文档](${repo_url}#readme)
|
||||
- [开源协议](${repo_url}/blob/main/LICENSE)
|
||||
|
||||
## Docker 镜像
|
||||
|
||||
| 组件 | 镜像 |
|
||||
|------|------|
|
||||
| All-in-one | \`${image_base}:${tag}\` |
|
||||
| API | \`${image_base}-api:${tag}\` |
|
||||
| Web | \`${image_base}-web:${tag}\` |
|
||||
| Postfix | \`${image_base}-postfix:${tag}\` |
|
||||
| Dovecot | \`${image_base}-dovecot:${tag}\` |
|
||||
| Rspamd | \`${image_base}-rspamd:${tag}\` |
|
||||
|
||||
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
|
||||
EOF
|
||||
|
||||
{
|
||||
echo
|
||||
cat generated-release-notes.md
|
||||
} >> release-notes.md
|
||||
cp generated-release-notes.md release-notes.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
env:
|
||||
|
||||
+5
-1
@@ -23,6 +23,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
|
||||
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
|
||||
|
||||
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
|
||||
|
||||
## Update
|
||||
|
||||
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
|
||||
@@ -39,10 +41,12 @@ Useful commands:
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`.
|
||||
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`.
|
||||
|
||||
## Required ports
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -29,10 +29,24 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
|
||||
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
|
||||
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
|
||||
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复。
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 询问邮件域名、访问地址、管理员用户名和密码
|
||||
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
@@ -48,6 +62,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
@@ -56,21 +72,31 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo ns
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
@@ -104,10 +130,11 @@ sudo newszxcn-email uninstall
|
||||
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
|-- data/ # SQLite、附件和更新前备份
|
||||
|-- mail/ # Maildir 邮件原文
|
||||
`-- dkim/ # DKIM 私钥
|
||||
|-- dkim/ # DKIM 私钥
|
||||
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
|
||||
+16
-4
@@ -24,10 +24,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
||||
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
@@ -62,10 +71,12 @@ sudo newszxcn-email rollback
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
@@ -99,10 +110,11 @@ sudo newszxcn-email uninstall
|
||||
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
├── data/ # SQLite、附件和更新前备份
|
||||
├── mail/ # Maildir 邮件原文
|
||||
└── dkim/ # DKIM 私钥
|
||||
├── dkim/ # DKIM 私钥
|
||||
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
|
||||
@@ -143,8 +143,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if role == "admin" {
|
||||
mailboxLimitOverride = nil
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -344,8 +344,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -562,8 +562,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
role := req.Role
|
||||
@@ -730,26 +730,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var count int
|
||||
if current != nil && owner == current.ID {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
@@ -765,12 +748,12 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
||||
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
|
||||
@@ -1648,6 +1648,45 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
t.Fatalf("folders for selected mailbox code=%d", code)
|
||||
}
|
||||
|
||||
var sharedFolder MailFolder
|
||||
if code := userClient.do("POST", "/api/mail/folders?mailboxId=all", map[string]string{"name": "Shared Project"}, &sharedFolder); code != http.StatusCreated {
|
||||
t.Fatalf("create shared folder code=%d folder=%+v", code, sharedFolder)
|
||||
}
|
||||
var primarySharedID, secondarySharedID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, primary.ID, "Shared Project").Scan(&primarySharedID); err != nil {
|
||||
t.Fatalf("primary shared folder: %v", err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, secondary.ID, "Shared Project").Scan(&secondarySharedID); err != nil {
|
||||
t.Fatalf("secondary shared folder: %v", err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=? WHERE id=?`, primarySharedID, "msg_multi_primary_read"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=? WHERE id=?`, secondarySharedID, "msg_multi_secondary_unread"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deleted struct {
|
||||
Moved int `json:"moved"`
|
||||
}
|
||||
deletePath := "/api/mail/folders/" + url.PathEscape(sharedFolder.ID) + "?mailboxId=all&folderName=" + url.QueryEscape(sharedFolder.Name)
|
||||
if code := userClient.do("DELETE", deletePath, nil, &deleted); code != http.StatusOK || deleted.Moved != 2 {
|
||||
t.Fatalf("delete shared folders code=%d moved=%d", code, deleted.Moved)
|
||||
}
|
||||
var sharedCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM folders WHERE mailbox_id IN (?,?) AND name=?`, primary.ID, secondary.ID, "Shared Project").Scan(&sharedCount); err != nil || sharedCount != 0 {
|
||||
t.Fatalf("shared folders remaining=%d err=%v", sharedCount, err)
|
||||
}
|
||||
var restoredPrimaryFolder, restoredSecondaryFolder string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_multi_primary_read").Scan(&restoredPrimaryFolder); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_multi_secondary_unread").Scan(&restoredSecondaryFolder); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restoredPrimaryFolder != primaryInboxID || restoredSecondaryFolder != secondaryInboxID {
|
||||
t.Fatalf("restored folders primary=%s secondary=%s", restoredPrimaryFolder, restoredSecondaryFolder)
|
||||
}
|
||||
|
||||
var sent MailMessage
|
||||
payload := map[string]any{
|
||||
"mailboxId": secondary.ID,
|
||||
|
||||
@@ -112,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("邮箱地址无效"))
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
@@ -245,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
badRequest(w, errors.New("新密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.NewPassword) {
|
||||
badRequest(w, errors.New("新密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
||||
|
||||
@@ -264,11 +264,6 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -285,6 +280,47 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("system folder already exists"))
|
||||
return
|
||||
}
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
|
||||
return
|
||||
}
|
||||
mailboxIDs := []string{}
|
||||
for rows.Next() {
|
||||
var mailboxID string
|
||||
if err := rows.Scan(&mailboxID); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
||||
return
|
||||
}
|
||||
mailboxIDs = append(mailboxIDs, mailboxID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
||||
return
|
||||
}
|
||||
rows.Close()
|
||||
if len(mailboxIDs) == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
for _, mailboxID := range mailboxIDs {
|
||||
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase})
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||
@@ -299,16 +335,20 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if folderID == "" {
|
||||
badRequest(w, errors.New("folder id is required"))
|
||||
return
|
||||
}
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
a.handleDeleteAllMailFolders(w, r, folderID)
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var folderName string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -378,6 +418,115 @@ func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, folderID string) {
|
||||
folderName := strings.TrimSpace(r.URL.Query().Get("folderName"))
|
||||
if folderName == "" && strings.HasPrefix(strings.ToLower(folderID), "all-") {
|
||||
folderName = strings.TrimSpace(folderID[4:])
|
||||
}
|
||||
name, err := normalizeCustomFolderName(folderName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if isSystemFolderName(name) {
|
||||
badRequest(w, errors.New("system folders cannot be deleted"))
|
||||
return
|
||||
}
|
||||
user := currentUser(r)
|
||||
type folderTarget struct {
|
||||
folderID string
|
||||
mailboxID string
|
||||
inboxID string
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.mailbox_id FROM folders f JOIN mailboxes mb ON mb.id=f.mailbox_id WHERE mb.user_id=? AND mb.status='active' AND lower(f.name)=lower(?)`, user.ID, name)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
||||
return
|
||||
}
|
||||
targets := []folderTarget{}
|
||||
for rows.Next() {
|
||||
var target folderTarget
|
||||
if err := rows.Scan(&target.folderID, &target.mailboxID); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
rows.Close()
|
||||
if len(targets) == 0 {
|
||||
respondError(w, http.StatusNotFound, "folder not found")
|
||||
return
|
||||
}
|
||||
for i := range targets {
|
||||
targets[i].inboxID, err = a.ensureFolder(r.Context(), targets[i].mailboxID, "Inbox")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load inbox")
|
||||
return
|
||||
}
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
moved := 0
|
||||
for _, target := range targets {
|
||||
messageRows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, target.mailboxID, target.folderID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder messages")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
for messageRows.Next() {
|
||||
var messageID string
|
||||
if err := messageRows.Scan(&messageID); err != nil {
|
||||
messageRows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||
return
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := messageRows.Err(); err != nil {
|
||||
messageRows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||
return
|
||||
}
|
||||
messageRows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
meta, err := a.nextIMAPMetadata(r.Context(), tx, target.inboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, target.inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move folder messages")
|
||||
return
|
||||
}
|
||||
moved++
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, target.folderID, target.mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
for _, target := range targets {
|
||||
_, _ = a.bumpFolderModSeq(r.Context(), target.inboxID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
||||
}
|
||||
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
||||
return a.ensureFolder(ctx, mailboxID, name)
|
||||
}
|
||||
|
||||
@@ -13,13 +13,20 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxMailImportBytes int64 = 256 << 20
|
||||
|
||||
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
const maxSelectedMailExport = 200
|
||||
|
||||
type exportedMessage struct {
|
||||
raw []byte
|
||||
subject string
|
||||
date time.Time
|
||||
}
|
||||
|
||||
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||
ids, err := a.exportMessageIDs(r)
|
||||
@@ -43,19 +50,21 @@ func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
usedNames := make(map[string]int, len(ids))
|
||||
for index, id := range ids {
|
||||
raw, subject, err := a.rawMessageForExport(r.Context(), id)
|
||||
for _, id := range ids {
|
||||
message, err := a.rawMessageForExport(r.Context(), id)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
|
||||
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
|
||||
entryName := uniqueExportFilename(exportMessageFilename(message.subject, message.date), usedNames)
|
||||
header := &zip.FileHeader{Name: entryName, Method: zip.Deflate}
|
||||
header.SetModTime(message.date)
|
||||
entry, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
if _, err := entry.Write(raw); err != nil {
|
||||
if _, err := entry.Write(message.raw); err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
@@ -74,6 +83,10 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
selectedIDs, err := selectedExportMessageIDs(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if view == "unknown" {
|
||||
if user.Role != "admin" {
|
||||
@@ -115,6 +128,14 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
return nil, errors.New("unsupported mail view")
|
||||
}
|
||||
}
|
||||
if len(selectedIDs) > 0 {
|
||||
placeholders := make([]string, 0, len(selectedIDs))
|
||||
for _, id := range selectedIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
where = append(where, "m.id IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
|
||||
if err != nil {
|
||||
@@ -132,40 +153,77 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
|
||||
func selectedExportMessageIDs(r *http.Request) ([]string, error) {
|
||||
values := r.URL.Query()["messageId"]
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
ids := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
id := strings.TrimSpace(value)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) > maxSelectedMailExport {
|
||||
return nil, fmt.Errorf("最多一次下载 %d 封邮件", maxSelectedMailExport)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (a *App) rawMessageForExport(ctx context.Context, id string) (exportedMessage, error) {
|
||||
msg, err := a.storedMessageByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return exportedMessage{}, err
|
||||
}
|
||||
exportDate := msg.ReceivedAt
|
||||
if exportDate.IsZero() {
|
||||
exportDate = messageDate(msg)
|
||||
}
|
||||
if msg.RawPath != "" {
|
||||
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
|
||||
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
|
||||
return raw, msg.Subject, nil
|
||||
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return exportedMessage{}, err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
|
||||
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
|
||||
Date: messageDate(msg), Attachments: attachments,
|
||||
})
|
||||
return raw, msg.Subject, err
|
||||
return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, err
|
||||
}
|
||||
|
||||
func exportMessageFilename(subject, id string, index int) string {
|
||||
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
|
||||
name = strings.Trim(name, ".-_")
|
||||
func exportMessageFilename(subject string, date time.Time) string {
|
||||
name := strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || strings.ContainsRune(`<>:"/\\|?*`, r) {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(subject))
|
||||
name = strings.Trim(name, " .-_")
|
||||
if name == "" {
|
||||
name = "message"
|
||||
name = "无主题"
|
||||
}
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
runes := []rune(name)
|
||||
if len(runes) > 80 {
|
||||
name = string(runes[:80])
|
||||
}
|
||||
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
|
||||
if date.IsZero() {
|
||||
return name + ".eml"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s).eml", name, date.Format("20060102"))
|
||||
}
|
||||
|
||||
func uniqueExportFilename(name string, used map[string]int) string {
|
||||
|
||||
@@ -5,11 +5,15 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
stdmail "net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseMBOXMultipleMessages(t *testing.T) {
|
||||
@@ -65,22 +69,27 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
|
||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: 中文标题\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||
olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: <older-imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import")
|
||||
var imported struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 {
|
||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 {
|
||||
t.Fatalf("import code=%d response=%+v", code, imported)
|
||||
}
|
||||
|
||||
var list struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" {
|
||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "中文标题" || list.Items[1].Subject != "older imported message" {
|
||||
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
receivedAt := time.Date(2024, time.January, 3, 8, 30, 0, 0, time.UTC)
|
||||
if _, err := a.db.Exec(`UPDATE messages SET received_at=? WHERE id=?`, receivedAt.Format(time.RFC3339Nano), list.Items[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
|
||||
if status != http.StatusOK {
|
||||
@@ -90,18 +99,50 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 1 {
|
||||
if len(zr.File) != 2 {
|
||||
t.Fatalf("zip entries=%d", len(zr.File))
|
||||
}
|
||||
if zr.File[0].Name != "中文标题 (20240103).eml" {
|
||||
t.Fatalf("first filename=%q", zr.File[0].Name)
|
||||
}
|
||||
wantModified := receivedAt
|
||||
if !zr.File[0].Modified.Equal(wantModified) {
|
||||
t.Fatalf("first modified=%s want=%s", zr.File[0].Modified, wantModified)
|
||||
}
|
||||
entry, err := zr.File[0].Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exported, err := io.ReadAll(entry)
|
||||
entry.Close()
|
||||
if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) {
|
||||
if err != nil {
|
||||
t.Fatalf("exported message err=%v raw=%q", err, exported)
|
||||
}
|
||||
parsed, err := stdmail.ReadMessage(bytes.NewReader(exported))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject"))
|
||||
if err != nil || decodedSubject != "中文标题" {
|
||||
t.Fatalf("decoded subject=%q err=%v", decodedSubject, err)
|
||||
}
|
||||
messageDate, err := parsed.Header.Date()
|
||||
if err != nil || !messageDate.Equal(time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("message date=%s err=%v", messageDate, err)
|
||||
}
|
||||
|
||||
selectedPath := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(list.Items[1].ID)
|
||||
status, selectedArchive := getMailExport(t, owner, selectedPath)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("selected export status=%d body=%q", status, selectedArchive)
|
||||
}
|
||||
selectedZip, err := zip.NewReader(bytes.NewReader(selectedArchive), int64(len(selectedArchive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(selectedZip.File) != 1 || selectedZip.File[0].Name != "older imported message (20240101).eml" {
|
||||
t.Fatalf("selected entries=%v", exportEntryNames(selectedZip.File))
|
||||
}
|
||||
|
||||
var denied map[string]any
|
||||
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
|
||||
@@ -113,6 +154,64 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedMailExportStillEnforcesOwnership(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var domains struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
|
||||
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
|
||||
}
|
||||
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-owner", "Export Owner", "Password123!", nil)
|
||||
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-other", "Export Other", "Password123!", nil)
|
||||
owner := &testClient{t: t, server: ts}
|
||||
other := &testClient{t: t, server: ts}
|
||||
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
if code := other.do("POST", "/api/auth/login", map[string]string{"email": otherMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("other login=%d", code)
|
||||
}
|
||||
otherEML := []byte("From: sender@example.com\r\nTo: " + otherMailbox.Address + "\r\nSubject: private message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <private@example.com>\r\n\r\nprivate")
|
||||
var imported map[string]any
|
||||
if code := doMailImport(t, other, otherMailbox.ID, "Inbox", map[string][]byte{"private.eml": otherEML}, &imported); code != http.StatusOK {
|
||||
t.Fatalf("other import=%d response=%v", code, imported)
|
||||
}
|
||||
var otherList struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := other.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+otherMailbox.ID, nil, &otherList); code != http.StatusOK || len(otherList.Items) != 1 {
|
||||
t.Fatalf("other list code=%d items=%d", code, len(otherList.Items))
|
||||
}
|
||||
path := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(otherList.Items[0].ID)
|
||||
status, archive := getMailExport(t, owner, path)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("cross-owner export status=%d body=%q", status, archive)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 0 {
|
||||
t.Fatalf("cross-owner export leaked entries=%v", exportEntryNames(zr.File))
|
||||
}
|
||||
}
|
||||
|
||||
func exportEntryNames(files []*zip.File) []string {
|
||||
names := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var mailboxes struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
|
||||
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
|
||||
t.Fatalf("delete final mailbox code=%d", code)
|
||||
}
|
||||
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
|
||||
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
|
||||
}
|
||||
var me map[string]any
|
||||
if code := admin.do("GET", "/api/me", nil, &me); code != http.StatusOK {
|
||||
t.Fatalf("account was not preserved code=%d", code)
|
||||
}
|
||||
}
|
||||
@@ -196,8 +196,8 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
@@ -318,26 +318,9 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
current := currentUser(r)
|
||||
if current != nil && owner == current.ID {
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
@@ -353,11 +336,11 @@ func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
||||
respondError(w, http.StatusInternalServerError, "删除邮箱失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
@@ -371,8 +354,8 @@ func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.R
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasMinimumPasswordLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
want bool
|
||||
}{
|
||||
{name: "five ASCII characters", password: "abc12", want: false},
|
||||
{name: "six ASCII characters", password: "abc123", want: true},
|
||||
{name: "six Unicode characters", password: "密码测试六位", want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasMinimumPasswordLength(tt.password); got != tt.want {
|
||||
t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||
return
|
||||
}
|
||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
||||
a.log.Error("trigger system update", "error", err)
|
||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
||||
return
|
||||
}
|
||||
|
||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"ok": true,
|
||||
@@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
"targetVersion": info.LatestVersion,
|
||||
"message": "更新已启动,服务会在完成后自动恢复",
|
||||
})
|
||||
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||
}
|
||||
|
||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||
@@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: 10 * time.Minute,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
@@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
|
||||
go func() {
|
||||
// Let the accepted response reach the browser before Watchtower replaces this container.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := a.triggerUpdateService(ctx); err != nil {
|
||||
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
defer releaseServer.Close()
|
||||
|
||||
var updateRequests atomic.Int32
|
||||
updateStarted := make(chan struct{}, 1)
|
||||
releaseUpdate := make(chan struct{})
|
||||
var releaseUpdateOnce sync.Once
|
||||
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
|
||||
defer releaseBlockedUpdate()
|
||||
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("update method = %s", r.Method)
|
||||
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
t.Errorf("authorization = %q", got)
|
||||
}
|
||||
updateRequests.Add(1)
|
||||
updateStarted <- struct{}{}
|
||||
<-releaseUpdate
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer updateServer.Close()
|
||||
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
t.Fatalf("unexpected version response: %+v", version)
|
||||
}
|
||||
|
||||
var update map[string]any
|
||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
||||
t.Fatalf("update code=%d response=%v", code, update)
|
||||
type updateResponse struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
if updateRequests.Load() != 1 {
|
||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
||||
response := make(chan updateResponse, 1)
|
||||
go func() {
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
req.AddCookie(admin.cookie)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
response <- updateResponse{err: err}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
response <- updateResponse{code: resp.StatusCode}
|
||||
}()
|
||||
select {
|
||||
case result := <-response:
|
||||
if result.err != nil || result.code != http.StatusAccepted {
|
||||
t.Fatalf("update response=%+v", result)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
releaseBlockedUpdate()
|
||||
t.Fatal("update response waited for container replacement")
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("scheduled update request did not start")
|
||||
}
|
||||
releaseBlockedUpdate()
|
||||
if got := updateRequests.Load(); got != 1 {
|
||||
t.Fatalf("update requests=%d", got)
|
||||
}
|
||||
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||
if err != nil || len(backups) != 1 {
|
||||
|
||||
@@ -14,12 +14,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||
|
||||
const minimumPasswordLength = 6
|
||||
|
||||
func hasMinimumPasswordLength(password string) bool {
|
||||
return utf8.RuneCountInString(password) >= minimumPasswordLength
|
||||
}
|
||||
|
||||
func NewHTMLPolicy() *HTMLPolicy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowElements("html", "head", "body", "center", "font")
|
||||
|
||||
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
setUpdatePhase("starting")
|
||||
const result = await api.updateSystem()
|
||||
const targetVersion = version.data?.latestVersion
|
||||
let result: Awaited<ReturnType<typeof api.updateSystem>>
|
||||
try {
|
||||
result = await api.updateSystem()
|
||||
} catch (error) {
|
||||
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
|
||||
result = {
|
||||
ok: true,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
message: "更新请求已发送,正在等待服务恢复",
|
||||
}
|
||||
}
|
||||
setUpdatePhase("restarting")
|
||||
await waitForUpdatedService(result.targetVersion)
|
||||
return result
|
||||
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isUpdateConnectionInterruption(error: unknown) {
|
||||
if (!(error instanceof Error)) return false
|
||||
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
|
||||
}
|
||||
|
||||
+11
-13
@@ -69,22 +69,13 @@ async function request<T>(path: string, init: RequestInit & { timeoutMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFile(path: string): Promise<Blob> {
|
||||
const res = await fetch(path, { credentials: "include" })
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
async function uploadForm<T>(path: string, form: FormData): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), 5 * 60_000)
|
||||
try {
|
||||
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) throw new Error("导入文件过大,请减少单次导入数量后重试")
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new Error(message)
|
||||
@@ -229,7 +220,13 @@ export const api = {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) })
|
||||
},
|
||||
deleteFolder: (id: string, mailboxId?: string) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
|
||||
deleteFolder: (id: string, mailboxId?: string, folderName?: string) => {
|
||||
const query = new URLSearchParams()
|
||||
if (mailboxId) query.set("mailboxId", mailboxId)
|
||||
if (folderName) query.set("folderName", folderName)
|
||||
const suffix = query.toString()
|
||||
return request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${suffix ? `?${suffix}` : ""}`, { method: "DELETE" })
|
||||
},
|
||||
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
@@ -254,12 +251,13 @@ export const api = {
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
|
||||
},
|
||||
exportMail: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => {
|
||||
exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string; messageIds?: string[] }) => {
|
||||
const query = new URLSearchParams({ view: params.view })
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.folder) query.set("folder", params.folder)
|
||||
if (params.labelId) query.set("labelId", params.labelId)
|
||||
return requestFile(`/api/mail/export?${query.toString()}`)
|
||||
params.messageIds?.forEach((id) => query.append("messageId", id))
|
||||
return `/api/mail/export?${query.toString()}`
|
||||
},
|
||||
importMail: (files: File[], payload: { mailboxId: string; folder: string }) => {
|
||||
const form = new FormData()
|
||||
|
||||
@@ -69,6 +69,7 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
|
||||
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
||||
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
||||
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
|
||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||
@@ -179,6 +180,12 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
|
||||
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
|
||||
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
|
||||
"请前往邮箱管理,创建、申请或联系管理员分配邮箱。": { "zh-TW": "請前往信箱管理,建立、申請或聯絡管理員分配信箱。", en: "Open mailbox management to create, request, or ask an administrator to assign a mailbox." },
|
||||
"前往邮箱管理": { "zh-TW": "前往信箱管理", en: "Go to mailbox management" },
|
||||
"提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。": { "zh-TW": "提示:尚未選擇開放網域。請在「後台管理 → 系統設定 → 郵件」中至少勾選一個已啟用網域。", en: "No domain is open for mailbox requests. Open Admin → System settings → Mail and select at least one active domain." },
|
||||
"提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。": { "zh-TW": "提示:帳號自助申請信箱尚未開啟。請在「後台管理 → 系統設定 → 郵件」中開啟,並勾選開放網域。", en: "Mailbox self-service is disabled. Enable it under Admin → System settings → Mail, then select the available domains." },
|
||||
"提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。": { "zh-TW": "提示:目前帳號暫時無法建立新信箱,請聯絡管理員開啟帳號自助申請信箱。", en: "This account cannot create a mailbox. Ask an administrator to enable mailbox self-service." },
|
||||
"前往设置": { "zh-TW": "前往設定", en: "Open settings" },
|
||||
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
|
||||
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
|
||||
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
|
||||
|
||||
@@ -27,6 +27,7 @@ import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
@@ -132,7 +133,7 @@ export function AdminPage() {
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} initialTab={params.get("settingsTab")} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -1018,7 +1019,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
function SystemSettingsSection({ settings, domains, initialTab }: { settings?: SystemSettings; domains: Domain[]; initialTab?: string | null }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
@@ -1030,7 +1031,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base")
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -1858,7 +1860,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
||||
<Field name="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
|
||||
<Field name="displayName" label="显示名称" placeholder="账号名称" />
|
||||
<Field name="password" label="初始密码" type="password" minLength={8} />
|
||||
<Field name="password" label="初始密码" type="password" minLength={6} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
||||
@@ -1928,7 +1930,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
||||
|
||||
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
@@ -1948,7 +1950,7 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
|
||||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 6 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||
|
||||
+120
-80
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
||||
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
@@ -88,10 +88,28 @@ const filterLabels: Record<MailFilter, string> = {
|
||||
|
||||
const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false }
|
||||
const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch }
|
||||
const mailboxSelectionStorageVersion = "2"
|
||||
const mailImportBatchBytes = 32 * 1024 * 1024
|
||||
const mailImportBatchFiles = 20
|
||||
const mailCompactBreakpoint = 768
|
||||
const mailDetailBreakpoint = 768
|
||||
|
||||
function buildMailImportBatches(files: File[]) {
|
||||
const batches: File[][] = []
|
||||
let batch: File[] = []
|
||||
let batchBytes = 0
|
||||
for (const file of files) {
|
||||
if (batch.length > 0 && (batch.length >= mailImportBatchFiles || batchBytes + file.size > mailImportBatchBytes)) {
|
||||
batches.push(batch)
|
||||
batch = []
|
||||
batchBytes = 0
|
||||
}
|
||||
batch.push(file)
|
||||
batchBytes += file.size
|
||||
}
|
||||
if (batch.length > 0) batches.push(batch)
|
||||
return batches
|
||||
}
|
||||
|
||||
function useMaxViewportWidth(maxWidth: number) {
|
||||
const [matches, setMatches] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
@@ -122,10 +140,7 @@ export function MailPage() {
|
||||
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
|
||||
const sidebarCollapsed = false
|
||||
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
|
||||
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => {
|
||||
if (localStorage.getItem("lanqin:selected-mailbox-version") !== mailboxSelectionStorageVersion) return "all"
|
||||
return localStorage.getItem("lanqin:selected-mailbox") || "all"
|
||||
})
|
||||
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
|
||||
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
|
||||
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
|
||||
const [externalFolder, setExternalFolder] = React.useState("INBOX")
|
||||
@@ -177,6 +192,11 @@ export function MailPage() {
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user) return
|
||||
void import("@/pages/profile")
|
||||
}, [user])
|
||||
|
||||
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
||||
const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled })
|
||||
const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId])
|
||||
@@ -190,6 +210,8 @@ export function MailPage() {
|
||||
const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || ""
|
||||
const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined)
|
||||
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
||||
const canManageFolders = canOrganizeMail && hasMailboxes
|
||||
const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected
|
||||
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail })
|
||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
|
||||
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
|
||||
@@ -444,7 +466,7 @@ export function MailPage() {
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: ["folders", activeMailboxId] }),
|
||||
})
|
||||
const deleteFolder = useMutation({
|
||||
mutationFn: (item: Extract<MailMenuItem, { type: "folder" }>) => api.deleteFolder(item.folderId, activeMailboxId),
|
||||
mutationFn: (item: Extract<MailMenuItem, { type: "folder" }>) => api.deleteFolder(item.folderId, activeMailboxId, item.folderName),
|
||||
onSuccess: async (result, item) => {
|
||||
setPendingConfirm(null)
|
||||
if (mailView === "folder" && folder === item.folderName) {
|
||||
@@ -491,14 +513,19 @@ export function MailPage() {
|
||||
}
|
||||
if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) {
|
||||
setSelectedMailboxId("all")
|
||||
setSelectedExternalAccountId("")
|
||||
setFolder("Inbox")
|
||||
setMailView("folder")
|
||||
setSelectedLabelId("")
|
||||
setSelectedId(null)
|
||||
setMailFilter("all")
|
||||
}
|
||||
}, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
|
||||
else localStorage.removeItem("lanqin:selected-mailbox")
|
||||
localStorage.setItem("lanqin:selected-mailbox-version", mailboxSelectionStorageVersion)
|
||||
}, [selectedMailboxId])
|
||||
localStorage.removeItem("lanqin:selected-mailbox")
|
||||
localStorage.removeItem("lanqin:selected-mailbox-version")
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedId(null)
|
||||
@@ -946,7 +973,9 @@ export function MailPage() {
|
||||
if (item.type !== "folder" || !item.custom) return
|
||||
setPendingConfirm({
|
||||
title: `删除文件夹“${item.label}”?`,
|
||||
description: "文件夹内的邮件会移回收件箱,不会被删除。",
|
||||
description: isAllMailboxSelected
|
||||
? "所有邮箱中的同名文件夹都会删除,文件夹内邮件会移回各自的收件箱。"
|
||||
: "文件夹内的邮件会移回收件箱,不会被删除。",
|
||||
confirmText: "删除文件夹",
|
||||
onConfirm: () => deleteFolder.mutate(item),
|
||||
})
|
||||
@@ -1075,28 +1104,22 @@ export function MailPage() {
|
||||
async function exportCurrentMail() {
|
||||
if (!canExportCurrentView || exportingMail) return
|
||||
setExportingMail(true)
|
||||
try {
|
||||
const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder"
|
||||
const blob = await api.exportMail({
|
||||
view: exportView,
|
||||
mailboxId: mailView === "unknown" ? undefined : activeMailboxId,
|
||||
folder: exportView === "folder" ? folder : undefined,
|
||||
labelId: exportView === "label" ? selectedLabelId : undefined,
|
||||
})
|
||||
const href = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = href
|
||||
anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(href), 1000)
|
||||
toast({ title: "邮件已导出", description: `${viewTitle} 已打包为 ZIP` })
|
||||
} catch (error) {
|
||||
toast({ title: "导出失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||
} finally {
|
||||
setExportingMail(false)
|
||||
}
|
||||
const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder"
|
||||
const selectedMessageIds = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = api.exportMailUrl({
|
||||
view: exportView,
|
||||
mailboxId: mailView === "unknown" ? undefined : activeMailboxId,
|
||||
folder: exportView === "folder" ? folder : undefined,
|
||||
labelId: exportView === "label" ? selectedLabelId : undefined,
|
||||
messageIds: selectedMessageIds.length > 0 ? selectedMessageIds : undefined,
|
||||
})
|
||||
anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
toast({ title: "已开始下载", description: selectedMessageIds.length > 0 ? `正在打包选中的 ${selectedMessageIds.length} 封邮件。` : "邮件将打包为 ZIP,压缩包内为标准 EML 文件;邮件较多时请查看浏览器下载进度。" })
|
||||
window.setTimeout(() => setExportingMail(false), 1000)
|
||||
}
|
||||
function chooseMailImport() {
|
||||
if (!canImportCurrentView || importingMail) {
|
||||
@@ -1111,8 +1134,22 @@ export function MailPage() {
|
||||
if (files.length === 0 || !selectedMailbox) return
|
||||
setImportingMail(true)
|
||||
try {
|
||||
const result = await api.importMail(files, { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" })
|
||||
const batches = buildMailImportBatches(files)
|
||||
const target = { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" }
|
||||
const result = { imported: 0, skipped: 0, errors: [] as string[] }
|
||||
for (const batch of batches) {
|
||||
try {
|
||||
const current = await api.importMail(batch, target)
|
||||
result.imported += current.imported
|
||||
result.skipped += current.skipped
|
||||
result.errors.push(...current.errors)
|
||||
} catch (error) {
|
||||
result.skipped += batch.length
|
||||
result.errors.push(error instanceof Error ? error.message : "导入请求失败")
|
||||
}
|
||||
}
|
||||
await refreshMailData()
|
||||
if (result.imported === 0 && result.errors.length > 0) throw new Error(result.errors[0])
|
||||
toast({
|
||||
title: `已导入 ${result.imported} 封邮件`,
|
||||
description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`,
|
||||
@@ -1129,7 +1166,7 @@ export function MailPage() {
|
||||
toast({ title: "邮箱地址已复制" })
|
||||
}
|
||||
function openSettings() {
|
||||
navigate("/profile")
|
||||
void import("@/pages/profile").then(() => navigate("/profile"))
|
||||
}
|
||||
function toggleAdvancedSearch() {
|
||||
setAdvancedSearchDraft(advancedSearch)
|
||||
@@ -1182,27 +1219,25 @@ export function MailPage() {
|
||||
onLanguageChange={setLanguage}
|
||||
onSettings={openSettings}
|
||||
/>
|
||||
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : "grid grid-cols-[minmax(0,1fr)_2rem]")}>
|
||||
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : showMailboxCopy ? "grid grid-cols-[minmax(0,1fr)_2rem]" : "grid grid-cols-1")}>
|
||||
<MailboxSwitcher
|
||||
collapsed={sidebarCollapsed}
|
||||
mailboxes={mailboxList.data?.items || []}
|
||||
loading={mailboxList.isLoading}
|
||||
selectedMailboxId={selectedMailboxId}
|
||||
selectedMailbox={selectedMailbox}
|
||||
fallbackAddress={selectedMailbox?.address || me.data?.user.email || ""}
|
||||
unreadCount={mailboxUnreadCount}
|
||||
onSelect={switchMailbox}
|
||||
/>
|
||||
{!sidebarCollapsed && (
|
||||
{!sidebarCollapsed && showMailboxCopy && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background", isAllMailboxSelected && "invisible pointer-events-none")}
|
||||
className="h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background"
|
||||
onClick={copyCurrentMailbox}
|
||||
disabled={!selectedMailbox || isAllMailboxSelected}
|
||||
disabled={!selectedMailbox}
|
||||
aria-label="复制邮箱地址"
|
||||
aria-hidden={isAllMailboxSelected}
|
||||
tabIndex={isAllMailboxSelected ? -1 : 0}
|
||||
title="复制邮箱地址"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
@@ -1319,12 +1354,12 @@ export function MailPage() {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>}
|
||||
{(customMailMenuItems.length > 0 || canOrganizeMail) && <SidebarGroup>
|
||||
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />文件夹</SidebarGroupLabel>
|
||||
{canOrganizeCurrentMailbox && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)} disabled={!selectedMailbox}>
|
||||
{canManageFolders && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -1449,7 +1484,7 @@ export function MailPage() {
|
||||
|
||||
const mailTransferTools = isTransferView ? (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button type="button" size="icon" variant="ghost" onClick={() => void exportCurrentMail()} disabled={!canExportCurrentView || exportingMail} className="h-8 w-8 text-muted-foreground hover:text-foreground" title="导出当前邮箱邮件为 ZIP" aria-label="导出当前邮箱邮件为 ZIP">
|
||||
<Button type="button" size="icon" variant="ghost" onClick={() => void exportCurrentMail()} disabled={!canExportCurrentView || exportingMail} className="h-8 w-8 text-muted-foreground hover:text-foreground" title={selectedCountOnPage > 0 ? `下载选中的 ${selectedCountOnPage} 封邮件` : "导出当前邮箱邮件为 ZIP"} aria-label={selectedCountOnPage > 0 ? `下载选中的 ${selectedCountOnPage} 封邮件` : "导出当前邮箱邮件为 ZIP"}>
|
||||
<Download className={cn("h-4 w-4", exportingMail && "animate-pulse")} />
|
||||
</Button>
|
||||
{mailView !== "unknown" && (
|
||||
@@ -1468,7 +1503,7 @@ export function MailPage() {
|
||||
) : !canReadMail ? (
|
||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||
<NoMailboxState onOpenSettings={openSettings} />
|
||||
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
||||
) : mailView === "scheduled" && canScheduleMail ? (
|
||||
<ScheduledSendView
|
||||
compact={compactMailLayout}
|
||||
@@ -1751,8 +1786,10 @@ export function MailPage() {
|
||||
/>
|
||||
<SidebarContextMenu
|
||||
state={sidebarContextMenu}
|
||||
canOrganize={canOrganizeMail}
|
||||
pending={reorderFolders.isPending}
|
||||
canCreate={canManageFolders}
|
||||
canReorder={canOrganizeCurrentMailbox}
|
||||
canDelete={canManageFolders}
|
||||
pending={reorderFolders.isPending || deleteFolder.isPending}
|
||||
onClose={closeSidebarContextMenu}
|
||||
onOpen={(item) => {
|
||||
closeSidebarContextMenu()
|
||||
@@ -2061,7 +2098,7 @@ function externalAccountSubtitle(account: ExternalImapAccount) {
|
||||
return [name, account.host, mode].filter(Boolean).join(" · ")
|
||||
}
|
||||
|
||||
function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
function NoMailboxState({ onManageMailboxes }: { onManageMailboxes: () => void }) {
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center p-6">
|
||||
<div className="w-full max-w-md rounded-lg border border-dashed p-8 text-center">
|
||||
@@ -2069,9 +2106,9 @@ function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-lg font-semibold">还没有可用邮箱</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。</div>
|
||||
<Button className="mt-5" onClick={onOpenSettings}>
|
||||
<Settings className="h-4 w-4" />前往个人中心
|
||||
<div className="mt-2 text-sm text-muted-foreground">请前往邮箱管理,创建、申请或联系管理员分配邮箱。</div>
|
||||
<Button className="mt-5" onClick={onManageMailboxes}>
|
||||
<MailboxIcon className="h-4 w-4" />前往邮箱管理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2422,7 +2459,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canOrganize: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) {
|
||||
function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canCreate: boolean; canReorder: boolean; canDelete: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) {
|
||||
React.useEffect(() => {
|
||||
if (!state) return
|
||||
const close = () => onClose()
|
||||
@@ -2460,30 +2497,32 @@ function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRe
|
||||
<Button type="button" variant="ghost" className={itemClass} onClick={onRefresh}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
</Button>
|
||||
{canOrganize && (
|
||||
{canCreate && (
|
||||
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
|
||||
<Plus className="h-4 w-4" />新建文件夹
|
||||
</Button>
|
||||
)}
|
||||
{canOrganize && customFolder && (
|
||||
{customFolder && (canReorder || canDelete) && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
|
||||
<ArrowLeft className="h-4 w-4 rotate-90" />移到最上
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
|
||||
<ChevronDown className="h-4 w-4 rotate-180" />上移一位
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
|
||||
<ChevronDown className="h-4 w-4" />下移一位
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
|
||||
<ArrowLeft className="h-4 w-4 -rotate-90" />移到最下
|
||||
</Button>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} onClick={() => onDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />删除文件夹
|
||||
</Button>
|
||||
{canReorder && <>
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
|
||||
<ArrowLeft className="h-4 w-4 rotate-90" />移到最上
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
|
||||
<ChevronDown className="h-4 w-4 rotate-180" />上移一位
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
|
||||
<ChevronDown className="h-4 w-4" />下移一位
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
|
||||
<ArrowLeft className="h-4 w-4 -rotate-90" />移到最下
|
||||
</Button>
|
||||
</>}
|
||||
{canReorder && canDelete && <div className="my-1 h-px bg-border" />}
|
||||
{canDelete && <Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />删除文件夹
|
||||
</Button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -3177,7 +3216,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme}>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
|
||||
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -3195,7 +3234,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onSettings}>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onSettings} title="设置" aria-label="设置">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3215,10 +3254,11 @@ function UnreadBadge({ count, tone = "danger" }: { count?: number; tone?: "dange
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMailbox, fallbackAddress, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailboxId: string; selectedMailbox?: Mailbox; fallbackAddress?: string; unreadCount: number; onSelect: (mailboxId: string) => void }) {
|
||||
function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, selectedMailbox, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; loading: boolean; selectedMailboxId: string; selectedMailbox?: Mailbox; unreadCount: number; onSelect: (mailboxId: string) => void }) {
|
||||
const [mailboxQuery, setMailboxQuery] = React.useState("")
|
||||
const isAllSelected = selectedMailboxId === "all"
|
||||
const displayAddress = isAllSelected ? "全部邮箱" : selectedMailbox?.address || fallbackAddress || "选择邮箱"
|
||||
const mailboxUnavailable = loading || mailboxes.length === 0
|
||||
const displayAddress = loading ? "全部邮箱" : mailboxes.length === 0 ? "未注册邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
|
||||
const selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
|
||||
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
||||
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
|
||||
@@ -3232,13 +3272,13 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
|
||||
return (
|
||||
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className={cn("h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
|
||||
<Button disabled={mailboxUnavailable} variant="outline" className={cn("h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{displayAddress}</span>
|
||||
<UnreadBadge count={selectedUnreadCount} />
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{!mailboxUnavailable && <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -3247,7 +3287,7 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
|
||||
align="start"
|
||||
className={cn(
|
||||
"max-w-[calc(100vw-32px)] p-1",
|
||||
collapsed ? "w-[204px]" : "w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)] min-w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)]"
|
||||
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-[var(--radix-dropdown-menu-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{mailboxes.length > 0 && (
|
||||
|
||||
@@ -59,6 +59,7 @@ export function ProfilePage() {
|
||||
const passwordFormRef = React.useRef<HTMLFormElement>(null)
|
||||
const twoFactorFormRef = React.useRef<HTMLFormElement>(null)
|
||||
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
|
||||
const [statsMailboxId, setStatsMailboxId] = React.useState("all")
|
||||
const [statsRangeDays, setStatsRangeDays] = React.useState(30)
|
||||
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||
const [displayMode, setDisplayMode] = useDisplayMode()
|
||||
@@ -82,6 +83,7 @@ export function ProfilePage() {
|
||||
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
|
||||
const canViewStats = hasPermission(user, "mail.stats.view")
|
||||
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
|
||||
const canConfigureMailboxApply = hasPermission(user, "admin.settings.update")
|
||||
const visibleTabKeys = tabKeys.filter((key) => {
|
||||
if (key === "profile") return true
|
||||
if (key === "mailboxes") return canAccessMail || canApplyMailbox
|
||||
@@ -97,29 +99,32 @@ export function ProfilePage() {
|
||||
const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
|
||||
const accountTab: AccountSettingsTab = rawAccountTab && accountSettingTabs.some((item) => item.key === rawAccountTab) ? rawAccountTab : "account"
|
||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules })
|
||||
const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox && tab === "mailboxes" })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings, enabled: tab === "mailboxes" || (tab === "profile" && accountTab === "clients") })
|
||||
const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens, enabled: tab === "apiTokens" })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts && tab === "contacts" })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures && tab === "profile" && accountTab === "mail" })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules && tab === "rules" })
|
||||
const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail && tab === "rules" })
|
||||
const ruleVerifiedEmails = React.useMemo(() => ruleForwarding.data?.verifiedEmails.filter((item) => item.verified).map((item) => item.email) || [], [ruleForwarding.data?.verifiedEmails])
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked && tab === "blocked" })
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled })
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: tab === "mailboxes" && !!activeMailboxId && canAccessMail && externalImapEnabled })
|
||||
React.useEffect(() => {
|
||||
if (!externalRunAccountId) return
|
||||
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
|
||||
setExternalRunAccountId("")
|
||||
}, [externalImapAccounts.data?.items, externalRunAccountId])
|
||||
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId, statsRangeDays], queryFn: () => api.mailStats(activeMailboxId, statsRangeDays), enabled: !!activeMailboxId && canViewStats })
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && ((tab === "profile" && accountTab === "mail" && (canReadMail || canManageLabels)) || (tab === "rules" && canManageRules)) })
|
||||
const accountStats = useQuery({ queryKey: ["mail-stats", "all", 30], queryFn: () => api.mailStats("all", 30), enabled: canViewStats && tab === "profile" && accountTab === "account" })
|
||||
const mailboxStats = useQuery({ queryKey: ["mail-stats", activeMailboxId, 30], queryFn: () => api.mailStats(activeMailboxId, 30), enabled: !!activeMailboxId && canViewStats && (tab === "cleanup" || tab === "cleanupQueue") })
|
||||
const blockedStats = useQuery({ queryKey: ["mail-stats", blockedMailboxId, 30], queryFn: () => api.mailStats(blockedMailboxId, 30), enabled: canViewStats && tab === "blocked" })
|
||||
const dashboardStats = useQuery({ queryKey: ["mail-stats", statsMailboxId, statsRangeDays], queryFn: () => api.mailStats(statsMailboxId, statsRangeDays), enabled: canViewStats && tab === "stats" })
|
||||
|
||||
const profile = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
|
||||
@@ -355,6 +360,16 @@ export function ProfilePage() {
|
||||
<span className="truncate">{tabs[key].label}</span>
|
||||
</button>
|
||||
))}
|
||||
{user.role === "admin" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 stroke-[1.8]" />
|
||||
<span className="truncate">后台管理</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t p-2">
|
||||
@@ -369,7 +384,10 @@ export function ProfilePage() {
|
||||
const pageTitle = tabs[tab].label
|
||||
const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined
|
||||
const pageAction = tab === "stats"
|
||||
? <StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
|
||||
? <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-[220px]"><MailboxSelect value={statsMailboxId} mailboxes={mailboxes.data?.items || []} onChange={setStatsMailboxId} /></div>
|
||||
<StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
|
||||
</div>
|
||||
: tab === "apiTokens"
|
||||
? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API 文档</a></Button>
|
||||
: undefined
|
||||
@@ -419,7 +437,7 @@ export function ProfilePage() {
|
||||
profile={profile}
|
||||
password={password}
|
||||
passwordFormRef={passwordFormRef}
|
||||
stats={canViewStats ? stats.data : undefined}
|
||||
stats={canViewStats ? accountStats.data : undefined}
|
||||
showStats={canViewStats}
|
||||
displayMode={displayMode}
|
||||
onDisplayModeChange={setDisplayMode}
|
||||
@@ -453,6 +471,7 @@ export function ProfilePage() {
|
||||
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
|
||||
applyOptions={mailboxApplyOptions.data}
|
||||
applyPending={applyMailbox.isPending}
|
||||
canConfigureApply={canConfigureMailboxApply}
|
||||
selectedMailboxId={mailboxId}
|
||||
externalImapEnabled={externalImapEnabled}
|
||||
externalAccounts={externalImapAccounts.data?.items || []}
|
||||
@@ -464,6 +483,7 @@ export function ProfilePage() {
|
||||
onSelect={setMailboxId}
|
||||
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
|
||||
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
|
||||
onConfigureApply={() => navigate("/admin?section=settings&settingsTab=mail")}
|
||||
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
|
||||
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
|
||||
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
|
||||
@@ -474,11 +494,11 @@ export function ProfilePage() {
|
||||
/>
|
||||
)
|
||||
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} verifiedEmails={ruleVerifiedEmails} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onUpdate={(id, payload) => updateRule.mutate({ id, payload })} onToggle={(item) => updateRule.mutate({ id: item.id, payload: { enabled: !item.enabled } })} onMove={(id, direction) => moveRule.mutate({ id, direction })} onApply={(id) => applyRule.mutate(id)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending || updateRule.isPending || moveRule.isPending || applyRule.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} onRefresh={() => stats.refetch()} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? blockedStats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "stats") return <StatsSection stats={dashboardStats.data} />
|
||||
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
|
||||
return null
|
||||
}
|
||||
@@ -905,8 +925,8 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
||||
<SettingsCard title="密码管理">
|
||||
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
|
||||
<Field label="当前密码"><PasswordInput name="currentPassword" required /></Field>
|
||||
<Field label="新密码"><PasswordInput name="newPassword" minLength={8} required placeholder="输入新密码" /></Field>
|
||||
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={8} required placeholder="再次输入密码" /></Field>
|
||||
<Field label="新密码"><PasswordInput name="newPassword" minLength={6} required placeholder="输入新密码" /></Field>
|
||||
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={6} required placeholder="再次输入密码" /></Field>
|
||||
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
@@ -1000,6 +1020,7 @@ function MailboxManagement({
|
||||
mailboxes,
|
||||
applyOptions,
|
||||
applyPending,
|
||||
canConfigureApply,
|
||||
selectedMailboxId,
|
||||
externalImapEnabled,
|
||||
externalAccounts,
|
||||
@@ -1011,6 +1032,7 @@ function MailboxManagement({
|
||||
onSelect,
|
||||
onOpen,
|
||||
onApply,
|
||||
onConfigureApply,
|
||||
onCreateExternal,
|
||||
onStartExternalOAuth,
|
||||
onUpdateExternal,
|
||||
@@ -1022,6 +1044,7 @@ function MailboxManagement({
|
||||
mailboxes: Mailbox[]
|
||||
applyOptions?: MailboxApplyOptions
|
||||
applyPending: boolean
|
||||
canConfigureApply: boolean
|
||||
selectedMailboxId: string
|
||||
externalImapEnabled: boolean
|
||||
externalAccounts: ExternalImapAccount[]
|
||||
@@ -1033,6 +1056,7 @@ function MailboxManagement({
|
||||
onSelect: (id: string) => void
|
||||
onOpen: (id: string) => void
|
||||
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
|
||||
onConfigureApply: () => void
|
||||
onCreateExternal: (payload: ExternalImapAccountPayload) => void
|
||||
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
|
||||
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
|
||||
@@ -1208,9 +1232,16 @@ function MailboxManagement({
|
||||
</select>
|
||||
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{canApply ? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。" : "提示:当前账号暂不可创建新邮箱。"}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
<span>{canApply
|
||||
? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。"
|
||||
: canConfigureApply && applyOptions?.enabled
|
||||
? "提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。"
|
||||
: canConfigureApply
|
||||
? "提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。"
|
||||
: "提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。"}</span>
|
||||
{!canApply && canConfigureApply && <Button type="button" variant="link" className="h-auto p-0 text-sm" onClick={onConfigureApply}>前往设置</Button>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border bg-card">
|
||||
@@ -2380,7 +2411,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
|
||||
)
|
||||
}
|
||||
|
||||
function StatsSection({ stats }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) {
|
||||
function StatsSection({ stats }: { stats?: MailStats }) {
|
||||
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
||||
const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100)
|
||||
const primaryCards = [
|
||||
|
||||
@@ -113,11 +113,11 @@ export function RegisterPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-sm font-medium">确认密码</Label>
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
||||
|
||||
+5
-2
@@ -20,9 +20,12 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
|
||||
# 手动部署可执行:openssl rand -hex 24
|
||||
LANQIN_UPDATE_TOKEN=
|
||||
|
||||
# 可选端口绑定。使用宿主机反向代理时,可将 HTTP 设为 127.0.0.1:8088。
|
||||
# 一键安装器记录的部署方式。手动部署时可以留空。
|
||||
LANQIN_INSTALL_FIREWALL_MODE=
|
||||
LANQIN_INSTALL_WEB_MODE=
|
||||
|
||||
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
|
||||
LANQIN_HTTP_BIND=80
|
||||
LANQIN_HTTPS_BIND=443
|
||||
LANQIN_SMTP_BIND=25
|
||||
LANQIN_SMTPS_BIND=465
|
||||
LANQIN_SUBMISSION_BIND=587
|
||||
|
||||
+11
-3
@@ -15,10 +15,19 @@ sudo newszxcn-email update
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email rollback
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
```
|
||||
|
||||
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
||||
|
||||
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
|
||||
|
||||
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
|
||||
|
||||
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。
|
||||
|
||||
## 最简单部署:单容器镜像版
|
||||
|
||||
服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。
|
||||
@@ -158,10 +167,9 @@ Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/
|
||||
|
||||
```dotenv
|
||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
||||
LANQIN_HTTPS_BIND=127.0.0.1:8443
|
||||
```
|
||||
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。不使用宿主机反向代理时保留默认的 `80` 与 `443` 即可。
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
@@ -182,7 +190,7 @@ services:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./certs:/certs:ro
|
||||
```
|
||||
|
||||
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
||||
|
||||
@@ -5,6 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 50m;
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -7,7 +7,6 @@ services:
|
||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||
ports:
|
||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||
- "${LANQIN_HTTPS_BIND:-443}:443"
|
||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||
- "${LANQIN_SMTPS_BIND:-465}:465"
|
||||
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
||||
@@ -17,8 +16,7 @@ services:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./certs:/certs:ro
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
||||
|
||||
@@ -3,6 +3,7 @@ server {
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 50m;
|
||||
proxy_pass http://api:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+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