diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 79b359e..956e2f9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Security vulnerability / 安全漏洞 - url: https://github.com/LanQin996/LanQin-Email/security/advisories/new + url: https://github.com/zxyszx/NewSzxcn-Email/security/advisories/new about: Please report security vulnerabilities privately. / 请通过私密渠道报告安全漏洞,不要公开提交漏洞细节。 - name: Community chat / 社区交流 url: https://t.me/+EhII7MSyi3QwNDQ5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 251cfa8..35c3e2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install web dependencies - run: pnpm install --frozen-lockfile --filter lanqin-email-web... + run: pnpm install --frozen-lockfile --filter newszxcn-email-web... - name: Check shadcn/ui usage run: pnpm --dir apps/web run check:shadcn diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 45a055f..bf0c6cf 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -17,7 +17,6 @@ concurrency: env: REGISTRY: ghcr.io - PLATFORMS: linux/amd64 jobs: checks: @@ -40,7 +39,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install web dependencies - run: pnpm install --frozen-lockfile --filter lanqin-email-web... + run: pnpm install --frozen-lockfile --filter newszxcn-email-web... - name: Check shadcn/ui usage run: pnpm --dir apps/web run check:shadcn @@ -76,9 +75,11 @@ jobs: version="${version#.}" version="${version#-}" release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${tag}" - echo "tag=${tag}" >> "$GITHUB_OUTPUT" - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "release_url=${release_url}" >> "$GITHUB_OUTPUT" + { + echo "tag=${tag}" + echo "version=${version}" + echo "release_url=${release_url}" + } >> "$GITHUB_OUTPUT" echo "::notice::Docker release tag: ${tag}" docker: @@ -93,35 +94,42 @@ jobs: suffix: "" context: . file: ./deploy/all-in-one/Dockerfile + platforms: linux/amd64,linux/arm64 - name: api suffix: -api context: . file: ./deploy/api.Dockerfile + platforms: linux/amd64,linux/arm64 - name: web suffix: -web context: . file: ./deploy/web.Dockerfile + platforms: linux/amd64,linux/arm64 - name: postfix suffix: -postfix context: ./deploy/postfix file: ./deploy/postfix/Dockerfile + platforms: linux/amd64 - name: dovecot suffix: -dovecot context: ./deploy/dovecot file: ./deploy/dovecot/Dockerfile + platforms: linux/amd64 - name: rspamd suffix: -rspamd context: ./deploy/rspamd file: ./deploy/rspamd/Dockerfile + platforms: linux/amd64 steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver: docker - name: Login to GHCR uses: docker/login-action@v3 @@ -135,9 +143,11 @@ jobs: shell: bash run: | image="${REGISTRY}/${GITHUB_REPOSITORY}${{ matrix.suffix }}" - echo "name=${image,,}" >> "$GITHUB_OUTPUT" - echo "tag=${{ needs.release.outputs.tag }}" >> "$GITHUB_OUTPUT" - echo "version=${{ needs.release.outputs.version }}" >> "$GITHUB_OUTPUT" + { + echo "name=${image,,}" + echo "tag=${{ needs.release.outputs.tag }}" + echo "version=${{ needs.release.outputs.version }}" + } >> "$GITHUB_OUTPUT" - name: Docker metadata id: meta @@ -158,9 +168,11 @@ jobs: with: context: ${{ matrix.context }} file: ${{ matrix.file }} - platforms: ${{ env.PLATFORMS }} + platforms: ${{ matrix.platforms }} push: true build-args: | + APP_VERSION=${{ needs.release.outputs.tag }} + APP_COMMIT=${{ github.sha }} VITE_APP_VERSION=${{ needs.release.outputs.tag }} VITE_RELEASE_URL=${{ needs.release.outputs.release_url }} tags: ${{ steps.meta.outputs.tags }} @@ -197,18 +209,20 @@ jobs: fi if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then - echo "GitHub 自动生成更新日志失败,已回退到提交列表。" > generated-release-notes.md - echo >> generated-release-notes.md - if [[ -n "${previous_tag}" ]]; then - git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}" >> generated-release-notes.md - echo >> generated-release-notes.md - echo >> generated-release-notes.md - echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})" >> generated-release-notes.md - else - echo "- 首个公开版本。" >> generated-release-notes.md - echo >> generated-release-notes.md - echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})" >> generated-release-notes.md - fi + { + 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 < - Mailbox - Compose -

- -

- Admin console - Mailbox management -

- -## Stack - -- Backend: Go -- Frontend: React + TypeScript + shadcn/ui -- Database: SQLite -- Mail stack: Postfix + Dovecot + Rspamd -- Deployment: Docker / Docker Compose - -## Quick Deploy +Debian and Ubuntu on `amd64` or `arm64` are supported. ```bash -cd deploy -cp .env.example .env -# Edit domain, public URL, admin email, and admin password -docker compose up -d --build +curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash ``` -Public mail delivery requires MX, SPF, DKIM, DMARC, and open mail ports. +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. -## Note +## Update -This is the NewSzxcn maintained version. Future changes are based on this repository. +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. + +CLI update and rollback: + +```bash +sudo newszxcn-email update +sudo newszxcn-email rollback +``` + +Useful commands: + +```bash +sudo newszxcn-email status +sudo newszxcn-email logs +sudo newszxcn-email uninstall +``` + +The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`. + +## Required ports + +Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Public delivery also requires correct MX, SPF, DKIM, and DMARC records. + +## Manual source deployment + +```bash +git clone https://github.com/zxyszx/NewSzxcn-Email.git +cd NewSzxcn-Email/deploy +cp .env.example .env +docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build +``` ## License -[MIT](./LICENSE) +[MIT](LICENSE) diff --git a/README.md b/README.md index 66b6fa2..a5749e9 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,142 @@ # NewSzxcn-Email -NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。 +NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。 -## 功能 +[![Release](https://img.shields.io/github/v/release/zxyszx/NewSzxcn-Email?display_name=tag&sort=semver)](https://github.com/zxyszx/NewSzxcn-Email/releases) +[![Docker Release](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml) +[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE) -- Webmail 收发邮件、写信、附件、草稿、搜索、星标、标签、已读/未读 -- 多邮箱、多域名、DKIM、DNS 检测、邮件转发 -- 账号管理、邮箱数量配额、权限配额、注册与自助申请邮箱 -- 管理后台、全部邮件、发送队列、系统设置 -- Postfix、Dovecot、Rspamd、SQLite、Docker 单容器部署 +[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) -## 技术栈 +## 主要功能 -- 后端:Go -- 前端:React + TypeScript + shadcn/ui -- 数据库:SQLite -- 邮件服务:Postfix + Dovecot + Rspamd -- 部署:Docker / Docker Compose +| 模块 | 能力 | +| --- | --- | +| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 | +| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP | +| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 | +| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 | +| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission | +| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 | +| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 | -## 快速部署 +## 一键安装 + +支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。 + +```bash +curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash +``` + +脚本会自动完成: + +- 安装或检查 Docker Engine 与 Docker Compose v2 +- 询问邮件域名、访问地址、管理员邮箱和密码 +- 创建 `/opt/newszxcn-email` 持久化目录 +- 拉取 GHCR 镜像并启动邮件服务 +- 生成后台在线更新所需的内部鉴权令牌 +- 等待 Web 与 API 健康检查通过 + +安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。 + +> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。 + +## 更新与回滚 + +### 后台页面更新 + +超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。 + +更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。 + +### 命令行更新 + +```bash +sudo newszxcn-email update +``` + +命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行: + +```bash +sudo newszxcn-email rollback +``` + +常用运维命令: + +```bash +sudo newszxcn-email status +sudo newszxcn-email logs +sudo newszxcn-email uninstall +``` + +`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。 + +## DNS 与端口 + +至少需要以下 DNS 记录: + +| 类型 | 示例 | 用途 | +| --- | --- | --- | +| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail | +| MX | `example.com -> mail.example.com` | 接收邮件 | +| SPF TXT | 后台生成 | 声明允许发信的服务器 | +| DKIM TXT | 后台按域名生成 | 邮件签名验证 | +| DMARC TXT | 后台生成建议值 | 发信策略与报告 | + +服务器防火墙和云安全组应按需开放: + +| 端口 | 协议 | 用途 | +| --- | --- | --- | +| 25 | TCP | SMTP 服务器间收发信 | +| 80 / 443 | TCP | Webmail 与证书签发 | +| 465 / 587 | TCP | 邮件客户端 SMTP 发信 | +| 993 | TCP | IMAP SSL | +| 995 | TCP | POP3 SSL | + +## 数据目录 + +默认部署目录为 `/opt/newszxcn-email`: + +```text +/opt/newszxcn-email/ +|-- .env # 环境配置与内部更新令牌 +|-- docker-compose.yml # 邮箱主服务与内部更新服务 +|-- data/ # SQLite、附件和更新前备份 +|-- mail/ # Maildir 邮件原文 +`-- dkim/ # DKIM 私钥 +``` + +升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。 + +## 手动部署 + +需要自行控制 Compose 配置时: + +```bash +git clone https://github.com/zxyszx/NewSzxcn-Email.git +cd NewSzxcn-Email/deploy +cp .env.example .env +# 编辑 .env +docker compose pull +docker compose up -d +``` + +本地源码构建: ```bash cd deploy -cp .env.example .env -# 修改域名、访问地址、管理员邮箱、管理员密码 -docker compose up -d --build +docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build ``` -公网收发邮件需要配置: +更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。 -- MX -- SPF -- DKIM -- DMARC -- 25 / 465 / 587 / 993 / 995 端口 +## 技术栈 + +- 后端:Go、Chi、SQLite +- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS +- 邮件:Postfix、Dovecot、Rspamd +- 部署:Docker、Docker Compose、GitHub Actions、GHCR ## 本地开发 @@ -48,10 +151,13 @@ pnpm install pnpm run dev ``` -## 说明 +提交前建议运行: -这是 NewSzxcn 自用维护版本,后续功能和界面修改都以本仓库为准。 +```bash +cd apps/api && go test ./... +cd apps/web && pnpm run check +``` -## License +## 开源协议 -[MIT](./LICENSE) +[MIT](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index 9fdd138..f493197 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,53 +1,137 @@ # NewSzxcn-Email -NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。 +NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。 -在线地址:[mail.newszxcn.com](https://mail.newszxcn.com) +[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) -## 功能 +## 主要功能 -- Webmail 收发邮件、写信、附件、草稿、搜索、星标、标签、已读/未读 -- 多邮箱、多域名、DKIM、DNS 检测、邮件转发 -- 账号管理、邮箱数量配额、权限配额、注册与自助申请邮箱 -- 管理后台、全部邮件、发送队列、系统设置 -- Postfix、Dovecot、Rspamd、SQLite、Docker 单容器部署 +| 模块 | 能力 | +| --- | --- | +| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 | +| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP | +| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 | +| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 | +| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission | +| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 | +| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 | -## 截图 +## 一键安装 -

- 邮箱首页 - 写邮件 -

+支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。 -

- 后台管理 - 邮箱管理 -

+```bash +curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash +``` -## 技术栈 +脚本会自动完成: -- 后端:Go -- 前端:React + TypeScript + shadcn/ui -- 数据库:SQLite -- 邮件服务:Postfix + Dovecot + Rspamd -- 部署:Docker / Docker Compose +- 安装或检查 Docker Engine 与 Docker Compose v2 +- 询问邮件域名、访问地址、管理员邮箱和密码 +- 创建 `/opt/newszxcn-email` 持久化目录 +- 拉取 GHCR 镜像并启动邮件服务 +- 生成后台在线更新所需的内部鉴权令牌 +- 等待 Web 与 API 健康检查通过 -## 快速部署 +安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。 + +> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。 + +## 更新与回滚 + +### 后台页面更新 + +超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。 + +更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。 + +### 命令行更新 + +```bash +sudo newszxcn-email update +``` + +命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行: + +```bash +sudo newszxcn-email rollback +``` + +常用运维命令: + +```bash +sudo newszxcn-email status +sudo newszxcn-email logs +sudo newszxcn-email uninstall +``` + +`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。 + +## DNS 与端口 + +至少需要以下 DNS 记录: + +| 类型 | 示例 | 用途 | +| --- | --- | --- | +| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail | +| MX | `example.com -> mail.example.com` | 接收邮件 | +| SPF TXT | 后台生成 | 声明允许发信的服务器 | +| DKIM TXT | 后台按域名生成 | 邮件签名验证 | +| DMARC TXT | 后台生成建议值 | 发信策略与报告 | + +服务器防火墙和云安全组应按需开放: + +| 端口 | 协议 | 用途 | +| --- | --- | --- | +| 25 | TCP | SMTP 服务器间收发信 | +| 80 / 443 | TCP | Webmail 与证书签发 | +| 465 / 587 | TCP | 邮件客户端 SMTP 发信 | +| 993 | TCP | IMAP SSL | +| 995 | TCP | POP3 SSL | + +## 数据目录 + +默认部署目录为 `/opt/newszxcn-email`: + +```text +/opt/newszxcn-email/ +├── .env # 环境配置与内部更新令牌 +├── docker-compose.yml # 邮箱主服务与内部更新服务 +├── data/ # SQLite、附件和更新前备份 +├── mail/ # Maildir 邮件原文 +└── dkim/ # DKIM 私钥 +``` + +升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。 + +## 手动部署 + +需要自行控制 Compose 配置时: + +```bash +git clone https://github.com/zxyszx/NewSzxcn-Email.git +cd NewSzxcn-Email/deploy +cp .env.example .env +# 编辑 .env +docker compose pull +docker compose up -d +``` + +本地源码构建: ```bash cd deploy -cp .env.example .env -# 修改域名、访问地址、管理员邮箱、管理员密码 -docker compose up -d --build +docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build ``` -公网收发邮件需要配置: +更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。 -- MX -- SPF -- DKIM -- DMARC -- 25 / 465 / 587 / 993 / 995 端口 +## 技术栈 + +- 后端:Go、Chi、SQLite +- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS +- 邮件:Postfix、Dovecot、Rspamd +- 部署:Docker、Docker Compose、GitHub Actions、GHCR ## 本地开发 @@ -62,10 +146,13 @@ pnpm install pnpm run dev ``` -## 说明 +提交前建议运行: -这是 NewSzxcn 自用维护版本,后续功能和界面修改都以本仓库为准。 +```bash +cd apps/api && go test ./... +cd apps/web && pnpm run build +``` -## License +## 开源协议 -[MIT](./LICENSE) +[MIT](LICENSE) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index fbf9813..72fb708 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -94,14 +94,14 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { var req struct { - LoginName string `json:"loginName"` - Email string `json:"email"` - DisplayName string `json:"displayName"` - Role string `json:"role"` - Password string `json:"password"` - Disabled bool `json:"disabled"` - MailboxLimitOverride *int `json:"mailboxLimitOverride"` - PermissionGroupIDs []string `json:"permissionGroupIds"` + LoginName string `json:"loginName"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Password string `json:"password"` + Disabled bool `json:"disabled"` + MailboxLimitOverride *int `json:"mailboxLimitOverride"` + PermissionGroupIDs []string `json:"permissionGroupIds"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -183,11 +183,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") current := currentUser(r) var req struct { - DisplayName string `json:"displayName"` - Role string `json:"role"` - Disabled *bool `json:"disabled"` - MailboxLimitOverride *int `json:"mailboxLimitOverride"` - PermissionGroupIDs *[]string `json:"permissionGroupIds"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Disabled *bool `json:"disabled"` + MailboxLimitOverride *int `json:"mailboxLimitOverride"` + PermissionGroupIDs *[]string `json:"permissionGroupIds"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -779,6 +779,13 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) folder := strings.TrimSpace(r.URL.Query().Get("folder")) + user := currentUser(r) + isSystemAdmin := user != nil && user.Role == "admin" + wantsUnregistered := mailboxID == "unregistered" || strings.EqualFold(folder, "Unregistered") + if wantsUnregistered && !isSystemAdmin { + respondError(w, http.StatusForbidden, "system admin required") + return + } offset, _ := strconv.Atoi(r.URL.Query().Get("cursor")) if offset < 0 { offset = 0 @@ -787,6 +794,9 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) { where := []string{"1=1"} args := []any{} + if !isSystemAdmin { + where = append(where, "m.mailbox_id IS NOT NULL") + } if mailboxID == "unregistered" { where = append(where, "m.mailbox_id IS NULL") } else if mailboxID != "" && mailboxID != "all" { @@ -844,6 +854,11 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "message not found") return } + user := currentUser(r) + if msg.MailboxID == "" && (user == nil || user.Role != "admin") { + respondError(w, http.StatusForbidden, "system admin required") + return + } if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,'') FROM messages m LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index be035e3..53842f6 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1075,6 +1075,73 @@ func TestMailRulesMailboxIsolation(t *testing.T) { } } +func TestMailRuleManagementActions(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + client := &testClient{t: t, server: ts} + + var login map[string]any + if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d", code) + } + _, mailbox := defaultAdminUserAndMailbox(t, a) + create := func(name, value string) MailRule { + t.Helper() + var rule MailRule + if code := client.do("POST", "/api/me/rules", map[string]any{ + "mailboxId": mailbox.ID, + "name": name, + "matchMode": "all", + "conditions": []map[string]string{{ + "field": "subject", "operator": "contains", "value": value, + }}, + "actions": []map[string]string{{"type": "archive"}}, + "enabled": true, + }, &rule); code != http.StatusCreated { + t.Fatalf("create %s code=%d rule=%+v", name, code, rule) + } + return rule + } + first := create("first", "欢迎使用 NewSzxcn 邮箱") + second := create("second", "two") + + var updated MailRule + if code := client.do("POST", "/api/me/rules/"+first.ID, map[string]any{"name": "first updated", "enabled": false}, &updated); code != http.StatusOK { + t.Fatalf("update code=%d rule=%+v", code, updated) + } + if updated.Name != "first updated" || updated.Enabled { + t.Fatalf("updated rule=%+v", updated) + } + + var applied struct { + OK bool `json:"ok"` + Affected int64 `json:"affected"` + } + if code := client.do("POST", "/api/me/rules/"+first.ID+"/apply", nil, &applied); code != http.StatusOK || !applied.OK || applied.Affected != 1 { + t.Fatalf("apply code=%d body=%+v", code, applied) + } + + var moved map[string]any + if code := client.do("POST", "/api/me/rules/"+second.ID+"/move", map[string]string{"direction": "down"}, &moved); code != http.StatusOK { + t.Fatalf("move code=%d body=%+v", code, moved) + } + var listed struct { + Items []MailRule `json:"items"` + } + if code := client.do("GET", "/api/me/rules", nil, &listed); code != http.StatusOK || len(listed.Items) != 2 { + t.Fatalf("list code=%d items=%+v", code, listed.Items) + } + if listed.Items[0].ID != first.ID || listed.Items[1].ID != second.ID { + t.Fatalf("unexpected order after move: %+v", listed.Items) + } + + var missing map[string]any + if code := client.do("POST", "/api/me/rules/missing/apply", nil, &missing); code != http.StatusNotFound { + t.Fatalf("missing apply code=%d body=%+v", code, missing) + } +} + func TestBlockedSenderMovesInboundToSpamAndIsolatesUsers(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) @@ -1769,6 +1836,47 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) { if got := list.Items[0].RecipientAddr; got != "ghost@lanqin.local" { t.Fatalf("recipientAddress=%q", got) } + unregisteredMessageID := list.Items[0].ID + + var auditGroup PermissionGroup + if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{ + "name": "Catch-all Message Auditors", + "description": "Test group for registered-message audit access", + "permissions": []string{PermissionMessagesView, PermissionMessagesRead, PermissionMessagesAttachment}, + }, &auditGroup); code != http.StatusCreated { + t.Fatalf("create message audit group code=%d group=%+v", code, auditGroup) + } + var auditor AdminUser + if code := admin.do("POST", "/api/admin/users", map[string]any{ + "email": "message-auditor@lanqin.local", + "displayName": "Message Auditor", + "role": "user", + "password": "Password123!", + "disabled": false, + "permissionGroupIds": []string{auditGroup.ID}, + }, &auditor); code != http.StatusCreated { + t.Fatalf("create message auditor code=%d user=%+v", code, auditor) + } + auditorClient := &testClient{t: t, server: ts} + if code := auditorClient.do("POST", "/api/auth/login", map[string]string{"email": "message-auditor@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("auditor login code=%d body=%v", code, login) + } + var errBody map[string]any + if code := auditorClient.do("GET", "/api/admin/messages?mailboxId=unregistered", nil, &errBody); code != http.StatusForbidden { + t.Fatalf("message auditor unregistered list code=%d body=%v", code, errBody) + } + list.Items = nil + if code := auditorClient.do("GET", "/api/admin/messages?q=stored%20for%20admin", nil, &list); code != http.StatusOK { + t.Fatalf("message auditor all-mail query code=%d items=%+v", code, list.Items) + } + for _, item := range list.Items { + if item.MailboxID == "" { + t.Fatalf("message auditor all-mail query exposed unregistered mail: %+v", item) + } + } + if code := auditorClient.do("GET", "/api/admin/messages/"+unregisteredMessageID, nil, &errBody); code != http.StatusForbidden { + t.Fatalf("message auditor unregistered detail code=%d body=%v", code, errBody) + } } func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) { diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index b5af951..36729d2 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -9,6 +9,7 @@ import ( type Config struct { Addr string + AppVersion string DBPath string DataDir string CookieName string @@ -55,12 +56,16 @@ type Config struct { StatusWebhookURL string StatusWebhookSecret string StatusWebhookAllowPrivateHosts bool + ReleaseAPIURL string + UpdateServiceURL string + UpdateServiceToken string } func LoadConfig() Config { dataDir := getenv("LANQIN_DATA_DIR", "./data") return Config{ Addr: getenv("LANQIN_ADDR", ":8080"), + AppVersion: getenv("LANQIN_APP_VERSION", BuildVersion), DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")), DataDir: dataDir, CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), @@ -107,6 +112,9 @@ func LoadConfig() Config { StatusWebhookURL: getenv("LANQIN_STATUS_WEBHOOK_URL", ""), StatusWebhookSecret: getenv("LANQIN_STATUS_WEBHOOK_SECRET", ""), StatusWebhookAllowPrivateHosts: getenvBool("LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS", false), + ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"), + UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""), + UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""), } } diff --git a/apps/api/internal/app/forwarding_handlers.go b/apps/api/internal/app/forwarding_handlers.go index 9a62b54..b800872 100644 --- a/apps/api/internal/app/forwarding_handlers.go +++ b/apps/api/internal/app/forwarding_handlers.go @@ -34,10 +34,10 @@ type MailboxForwardingRule struct { } type ForwardingSettings struct { - VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"` - AccountTargetEmail string `json:"accountTargetEmail"` + VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"` + AccountTargetEmail string `json:"accountTargetEmail"` AccountTargetEmails []string `json:"accountTargetEmails"` - MailboxRules []MailboxForwardingRule `json:"mailboxRules"` + MailboxRules []MailboxForwardingRule `json:"mailboxRules"` } func (a *App) handleForwardingSettings(w http.ResponseWriter, r *http.Request) { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index e9730d7..df78faf 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -2126,13 +2126,18 @@ func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) { func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) { attID := chi.URLParam(r, "id") - row := a.db.QueryRowContext(r.Context(), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?`, attID) - var filename, contentType, path string + row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path,COALESCE(m.mailbox_id,'') FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=?`, attID) + var filename, contentType, path, mailboxID string var size int64 - if err := row.Scan(&filename, &contentType, &size, &path); err != nil { + if err := row.Scan(&filename, &contentType, &size, &path, &mailboxID); err != nil { respondError(w, http.StatusNotFound, "attachment not found") return } + user := currentUser(r) + if mailboxID == "" && (user == nil || user.Role != "admin") { + respondError(w, http.StatusForbidden, "system admin required") + return + } f, err := os.Open(path) if err != nil { respondError(w, http.StatusNotFound, "attachment file missing") diff --git a/apps/api/internal/app/mail_transfer_handlers.go b/apps/api/internal/app/mail_transfer_handlers.go new file mode 100644 index 0000000..c439b26 --- /dev/null +++ b/apps/api/internal/app/mail_transfer_handlers.go @@ -0,0 +1,351 @@ +package app + +import ( + "archive/zip" + "bufio" + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +const maxMailImportBytes int64 = 256 << 20 + +var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) { + ids, err := a.exportMessageIDs(r) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "mailbox or label not found") + return + } + if errors.Is(err, errSystemAdminRequired) { + respondError(w, http.StatusForbidden, "system admin required") + return + } + badRequest(w, err) + return + } + + filename := fmt.Sprintf("mail-export-%s.zip", a.now().UTC().Format("20060102-150405")) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Cache-Control", "no-store") + + zw := zip.NewWriter(w) + usedNames := make(map[string]int, len(ids)) + for index, id := range ids { + raw, subject, 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}) + if err != nil { + _ = zw.Close() + return + } + if _, err := entry.Write(raw); err != nil { + _ = zw.Close() + return + } + } + _ = zw.Close() +} + +var errSystemAdminRequired = errors.New("system admin required") + +func (a *App) exportMessageIDs(r *http.Request) ([]string, error) { + user := currentUser(r) + if user == nil { + return nil, errors.New("no user") + } + view := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view"))) + mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) + where := []string{} + args := []any{} + + if view == "unknown" { + if user.Role != "admin" { + return nil, errSystemAdminRequired + } + where = append(where, "m.mailbox_id IS NULL") + } else { + where = append(where, "EXISTS (SELECT 1 FROM mailboxes owner_mb WHERE owner_mb.id=m.mailbox_id AND owner_mb.user_id=? AND owner_mb.status='active')") + args = append(args, user.ID) + if mailboxID != "" && !isAllMailboxID(mailboxID) { + if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil { + return nil, err + } + where = append(where, "m.mailbox_id=?") + args = append(args, mailboxID) + } + switch view { + case "", "folder": + folder := strings.TrimSpace(r.URL.Query().Get("folder")) + if folder == "" { + folder = "Inbox" + } + normalized, err := normalizeFolderNameForUser(folder) + if err != nil { + return nil, err + } + where = append(where, "f.name=?") + args = append(args, normalized) + case "starred": + where = append(where, "m.is_starred=1") + case "label": + labelID := strings.TrimSpace(r.URL.Query().Get("labelId")) + if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) { + return nil, sql.ErrNoRows + } + where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)") + args = append(args, labelID) + default: + return nil, errors.New("unsupported mail view") + } + } + + 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 { + return nil, err + } + defer rows.Close() + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) { + msg, err := a.storedMessageByID(ctx, id) + if err != nil { + return nil, "", err + } + 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 + } + } + } + attachments, err := a.attachmentInputsForMessage(ctx, id) + if err != nil { + return nil, "", 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 +} + +func exportMessageFilename(subject, id string, index int) string { + name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-") + name = strings.Trim(name, ".-_") + if name == "" { + name = "message" + } + if len(name) > 80 { + name = name[:80] + } + return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id) +} + +func uniqueExportFilename(name string, used map[string]int) string { + used[name]++ + if used[name] == 1 { + return name + } + base := strings.TrimSuffix(name, filepath.Ext(name)) + return fmt.Sprintf("%s-%d%s", base, used[name], filepath.Ext(name)) +} + +func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxMailImportBytes) + if err := r.ParseMultipartForm(maxMailImportBytes); err != nil { + respondError(w, http.StatusRequestEntityTooLarge, "import is too large") + return + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + mb, err := a.mailboxForCurrentUserWithID(r, r.FormValue("mailboxId")) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + folderName := strings.TrimSpace(r.FormValue("folder")) + if folderName == "" { + folderName = "Inbox" + } + folderName, err = normalizeFolderNameForUser(folderName) + if err != nil { + badRequest(w, err) + return + } + folderID, err := a.ensureFolder(r.Context(), mb.ID, folderName) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load folder") + return + } + files := r.MultipartForm.File["files"] + if len(files) == 0 { + files = r.MultipartForm.File["file"] + } + if len(files) == 0 { + badRequest(w, errors.New("at least one EML or MBOX file is required")) + return + } + + imported, skipped := 0, 0 + problems := []string{} + maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024 + if maxMessageBytes <= 0 { + maxMessageBytes = 35 * 1024 * 1024 + } + for _, header := range files { + messages, fileErr := readImportFile(header, maxMessageBytes) + if fileErr != nil { + skipped++ + problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, fileErr)) + continue + } + for _, raw := range messages { + if err := a.importRawMessage(r.Context(), mb, folderID, raw); err != nil { + skipped++ + problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, err)) + continue + } + imported++ + } + } + if imported == 0 && len(problems) > 0 { + badRequest(w, errors.New(problems[0])) + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported, "skipped": skipped, "errors": problems}) +} + +func readImportFile(header *multipart.FileHeader, maxMessageBytes int64) ([][]byte, error) { + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != ".eml" && ext != ".mbox" { + return nil, errors.New("only .eml and .mbox files are supported") + } + file, err := header.Open() + if err != nil { + return nil, err + } + defer file.Close() + if ext == ".eml" { + raw, err := io.ReadAll(io.LimitReader(file, maxMessageBytes+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) > maxMessageBytes { + return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024)) + } + if len(bytes.TrimSpace(raw)) == 0 { + return nil, errors.New("message is empty") + } + return [][]byte{raw}, nil + } + return parseMBOX(file, maxMessageBytes) +} + +func parseMBOX(reader io.Reader, maxMessageBytes int64) ([][]byte, error) { + scanner := bufio.NewScanner(reader) + bufferSize := int(maxMessageBytes + 1024) + if bufferSize < 64*1024 { + bufferSize = 64 * 1024 + } + scanner.Buffer(make([]byte, 64*1024), bufferSize) + var current bytes.Buffer + messages := [][]byte{} + seenSeparator := false + flush := func() error { + raw := bytes.TrimSpace(current.Bytes()) + current.Reset() + if len(raw) == 0 { + return nil + } + if int64(len(raw)) > maxMessageBytes { + return fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024)) + } + messages = append(messages, append([]byte(nil), raw...)) + return nil + } + for scanner.Scan() { + line := scanner.Bytes() + if bytes.HasPrefix(line, []byte("From ")) { + if seenSeparator { + if err := flush(); err != nil { + return nil, err + } + } + seenSeparator = true + continue + } + if bytes.HasPrefix(line, []byte(">From ")) { + line = line[1:] + } + current.Write(line) + current.WriteString("\r\n") + if int64(current.Len()) > maxMessageBytes { + return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024)) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + if err := flush(); err != nil { + return nil, err + } + if len(messages) == 0 { + return nil, errors.New("MBOX contains no messages") + } + return messages, nil +} + +func (a *App) importRawMessage(ctx context.Context, mb *Mailbox, folderID string, raw []byte) error { + msg, attachments, err := a.parseMaildirMessage(raw, mb.Address) + if err != nil { + return fmt.Errorf("invalid message: %w", err) + } + msg.MailboxID = mb.ID + msg.FolderID = folderID + msg.RecipientAddr = mb.Address + msg.RawPath = "" + id, err := a.insertMessage(ctx, msg, attachments) + if err != nil { + return err + } + if err := a.writeRawMessageToMaildir(ctx, id, raw, false); err != nil { + a.deleteMessage(ctx, id) + return err + } + return nil +} + +func appendImportProblem(items []string, problem string) []string { + if len(items) >= 5 { + return items + } + return append(items, problem) +} diff --git a/apps/api/internal/app/mail_transfer_handlers_test.go b/apps/api/internal/app/mail_transfer_handlers_test.go new file mode 100644 index 0000000..867c568 --- /dev/null +++ b/apps/api/internal/app/mail_transfer_handlers_test.go @@ -0,0 +1,174 @@ +package app + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseMBOXMultipleMessages(t *testing.T) { + raw := strings.Join([]string{ + "From sender@example.com Mon Jan 1 00:00:00 2024", + "From: sender@example.com", + "To: first@example.com", + "Subject: first", + "", + "first body", + ">From escaped body line", + "From sender@example.com Tue Jan 2 00:00:00 2024", + "From: sender@example.com", + "To: second@example.com", + "Subject: second", + "", + "second body", + }, "\n") + messages, err := parseMBOX(strings.NewReader(raw), 1<<20) + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 { + t.Fatalf("messages=%d", len(messages)) + } + if !bytes.Contains(messages[0], []byte("Subject: first")) || !bytes.Contains(messages[0], []byte("From escaped body line")) { + t.Fatalf("first message=%q", messages[0]) + } + if !bytes.Contains(messages[1], []byte("Subject: second")) { + t.Fatalf("second message=%q", messages[1]) + } +} + +func TestMailImportExportAndOwnership(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, "transfer-owner", "Transfer Owner", "Password123!", nil) + otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-other", "Transfer Other", "Password123!", nil) + owner := &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) + } + + eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: \r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello 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 { + 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" { + t.Fatalf("list code=%d items=%+v", code, list.Items) + } + + status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID) + if status != http.StatusOK { + t.Fatalf("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) != 1 { + t.Fatalf("zip entries=%d", len(zr.File)) + } + 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")) { + t.Fatalf("exported message err=%v raw=%q", err, exported) + } + + var denied map[string]any + if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound { + t.Fatalf("cross-mailbox import code=%d", code) + } + status, _ = getMailExport(t, owner, "/api/mail/export?view=unknown") + if status != http.StatusForbidden { + t.Fatalf("unknown export status=%d", status) + } +} + +func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("mailboxId", mailboxID) + _ = writer.WriteField("folder", folder) + for name, content := range files { + part, err := writer.CreateFormFile("files", name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(content); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, client.server.URL+"/api/mail/import", &body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + if client.cookie != nil { + req.AddCookie(client.cookie) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + t.Fatalf("decode import response: %v", err) + } + } + return resp.StatusCode +} + +func getMailExport(t *testing.T, client *testClient, path string) (int, []byte) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, client.server.URL+path, nil) + if err != nil { + t.Fatal(err) + } + if client.cookie != nil { + req.AddCookie(client.cookie) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, body +} diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go index 82140ba..626ac50 100644 --- a/apps/api/internal/app/permissions.go +++ b/apps/api/internal/app/permissions.go @@ -137,12 +137,12 @@ type PermissionGroup struct { } type PermissionLimits struct { - MaxAttachmentMB int `json:"maxAttachmentMb"` - MaxMailboxCount int `json:"maxMailboxCount"` - SMTPDailyLimit int `json:"smtpDailyLimit"` - SMTPMinuteLimit int `json:"smtpMinuteLimit"` - IMAPMinuteLimit int `json:"imapMinuteLimit"` - POP3MinuteLimit int `json:"pop3MinuteLimit"` + MaxAttachmentMB int `json:"maxAttachmentMb"` + MaxMailboxCount int `json:"maxMailboxCount"` + SMTPDailyLimit int `json:"smtpDailyLimit"` + SMTPMinuteLimit int `json:"smtpMinuteLimit"` + IMAPMinuteLimit int `json:"imapMinuteLimit"` + POP3MinuteLimit int `json:"pop3MinuteLimit"` } func defaultPermissionLimits() PermissionLimits { @@ -213,12 +213,12 @@ func encodePermissionLimits(limits PermissionLimits) string { func mergePermissionLimits(left, right PermissionLimits) PermissionLimits { return PermissionLimits{ - MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB), - MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount), - SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit), - SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit), - IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit), - POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit), + MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB), + MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount), + SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit), + SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit), + IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit), + POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit), } } @@ -233,8 +233,8 @@ func mergeLimitValue(left, right int) int { } func minimalLimits() PermissionLimits { - // minimalLimits sets every field to 1 so that mergePermissionLimits - // (which takes the max of each field) produces correct aggregation + // minimalLimits sets every field to 1 so that mergePermissionLimits + // (which takes the max of each field) produces correct aggregation // when no group has a limit set for a given field. return PermissionLimits{ MaxAttachmentMB: 1, @@ -311,20 +311,20 @@ var permissionCatalogItems = []PermissionInfo{ {Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"}, {Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"}, {Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"}, - {Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"}, + {Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"}, - {Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"}, + {Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"}, - {Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"}, - {Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"}, - {Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"}, - {Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"}, - {Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"}, + {Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"}, + {Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"}, + {Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"}, + {Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"}, + {Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"}, - {Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"}, - {Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"}, - {Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"}, - {Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"}, + {Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"}, + {Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"}, + {Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"}, + {Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"}, {Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"}, {Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"}, @@ -334,15 +334,15 @@ var permissionCatalogItems = []PermissionInfo{ {Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"}, {Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"}, - {Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"}, - {Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"}, - {Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"}, - {Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"}, + {Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"}, + {Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"}, + {Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"}, + {Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"}, - {Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"}, - {Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"}, - {Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"}, - {Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"}, + {Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"}, + {Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"}, + {Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"}, + {Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"}, {Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"}, {Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"}, @@ -454,8 +454,8 @@ func defaultPermissionGroups() []PermissionGroup { return []PermissionGroup{ { ID: PermissionGroupSuperAdmin, - Name: "管理员", - Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。", + Name: "管理员", + Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。", Permissions: allPermissionKeys(), Limits: PermissionLimits{}, System: true, diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index 896566d..f5fb7b6 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -565,6 +565,201 @@ func (a *App) handleDeleteRule(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (a *App) handleUpdateRule(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + id := chi.URLParam(r, "id") + item, err := a.ruleByID(r.Context(), user.ID, id) + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "rule not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load rule") + return + } + var req struct { + MailboxID *string `json:"mailboxId"` + Name *string `json:"name"` + MatchMode *string `json:"matchMode"` + Conditions *[]MailRuleCondition `json:"conditions"` + Actions *[]MailRuleAction `json:"actions"` + ApplyExisting *bool `json:"applyToExisting"` + StopProcessing *bool `json:"stopProcessing"` + Enabled *bool `json:"enabled"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if req.MailboxID != nil { + mailboxID, ok := a.optionalMailboxIDForUser(r, *req.MailboxID) + if !ok { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + item.MailboxID = mailboxID + } + if req.Name != nil { + item.Name = strings.TrimSpace(*req.Name) + if item.Name == "" { + item.Name = "收件规则" + } + } + if req.MatchMode != nil { + raw := strings.ToLower(strings.TrimSpace(*req.MatchMode)) + if raw != "all" && raw != "and" && raw != "any" && raw != "or" { + badRequest(w, errors.New("invalid match mode")) + return + } + item.MatchMode = normalizeRuleMatchMode(raw) + } + if req.Conditions != nil { + item.Conditions = normalizeRuleConditions(*req.Conditions, "", "") + if len(item.Conditions) == 0 { + badRequest(w, errors.New("rule condition is required")) + return + } + } + if req.Actions != nil { + item.Actions = normalizeRuleActions(*req.Actions, "") + item.Actions, err = a.cleanRuleActions(r.Context(), user.ID, item.Actions) + if err != nil || len(item.Actions) == 0 { + if err == nil { + err = errors.New("rule action is required") + } + badRequest(w, err) + return + } + } + if req.ApplyExisting != nil { + item.ApplyToExisting = *req.ApplyExisting + } + if req.StopProcessing != nil { + item.StopProcessing = *req.StopProcessing + } + if req.Enabled != nil { + item.Enabled = *req.Enabled + } + conditionsJSON, err := json.Marshal(item.Conditions) + if err != nil { + badRequest(w, err) + return + } + actionsJSON, err := json.Marshal(item.Actions) + if err != nil { + badRequest(w, err) + return + } + item.FromContains = legacyConditionValue(item.Conditions, "from") + item.SubjectContains = legacyConditionValue(item.Conditions, "subject") + item.Action = item.Actions[0].Type + now := a.now().UTC().Format(time.RFC3339Nano) + _, err = a.db.ExecContext(r.Context(), `UPDATE mail_rules SET mailbox_id=?,name=?,match_mode=?,conditions_json=?,actions_json=?,from_contains=?,subject_contains=?,action=?,apply_to_existing=?,stop_processing=?,enabled=?,updated_at=? WHERE id=? AND user_id=?`, item.MailboxID, item.Name, item.MatchMode, string(conditionsJSON), string(actionsJSON), item.FromContains, item.SubjectContains, item.Action, boolInt(item.ApplyToExisting), boolInt(item.StopProcessing), boolInt(item.Enabled), now, id, user.ID) + if err != nil { + badRequest(w, err) + return + } + updated, err := a.ruleByID(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load rule") + return + } + respondJSON(w, http.StatusOK, updated) +} + +func (a *App) handleMoveRule(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + var req struct { + Direction string `json:"direction"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if req.Direction != "up" && req.Direction != "down" { + badRequest(w, errors.New("invalid direction")) + return + } + type orderedRule struct{ id, createdAt string } + rows, err := a.db.QueryContext(r.Context(), `SELECT id,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load rules") + return + } + items := []orderedRule{} + for rows.Next() { + var item orderedRule + if err := rows.Scan(&item.id, &item.createdAt); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan rules") + return + } + items = append(items, item) + } + rows.Close() + index := -1 + for i := range items { + if items[i].id == chi.URLParam(r, "id") { + index = i + break + } + } + if index < 0 { + respondError(w, http.StatusNotFound, "rule not found") + return + } + target := index - 1 + if req.Direction == "down" { + target = index + 1 + } + if target < 0 || target >= len(items) { + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to move rule") + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[target].createdAt, items[index].id, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to move rule") + return + } + if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[index].createdAt, items[target].id, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to move rule") + return + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to move rule") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) handleApplyRule(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + item, err := a.ruleByID(r.Context(), user.ID, chi.URLParam(r, "id")) + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "rule not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load rule") + return + } + affected, err := a.applyRuleToExistingMessages(r.Context(), user.ID, item.MailboxID, item) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to apply rule") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affected": affected}) +} + +func (a *App) ruleByID(ctx context.Context, userID, id string) (MailRule, error) { + return scanRule(a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=? AND user_id=?`, id, userID)) +} + func (a *App) handleListBlockedSenders(w http.ResponseWriter, r *http.Request) { user := currentUser(r) rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? ORDER BY created_at DESC`, user.ID) @@ -1114,7 +1309,7 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr a.moveBlockedMessageToSpam(ctx, messageID, mailboxID) return } - rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID) + rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at DESC`, userID, mailboxID) if err != nil { return } @@ -1620,13 +1815,23 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID if err != nil { return 0, err } - messages := []ruleMessage{} + messageIDs := []string{} var count int64 for rows.Next() { var messageID string if err := rows.Scan(&messageID); err != nil { + rows.Close() return count, err } + messageIDs = append(messageIDs, messageID) + } + if err := rows.Err(); err != nil { + rows.Close() + return count, err + } + rows.Close() + messages := []ruleMessage{} + for _, messageID := range messageIDs { msg, ok := a.ruleMessageByID(ctx, messageID) if !ok { continue @@ -1636,10 +1841,6 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID } messages = append(messages, msg) } - if err := rows.Err(); err != nil { - return count, err - } - rows.Close() for _, msg := range messages { if err := a.applyRuleActions(ctx, msg.MailboxID, msg.ID, rule.Actions); err != nil { return count, err diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index da1e8bb..fb0db53 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -65,6 +65,9 @@ func (a *App) Router() http.Handler { r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature) r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules) r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule) + r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}", a.handleUpdateRule) + r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/move", a.handleMoveRule) + r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/apply", a.handleApplyRule) r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule) r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders) r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender) @@ -99,6 +102,8 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages) + r.With(a.requirePermission(PermissionMailRead)).Get("/mail/export", a.handleExportMail) + r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/import", a.handleImportMail) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage) r.With(a.requirePermission(PermissionMailRead)).Post("/mail/messages/{id}/translate", a.handleTranslateMailMessage) r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts) @@ -131,6 +136,8 @@ func (a *App) Router() http.Handler { r.Group(func(r chi.Router) { r.Use(a.requireAuth) r.Use(a.requireAdminAccess) + r.Get("/admin/system/version", a.handleSystemVersion) + r.Post("/admin/system/update", a.handleSystemUpdate) r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview) r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers) r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser) diff --git a/apps/api/internal/app/system_update_handlers.go b/apps/api/internal/app/system_update_handlers.go new file mode 100644 index 0000000..1700baf --- /dev/null +++ b/apps/api/internal/app/system_update_handlers.go @@ -0,0 +1,282 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var ( + BuildVersion = "dev" + BuildCommit = "" + BuildDate = "" +) + +type systemVersionInfo struct { + CurrentVersion string `json:"currentVersion"` + CurrentCommit string `json:"currentCommit,omitempty"` + BuildDate string `json:"buildDate,omitempty"` + LatestVersion string `json:"latestVersion,omitempty"` + LatestName string `json:"latestName,omitempty"` + ReleaseURL string `json:"releaseUrl,omitempty"` + ReleaseNotes string `json:"releaseNotes,omitempty"` + PublishedAt *time.Time `json:"publishedAt,omitempty"` + UpdateAvailable bool `json:"updateAvailable"` + UpdateEnabled bool `json:"updateEnabled"` + CheckError string `json:"checkError,omitempty"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + PublishedAt time.Time `json:"published_at"` +} + +func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) { + info, err := a.systemVersion(r.Context()) + if err != nil { + info.CheckError = "暂时无法连接版本服务" + a.log.Warn("check system version", "error", err) + } + respondJSON(w, http.StatusOK, info) +} + +func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil || user.Role != "admin" { + respondError(w, http.StatusForbidden, "system administrator required") + return + } + if !a.updateEnabled() { + respondError(w, http.StatusServiceUnavailable, "online update is not configured") + return + } + + info, err := a.systemVersion(r.Context()) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to check latest release") + return + } + if !info.UpdateAvailable { + respondError(w, http.StatusConflict, "already on the latest version") + return + } + + backupPath, err := a.backupDatabaseBeforeUpdate(r.Context()) + if err != nil { + a.log.Error("backup database before update", "error", err) + 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, + "currentVersion": info.CurrentVersion, + "targetVersion": info.LatestVersion, + "message": "更新已启动,服务会在完成后自动恢复", + }) +} + +func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) { + current := strings.TrimSpace(a.cfg.AppVersion) + if current == "" { + current = BuildVersion + } + info := systemVersionInfo{ + CurrentVersion: current, + CurrentCommit: strings.TrimSpace(BuildCommit), + BuildDate: strings.TrimSpace(BuildDate), + UpdateEnabled: a.updateEnabled(), + } + + release, err := a.fetchLatestRelease(ctx) + if err != nil { + return info, err + } + info.LatestVersion = strings.TrimSpace(release.TagName) + info.LatestName = strings.TrimSpace(release.Name) + info.ReleaseURL = strings.TrimSpace(release.HTMLURL) + info.ReleaseNotes = strings.TrimSpace(release.Body) + if !release.PublishedAt.IsZero() { + info.PublishedAt = &release.PublishedAt + } + info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion) + return info, nil +} + +func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) { + endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL) + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return githubRelease{}, errors.New("invalid release API URL") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return githubRelease{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v")) + client := &http.Client{ + Timeout: 8 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return githubRelease{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status) + } + var release githubRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil { + return githubRelease{}, err + } + if strings.TrimSpace(release.TagName) == "" { + return githubRelease{}, errors.New("release API returned an empty tag") + } + return release, nil +} + +func (a *App) updateEnabled() bool { + return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != "" +} + +func (a *App) triggerUpdateService(ctx context.Context) error { + parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return errors.New("invalid update service URL") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken)) + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("update service returned %s", resp.Status) + } + return nil +} + +func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) { + backupDir := filepath.Join(a.cfg.DataDir, "backups") + if err := os.MkdirAll(backupDir, 0o700); err != nil { + return "", err + } + backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db") + quotedPath := strings.ReplaceAll(backupPath, "'", "''") + if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil { + return "", err + } + if err := pruneUpdateBackups(backupDir, 5); err != nil { + a.log.Warn("prune update backups", "error", err) + } + return backupPath, nil +} + +func pruneUpdateBackups(dir string, keep int) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + type backupFile struct { + path string + modTime time.Time + } + backups := make([]backupFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") { + continue + } + info, err := entry.Info() + if err != nil { + return err + } + backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()}) + } + sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) }) + if keep < 0 { + keep = 0 + } + if len(backups) <= keep { + return nil + } + for _, backup := range backups[keep:] { + if err := os.Remove(backup.path); err != nil { + return err + } + } + return nil +} + +var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`) + +func versionIsNewer(latest, current string) bool { + latestParts, latestPrerelease, latestOK := parseVersion(latest) + currentParts, currentPrerelease, currentOK := parseVersion(current) + if !latestOK { + return false + } + if !currentOK { + return true + } + for i := 0; i < len(latestParts); i++ { + if latestParts[i] != currentParts[i] { + return latestParts[i] > currentParts[i] + } + } + return currentPrerelease != "" && latestPrerelease == "" +} + +func parseVersion(value string) ([3]int, string, bool) { + match := versionPattern.FindStringSubmatch(strings.TrimSpace(value)) + if match == nil { + return [3]int{}, "", false + } + var parts [3]int + for i := 0; i < 3; i++ { + if match[i+1] == "" { + continue + } + part, err := strconv.Atoi(match[i+1]) + if err != nil { + return [3]int{}, "", false + } + parts[i] = part + } + return parts, match[4], true +} diff --git a/apps/api/internal/app/system_update_handlers_test.go b/apps/api/internal/app/system_update_handlers_test.go new file mode 100644 index 0000000..57c659e --- /dev/null +++ b/apps/api/internal/app/system_update_handlers_test.go @@ -0,0 +1,164 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestSystemVersionAndUpdate(t *testing.T) { + releaseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"tag_name":"v0.2.0","name":"Version 0.2.0","html_url":"https://example.test/releases/v0.2.0","body":"Release notes","published_at":"2026-08-03T00:00:00Z"}`) + })) + defer releaseServer.Close() + + var updateRequests atomic.Int32 + updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("update method = %s", r.Method) + } + if got := r.Header.Get("Authorization"); got != "Bearer update-secret" { + t.Errorf("authorization = %q", got) + } + updateRequests.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer updateServer.Close() + + dir := t.TempDir() + a := newTestAppWithConfig(t, Config{ + Addr: ":0", + AppVersion: "v0.1.0", + DBPath: filepath.Join(dir, "lanqin.db"), + DataDir: dir, + CookieName: "lanqin_test", + SessionTTLHours: 24, + AdminEmail: "admin@lanqin.local", + AdminPassword: "ChangeMe123!", + PublicHostname: "mail.example.test", + PublicBaseURL: "http://localhost:5173", + AllowInsecureHTTP: true, + ReleaseAPIURL: releaseServer.URL, + UpdateServiceURL: updateServer.URL, + UpdateServiceToken: "update-secret", + }) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK { + t.Fatalf("login code=%d", code) + } + + var version systemVersionInfo + if code := admin.do("GET", "/api/admin/system/version", nil, &version); code != http.StatusOK { + t.Fatalf("version code=%d", code) + } + if version.CurrentVersion != "v0.1.0" || version.LatestVersion != "v0.2.0" || !version.UpdateAvailable || !version.UpdateEnabled { + 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) + } + if updateRequests.Load() != 1 { + t.Fatalf("update requests=%d", updateRequests.Load()) + } + backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db")) + if err != nil || len(backups) != 1 { + t.Fatalf("backups=%v err=%v", backups, err) + } + if info, err := os.Stat(backups[0]); err != nil || info.Size() == 0 { + t.Fatalf("backup stat=%v err=%v", info, err) + } +} + +func TestSystemUpdateRequiresSystemAdministrator(t *testing.T) { + a := newTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/api/admin/system/update", nil) + req = req.WithContext(context.WithValue(req.Context(), userContextKey, &User{ID: "operator", Role: "user"})) + recorder := httptest.NewRecorder() + a.handleSystemUpdate(recorder, req) + if recorder.Code != http.StatusForbidden { + t.Fatalf("code=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestSystemVersionHandlesReleaseFailure(t *testing.T) { + dir := t.TempDir() + a, err := New(Config{ + Addr: ":0", + AppVersion: "v0.1.0", + DBPath: filepath.Join(dir, "lanqin.db"), + DataDir: dir, + CookieName: "lanqin_test", + SessionTTLHours: 24, + AdminEmail: "admin@lanqin.local", + AdminPassword: "ChangeMe123!", + PublicHostname: "mail.example.test", + PublicBaseURL: "http://localhost:5173", + ReleaseAPIURL: "http://127.0.0.1:1/releases/latest", + AllowInsecureHTTP: true, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + defer a.Close() + req := httptest.NewRequest(http.MethodGet, "/api/admin/system/version", nil) + recorder := httptest.NewRecorder() + a.handleSystemVersion(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("code=%d", recorder.Code) + } + var info systemVersionInfo + if err := json.Unmarshal(recorder.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if !strings.Contains(info.CheckError, "版本服务") || info.CurrentVersion != "v0.1.0" { + t.Fatalf("unexpected response: %+v", info) + } +} + +func TestVersionIsNewer(t *testing.T) { + tests := []struct { + latest string + current string + want bool + }{ + {"v0.2.0", "v0.1.9", true}, + {"v1.0.0", "v0.99.99", true}, + {"v1.0.0", "v1.0.0", false}, + {"v1.0.0-beta.1", "v1.0.0", false}, + {"v1.0.0", "v1.0.0-beta.1", true}, + {"v1.0.0+build.2", "v1.0.0+build.1", false}, + {"v1.0.0", "dev", true}, + } + for _, tt := range tests { + if got := versionIsNewer(tt.latest, tt.current); got != tt.want { + t.Errorf("versionIsNewer(%q, %q)=%v want %v", tt.latest, tt.current, got, tt.want) + } + } +} + +func TestPruneUpdateBackupsWithFewerFilesThanLimit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pre-update-one.db") + if err := os.WriteFile(path, []byte("backup"), 0o600); err != nil { + t.Fatal(err) + } + if err := pruneUpdateBackups(dir, 5); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("backup should be retained: %v", err) + } +} diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx index 513673f..205fc34 100644 --- a/apps/web/src/components/protected-layout.tsx +++ b/apps/web/src/components/protected-layout.tsx @@ -7,6 +7,7 @@ import { AuthGuard } from "@/components/auth-guard" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { SystemVersionDialog } from "@/components/system-version-dialog" import { hasAnyPermission } from "@/lib/permissions" import type { PermissionKey } from "@/lib/api-types" import { @@ -66,20 +67,23 @@ function ProtectedContent() { - - - - -
- -
-
- NewSzxcn 邮箱 -
- -
-
-
+
+ + + + +
+ +
+
+ NewSzxcn 邮箱 +
+ +
+
+
+ {isAdminRoute && } +
{isAdminRoute && visibleAdminSections.length > 0 && ( diff --git a/apps/web/src/components/system-version-dialog.tsx b/apps/web/src/components/system-version-dialog.tsx new file mode 100644 index 0000000..71a886d --- /dev/null +++ b/apps/web/src/components/system-version-dialog.tsx @@ -0,0 +1,178 @@ +import * as React from "react" +import { useMutation, useQuery } from "@tanstack/react-query" +import { CheckCircle2, Download, ExternalLink, Loader2, RefreshCcw, TriangleAlert } from "lucide-react" +import { api } from "@/lib/api" +import { cn, formatDate } from "@/lib/utils" +import { useMe } from "@/hooks/use-me" +import { useToast } from "@/hooks/use-toast" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" + +const frontendVersion = import.meta.env.VITE_APP_VERSION || "dev" + +export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "sidebar" | "inline"; className?: string }) { + const me = useMe() + const { toast } = useToast() + const [open, setOpen] = React.useState(false) + const [updatePhase, setUpdatePhase] = React.useState<"idle" | "starting" | "restarting">("idle") + const version = useQuery({ + queryKey: ["admin", "system-version"], + queryFn: api.systemVersion, + staleTime: 5 * 60_000, + retry: 1, + }) + const currentVersion = version.data?.currentVersion || frontendVersion + const isSystemAdmin = me.data?.user.role === "admin" + const update = useMutation({ + mutationFn: async () => { + setUpdatePhase("starting") + const result = await api.updateSystem() + setUpdatePhase("restarting") + await waitForUpdatedService(result.targetVersion) + return result + }, + onError: (error) => { + setUpdatePhase("idle") + toast({ title: "更新失败", description: error.message }) + }, + }) + + const trigger = mode === "inline" ? ( + + ) : ( + + ) + + return ( + + {trigger} + + +
+ 系统版本 + +
+
+ +
+
+
当前版本
+
{currentVersion}
+ {version.data?.latestVersion &&
最新版本:{version.data.latestVersion}
} +
+ + {version.isLoading && } title="正在检查更新" description="正在连接 GitHub Release。" />} + {version.data?.checkError && } title="暂时无法检查更新" description={version.data.checkError} tone="warning" />} + {version.data && !version.data.checkError && !version.data.updateAvailable && } title="已是最新版本" description="当前无需更新。" tone="success" />} + {version.data?.updateAvailable && ( + } + title="发现新版本" + description={`${version.data.latestVersion} 已发布${version.data.publishedAt ? ` · ${formatDate(version.data.publishedAt)}` : ""}`} + tone="warning" + /> + )} + + {version.data?.releaseNotes && ( +
+
更新日志
+
+ {version.data.releaseNotes} +
+
+ )} + + {update.isPending && ( +
+
+ + {updatePhase === "starting" ? "正在准备更新" : "正在重启服务"} +
+
请保持页面打开,服务恢复后会自动刷新。
+
+ )} + + {version.data?.updateAvailable && !version.data.updateEnabled && ( +
+ 当前部署未启用页面更新,请在服务器执行 sudo newszxcn-email update。 +
+ )} +
+ + +
+ {version.data?.releaseUrl && ( + + )} +
+ {version.data?.updateAvailable && version.data.updateEnabled && ( + + )} +
+
+
+ ) +} + +function VersionState({ icon, title, description, tone = "neutral" }: { icon: React.ReactNode; title: string; description: string; tone?: "neutral" | "success" | "warning" }) { + return ( +
+ {icon} + + {title} + {description} + +
+ ) +} + +async function waitForUpdatedService(targetVersion: string) { + const deadline = Date.now() + 8 * 60_000 + while (Date.now() < deadline) { + await delay(3000) + try { + const health = await fetch(`/healthz?update=${Date.now()}`, { cache: "no-store" }) + if (!health.ok) { + continue + } + const response = await fetch(`/api/admin/system/version?update=${Date.now()}`, { credentials: "include", cache: "no-store" }) + if (!response.ok) continue + const body = await response.json() as { currentVersion?: string } + if (body.currentVersion === targetVersion) { + window.location.reload() + return + } + } catch {} + } + throw new Error("更新等待超时,请稍后手动刷新页面检查服务状态") +} + +function delay(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)) +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 43689cc..cf2d2c4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -5,44 +5,44 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 222 47% 11%; + --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; - --card-foreground: 222 47% 11%; + --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; - --popover-foreground: 222 47% 11%; + --popover-foreground: 222.2 84% 4.9%; --primary: 224 44% 12%; --primary-foreground: 0 0% 98%; - --secondary: 213 37% 96%; - --secondary-foreground: 222 47% 11%; - --muted: 213 37% 96%; - --muted-foreground: 216 22% 42%; - --accent: 213 37% 94%; - --accent-foreground: 222 47% 11%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 84% 4.9%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 84% 4.9%; --destructive: 358 88% 61%; --destructive-foreground: 0 0% 98%; - --border: 214 32% 90%; - --input: 214 32% 86%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; --ring: 216 22% 42%; --radius: 0.5rem; --sidebar-background: 0 0% 100%; - --sidebar-foreground: 222 47% 11%; + --sidebar-foreground: 222.2 84% 4.9%; --sidebar-primary: 224 44% 12%; --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 213 37% 94%; - --sidebar-accent-foreground: 222 47% 11%; - --sidebar-border: 214 32% 90%; - --sidebar-ring: 216 22% 42%; + --sidebar-accent: 210 40% 96.1%; + --sidebar-accent-foreground: 222.2 84% 4.9%; + --sidebar-border: 214.3 31.8% 91.4%; + --sidebar-ring: 215.4 16.3% 46.9%; } * { @apply border-border; } html { color-scheme: light; - font-size: 15px; + font-size: 16px; } body { @apply bg-background text-foreground antialiased; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; - font-size: 13px; + font-size: 16px; } html, body, #root { min-height: 100%; diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index a111a76..651d4d7 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -183,6 +183,25 @@ export type MaildirSyncHealth = { recentErrors: string[] summary: MaildirSyncCounts } +export type SystemVersion = { + currentVersion: string + currentCommit?: string + buildDate?: string + latestVersion?: string + latestName?: string + releaseUrl?: string + releaseNotes?: string + publishedAt?: string + updateAvailable: boolean + updateEnabled: boolean + checkError?: string +} +export type SystemUpdateResult = { + ok: boolean + currentVersion: string + targetVersion: string + message: string +} export type SystemSettings = { publicHostname: string publicBaseUrl: string diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ca8a525..2205ee8 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -69,6 +69,35 @@ async function request(path: string, init: RequestInit & { timeoutMs?: number } } +async function requestFile(path: string): Promise { + 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(path: string, form: FormData): Promise { + 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) { + let message = `${res.status} ${res.statusText}` + try { const body = await res.json(); message = body.error || message } catch {} + throw new Error(message) + } + return res.json() as Promise + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw new Error("导入超时,请缩小文件后重试") + throw error instanceof Error ? error : new Error("网络请求失败") + } finally { + window.clearTimeout(timeout) + } +} + export const api = { publicSettings: () => request("/api/public/settings"), register: (payload: RegisterPayload) => request<{ user: User }>("/api/auth/register", { method: "POST", body: JSON.stringify(payload) }), @@ -95,6 +124,9 @@ export const api = { defaultSignature: (mailboxId?: string) => request<{ signature: MailSignature | null }>(`/api/me/signatures/default${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), rules: () => request>("/api/me/rules"), createRule: (payload: { mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }) => request("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }), + updateRule: (id: string, payload: Partial<{ mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }>) => request(`/api/me/rules/${id}`, { method: "POST", body: JSON.stringify(payload) }), + moveRule: (id: string, direction: "up" | "down") => request<{ ok: boolean }>(`/api/me/rules/${id}/move`, { method: "POST", body: JSON.stringify({ direction }) }), + applyRule: (id: string) => request<{ ok: boolean; affected: number }>(`/api/me/rules/${id}/apply`, { method: "POST" }), deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }), blockedSenders: () => request>("/api/me/blocked-senders"), createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }), @@ -168,6 +200,8 @@ export const api = { const suffix = query.toString() return request>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`) }, + systemVersion: () => request("/api/admin/system/version"), + updateSystem: () => request("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }), systemSettings: () => request("/api/admin/settings"), maildirSyncHealth: () => request("/api/admin/maildir-sync/health"), updateSystemSettings: (payload: SystemSettingsPayload) => request("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), @@ -220,6 +254,20 @@ export const api = { if (mailboxId) params.set("mailboxId", mailboxId) return request>(`/api/mail/starred?${params.toString()}`) }, + exportMail: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: 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()}`) + }, + importMail: (files: File[], payload: { mailboxId: string; folder: string }) => { + const form = new FormData() + form.set("mailboxId", payload.mailboxId) + form.set("folder", payload.folder) + files.forEach((file) => form.append("files", file)) + return uploadForm<{ ok: boolean; imported: number; skipped: number; errors: string[] }>("/api/mail/import", form) + }, message: (id: string, options: { markRead?: boolean } = {}) => request(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`), translateMessage: (id: string, targetLanguage: string) => request(`/api/mail/messages/${id}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), translateExternalMessage: (id: string, remoteId: string, targetLanguage: string) => request(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index a3558d0..ade0f62 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -2,7 +2,7 @@ import * as React from "react" import DOMPurify from "dompurify" import { useSearchParams } from "react-router-dom" import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mail, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react" +import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react" import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -20,6 +20,7 @@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Textarea } from "@/components/ui/textarea" import { ConfirmDialog } from "@/components/confirm-dialog" +import { SystemVersionDialog } from "@/components/system-version-dialog" import { useMe } from "@/hooks/use-me" import { useToast } from "@/hooks/use-toast" import { hasAnyPermission, hasPermission } from "@/lib/permissions" @@ -54,13 +55,13 @@ const sectionPermissions: Record = { } const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email" const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5" -const projectTag = import.meta.env.VITE_APP_VERSION || "" -const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "") const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 } const defaultMailboxLimitOverride = 9 const accountLoginName = (user: Pick) => user.loginName || user.email export function AdminPage() { + const qc = useQueryClient() + const { toast } = useToast() const me = useMe() const user = me.data?.user const canOverview = hasPermission(user, "admin.overview.view") @@ -81,6 +82,7 @@ export function AdminPage() { const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView }) const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView }) const [params, setParams] = useSearchParams() + const [refreshing, setRefreshing] = React.useState(false) const domainItems = domains.data?.items || [] const mailboxItems = mailboxes.data?.items || [] @@ -91,10 +93,27 @@ export function AdminPage() { const rawSection = params.get("section") as Section | null const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview" + async function refreshAdminPage() { + if (refreshing) return + setRefreshing(true) + try { + await Promise.all([ + qc.invalidateQueries({ queryKey: ["admin"] }), + qc.invalidateQueries({ queryKey: ["mailboxes"] }), + qc.invalidateQueries({ queryKey: ["me"] }), + ]) + toast({ title: "后台数据已刷新" }) + } catch (error) { + toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" }) + } finally { + setRefreshing(false) + } + } + return (
- + {section === "overview" && canOverview && (
@@ -111,7 +130,7 @@ export function AdminPage() { {section === "domains" && } {section === "mailboxes" && } {section === "aliases" && } - {section === "messages" && } + {section === "messages" && } {section === "sendAudit" && } {section === "settings" && }
@@ -119,7 +138,7 @@ export function AdminPage() { ) } -function AdminPageHeader({ section }: { section: Section }) { +function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; refreshing: boolean; onRefresh: () => void }) { const meta = sectionMeta[section] return (
@@ -133,7 +152,12 @@ function AdminPageHeader({ section }: { section: Section }) {

{meta.label}

{meta.description}

- NewSzxcn +
+ + NewSzxcn +
) @@ -756,8 +780,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai ) } -function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) { - const qc = useQueryClient() +function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxType[]; systemAdmin: boolean }) { const [query, setQuery] = React.useState("") const [mailboxId, setMailboxId] = React.useState("all") const [folder, setFolder] = React.useState("all") @@ -780,8 +803,8 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
全部邮件 -
@@ -795,7 +818,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) { 全部邮箱 - 未注册收件 + {systemAdmin && 未知收件} {mailboxes.map((mailbox) => {mailbox.address})} @@ -808,7 +831,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) { 归档 垃圾邮件 回收站 - 未注册收件 + {systemAdmin && 未知收件} @@ -884,7 +907,6 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) { } function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) { - const qc = useQueryClient() const [mailboxId, setMailboxId] = React.useState("all") const [event, setEvent] = React.useState("all") const [messageId, setMessageId] = React.useState("") @@ -909,8 +931,8 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
发送队列 -
@@ -1391,47 +1413,7 @@ function queryErrorMessage(error: unknown) { return error instanceof Error ? error.message : "读取 Maildir 同步健康失败" } -function parseSemver(tag: string): number[] { - return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number) -} - function AboutProjectCard() { - const { toast } = useToast() - const latestRelease = useQuery({ - queryKey: ["github", "latest-release"], - queryFn: async () => { - const res = await fetch("https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest") - if (!res.ok) throw new Error("rate limited or unavailable") - return res.json() as Promise<{ tag_name: string; html_url: string }> - }, - enabled: !!projectTag, - staleTime: 1000 * 60 * 60, // 1 hour - retry: 1, - }) - const updateAvailable = React.useMemo(() => { - if (!projectTag || !latestRelease.data) return false - const current = parseSemver(projectTag) - const latest = parseSemver(latestRelease.data.tag_name) - for (let i = 0; i < Math.max(current.length, latest.length); i++) { - const a = current[i] ?? 0 - const b = latest[i] ?? 0 - if (b > a) return true - if (a > b) return false - } - return false - }, [projectTag, latestRelease.data]) - - React.useEffect(() => { - if (updateAvailable && latestRelease.data) { - toast({ - title: "发现新版本", - description: `${latestRelease.data.tag_name} 已可用,点击版本号查看详情。`, - }) - } - // Only toast once on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [updateAvailable]) - return ( @@ -1439,32 +1421,7 @@ function AboutProjectCard() { - {projectTag ? ( -
- - {updateAvailable && latestRelease.data && ( - - )} - {latestRelease.isLoading && ( - 检查更新中... - )} -
- ) : ( - - )} +
diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index e543ab2..c2f289e 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -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, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, 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, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react" import { api, ExternalImapAccount, ExternalImapFolder, 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" @@ -60,7 +60,7 @@ const folderLabels: Record = { type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean } type MailFilter = "all" | "unread" | "starred" | "attachments" | "recent7" -type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external" +type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external" | "unknown" type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type MailNotificationState = { latestId: string; latestReceivedAt: string } @@ -75,6 +75,7 @@ type MailMenuItem = | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number; order: number } + | { type: "unknown"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "folder"; key: string; folderId: string; folderName: string; label: string; icon: React.ReactNode; count: number; custom: boolean; order: number } const filterLabels: Record = { @@ -137,6 +138,8 @@ export function MailPage() { const compactMailLayout = isMobile || isNarrowMailViewport || displayMode === "compact" const [refreshing, setRefreshing] = React.useState(false) const [autoRefreshing, setAutoRefreshing] = React.useState(false) + const [exportingMail, setExportingMail] = React.useState(false) + const [importingMail, setImportingMail] = React.useState(false) const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState(null) const [bulkPending, setBulkPending] = React.useState(false) const [pendingConfirm, setPendingConfirm] = React.useState(null) @@ -159,6 +162,7 @@ export function MailPage() { const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) const mailAudioContextRef = React.useRef(null) + const mailImportInputRef = React.useRef(null) const user = me.data?.user const canAccessMail = hasPermission(user, "mail.access") const canReadMail = hasPermission(user, "mail.messages.read") @@ -169,6 +173,7 @@ export function MailPage() { const canManageLabels = hasPermission(user, "mail.labels.manage") const canDownloadAttachments = hasPermission(user, "mail.attachments.download") const canManageSignatures = hasPermission(user, "mail.signatures.manage") + const canViewUnknownMail = user?.role === "admin" const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false @@ -234,7 +239,18 @@ export function MailPage() { }, initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, - enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId), + enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && mailView !== "unknown" && (mailView !== "label" || !!selectedLabelId), + }) + const unknownMessages = useInfiniteQuery({ + queryKey: ["admin", "unknown-messages", query], + queryFn: ({ pageParam }) => api.adminMessages({ + mailboxId: "unregistered", + q: query.trim(), + cursor: typeof pageParam === "string" ? pageParam : "", + }), + initialPageParam: "", + getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, + enabled: canViewUnknownMail && mailView === "unknown", }) const externalMessages = useInfiniteQuery({ queryKey: ["external-messages", selectedExternalAccountId, externalFolder, query], @@ -243,7 +259,11 @@ export function MailPage() { getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, enabled: !!selectedExternalAccountId && canReadMail && mailView === "external" && externalImapEnabled, }) - const detail = useQuery({ queryKey: ["message", selectedId, mailView, selectedExternalAccountId], queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) }) + const detail = useQuery({ + queryKey: ["message", selectedId, mailView, selectedExternalAccountId], + queryFn: () => mailView === "external" ? api.externalMessage(selectedExternalAccountId, selectedId!) : mailView === "unknown" ? api.adminMessage(selectedId!) : api.message(selectedId!, { markRead: false }), + enabled: !!selectedId && canReadMail && (mailView !== "external" || (!!selectedExternalAccountId && externalImapEnabled)) && (mailView !== "unknown" || canViewUnknownMail), + }) function updateCachedMessage(id: string, patch: Partial) { qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current) qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData | undefined) => { @@ -567,6 +587,8 @@ export function MailPage() { React.useEffect(() => { const events = new EventSource("/api/events", { withCredentials: true }) events.addEventListener("sync", () => { + qc.invalidateQueries({ queryKey: ["messages"] }) + qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }) qc.invalidateQueries({ queryKey: ["folders"] }) qc.invalidateQueries({ queryKey: ["mail-stats"] }) qc.invalidateQueries({ queryKey: ["labels"] }) @@ -581,6 +603,9 @@ export function MailPage() { setAutoRefreshing(true) Promise.all([ qc.invalidateQueries({ queryKey: ["messages"] }), + qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }), + qc.invalidateQueries({ queryKey: ["external-messages"] }), + qc.invalidateQueries({ queryKey: ["mail-external-folders"] }), qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), @@ -596,7 +621,7 @@ export function MailPage() { }, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc]) const selected = detail.data - const allMessages = (mailView === "external" ? externalMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || [] + const allMessages = (mailView === "external" ? externalMessages.data?.pages : mailView === "unknown" ? unknownMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || [] const visibleMessages = allMessages.filter((message) => { if (!messageMatchesAdvancedSearch(message, advancedSearch)) return false if (mailFilter === "unread") return !message.isRead @@ -621,7 +646,7 @@ export function MailPage() { const sendQueueItems = sendQueue.data?.items || [] const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length const visibleSendQueueItems = sendQueueItems - const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue) + const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue, canViewUnknownMail) const primaryMailMenuItems = mailMenuItems.filter((item) => !isCustomMenuFolder(item)) const customMailMenuItems = mailMenuItems.filter(isCustomMenuFolder) const canOrganizeCurrentMailbox = canOrganizeMail && !isAllMailboxSelected @@ -630,7 +655,10 @@ export function MailPage() { const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : [] const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) - const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder + const viewTitle = mailView === "external" ? `${selectedExternalAccount?.name || "外部邮箱"} · ${folderLabels[externalFolder] || externalFolder}` : mailView === "unknown" ? "未知收件" : mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder + const isTransferView = mailView === "folder" || mailView === "starred" || mailView === "label" || mailView === "unknown" + const canExportCurrentView = canReadMail && isTransferView + const canImportCurrentView = canOrganizeMail && isTransferView && mailView !== "unknown" && !!selectedMailbox const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length) const visibleMessageIds = visibleMessages.map((message) => message.id) const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length @@ -638,8 +666,11 @@ export function MailPage() { const bulkReadAction: BulkAction = selectedMessagesOnPage.some((message) => !message.isRead) ? "read" : "unread" const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected - const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : !!messages.hasNextPage - const canLoadMore = mailView === "external" ? !!externalMessages.hasNextPage && !externalMessages.isFetchingNextPage : !!messages.hasNextPage && !messages.isFetchingNextPage + const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading + const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage + const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage + const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore + const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage() function toggleCompactSelectAll(checked: boolean) { setCompactSelectedIds(checked ? visibleMessageIds : []) } @@ -649,6 +680,7 @@ export function MailPage() { async function refreshMailData() { await Promise.all([ qc.invalidateQueries({ queryKey: ["messages"] }), + qc.invalidateQueries({ queryKey: ["admin", "unknown-messages"] }), qc.invalidateQueries({ queryKey: ["external-messages"] }), qc.invalidateQueries({ queryKey: ["mail-external-folders"] }), qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }), @@ -806,8 +838,17 @@ export function MailPage() { setMailFilter("all") setMobileSidebarOpen(false) } + function openUnknownMail() { + if (!canViewUnknownMail) return + setSelectedExternalAccountId("") + setMailView("unknown") + setSelectedLabelId("") + setSelectedId(null) + setMailFilter("all") + setMobileSidebarOpen(false) + } function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) { - if (mailView === "external") return + if (mailView === "external" || mailView === "unknown") return event.preventDefault() event.stopPropagation() if (message.folder !== "Drafts") setSelectedId(message.id) @@ -828,6 +869,7 @@ export function MailPage() { if (item.type === "starred") openStarred() else if (item.type === "scheduled") openScheduled() else if (item.type === "sendQueue") openSendQueue() + else if (item.type === "unknown") openUnknownMail() else openFolder(item.folderName) } function openExternalFolder(account: ExternalImapAccount, folderName = "INBOX") { @@ -1011,20 +1053,76 @@ export function MailPage() { return } setSelectedId(messageId) - if (message && !message.isRead && canOrganizeMail) { + if (message && !message.isRead && canOrganizeMail && mailView !== "unknown") { if (mailView === "external" && selectedExternalAccountId) markExternalRead.mutate({ id: selectedExternalAccountId, remoteId: message.id, read: true }) else markRead.mutate({ id: message.id, read: true }) } } async function refreshMail() { + if (refreshing || autoRefreshing) return setRefreshing(true) try { await refreshMailData() - setLastAutoRefreshAt(new Date()) + const refreshedAt = new Date() + setLastAutoRefreshAt(refreshedAt) + toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` }) + } catch (error) { + toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" }) } finally { setRefreshing(false) } } + 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) + } + } + function chooseMailImport() { + if (!canImportCurrentView || importingMail) { + if (isAllMailboxSelected) toast({ title: "请先选择一个邮箱", description: "导入邮件需要明确目标邮箱。" }) + return + } + mailImportInputRef.current?.click() + } + async function importSelectedMailFiles(event: React.ChangeEvent) { + const files = Array.from(event.target.files || []) + event.target.value = "" + if (files.length === 0 || !selectedMailbox) return + setImportingMail(true) + try { + const result = await api.importMail(files, { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" }) + await refreshMailData() + toast({ + title: `已导入 ${result.imported} 封邮件`, + description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`, + }) + } catch (error) { + toast({ title: "导入失败", description: error instanceof Error ? error.message : "请检查 EML/MBOX 文件" }) + } finally { + setImportingMail(false) + } + } async function copyCurrentMailbox() { if (!selectedMailbox?.address) return await navigator.clipboard.writeText(selectedMailbox.address) @@ -1139,7 +1237,7 @@ export function MailPage() { onContextMenu={(event) => openSidebarContextMenu(event, item)} > ) + const mailTransferTools = isTransferView ? ( +
+ + {mailView !== "unknown" && ( + + )} + +
+ ) : null + const contentView = !canAccessMail ? ( ) : !canReadMail ? ( - ) : !mailboxList.isLoading && !hasMailboxes ? ( + ) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? ( ) : mailView === "scheduled" && canScheduleMail ? ( mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()} + loadingMore={mailMessagesLoadingMore} + onLoadMore={loadMoreMessages} emptyMessage={emptyMessage} selectedId={selectedId} selected={selected} @@ -1429,10 +1543,11 @@ export function MailPage() { onBulkAction={runBulkAction} onContextMenu={openMessageContextMenu} canSend={canSendMail} - canOrganize={canOrganizeMail && mailView !== "external"} - canManageLabels={canManageLabels && mailView !== "external"} + canOrganize={canOrganizeMail && mailView !== "external" && mailView !== "unknown"} + canManageLabels={canManageLabels && mailView !== "external" && mailView !== "unknown"} canDownloadAttachments={canDownloadAttachments} language={language} + tools={!isMobile ? mailTransferTools : undefined} /> ) : (
@@ -1488,12 +1603,8 @@ export function MailPage() {
-

{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}

-
- -
+

{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}

+ {mailTransferTools}
@@ -1501,20 +1612,20 @@ export function MailPage() {
- {selectedCountOnPage > 0 && canOrganizeMail && ( + {selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
已选 {selectedCountOnPage} 封
)} - {(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && } - {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail} />)} - {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && visibleMessages.length === 0 &&
{emptyMessage}
} - {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && hasMoreMessages && ( + {mailMessagesLoading && } + {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)} + {!mailMessagesLoading && visibleMessages.length === 0 &&
{emptyMessage}
} + {!mailMessagesLoading && hasMoreMessages && (
-
)} @@ -1546,18 +1657,18 @@ export function MailPage() {
{canSendMail && } {canSendMail && } - {mailView !== "external" && selected.sendQueueId && } - {mailView !== "external" && canOrganizeMail && (selected.folder === "Archive" ? ( + {mailView !== "external" && mailView !== "unknown" && selected.sendQueueId && } + {mailView !== "external" && mailView !== "unknown" && canOrganizeMail && (selected.folder === "Archive" ? ( ) : ( ))} - {mailView !== "external" && canOrganizeMail && } + {mailView !== "external" && mailView !== "unknown" && canOrganizeMail && }
addLabel.mutate({ id: selected.id, label }), onRemoveLabel: (labelId: string) => removeLabel.mutate({ id: selected.id, labelId }), labelPending: addLabel.isPending || removeLabel.isPending } : {})} + {...(canManageLabels && mailView !== "unknown" ? { availableLabels: labelItems, onAddLabel: (label: MailLabel) => addLabel.mutate({ id: selected.id, label }), onRemoveLabel: (labelId: string) => removeLabel.mutate({ id: selected.id, labelId }), labelPending: addLabel.isPending || removeLabel.isPending } : {})} /> @@ -1588,10 +1699,8 @@ export function MailPage() {
{sidebarContent}
-
{mailView === "label" && selectedLabel ? {selectedLabel.name} : viewTitle}
+ {mailTransferTools} {canSendMail && }
@@ -1603,9 +1712,9 @@ export function MailPage() {
) : (
- +
{contentView}
@@ -1614,6 +1723,7 @@ export function MailPage() {
{ setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} /> + [item.name, item])) const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 }) for (const item of folders) { @@ -1702,11 +1812,13 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul custom: isCustomMailFolder(item), order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name), })) - const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount, order: 2000 } - const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "稍后提醒", icon: , count: scheduledCount, order: 6000 } - const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount, order: 9000 } + const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount, order: 6000 } + const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "稍后提醒", icon: , count: scheduledCount, order: 7000 } + const unknownItem: MailMenuItem = { type: "unknown", key: "unknown", label: "未知收件", icon: , count: 0, order: 8000 } + const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount, order: 10000 } const specialItems: MailMenuItem[] = [starredItem] if (includeScheduled) specialItems.push(scheduledItem) + if (includeUnknown) specialItems.push(unknownItem) if (includeSendQueue && sendQueueCount > 0) specialItems.push(sendQueueItem) return [...folderItems, ...specialItems].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)) } @@ -1840,11 +1952,11 @@ function searchTextMatches(values: string[], needle: string) { function menuAnchorOrder(name: string) { switch (name) { case "Inbox": return 1000 - case "Drafts": return 3000 - case "Sent": return 4000 - case "Archive": return 5000 - case "Trash": return 7000 - case "Spam": return 8000 + case "Drafts": return 2000 + case "Sent": return 3000 + case "Archive": return 4000 + case "Trash": return 5000 + case "Spam": return 9000 default: return 100000 } } @@ -1912,9 +2024,9 @@ function SearchFilterChip({ label, onRemove }: { label: string; onRemove: () => return ( {label} - + ) } @@ -1924,6 +2036,7 @@ function MessageSkeleton() { return
{Array.from({ len function getEmptyMessage(mailView: MailView, folder: string, total: number) { if (mailView === "external") return total === 0 ? "远端文件夹没有邮件" : "当前筛选条件下没有远端邮件" + if (mailView === "unknown") return total === 0 ? "暂无未知收件" : "当前筛选条件下没有邮件" if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件" if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务" if (total > 0) return "当前筛选条件下没有邮件" @@ -2059,6 +2172,7 @@ function attachmentHref(message: MailMessage, attachmentId: string) { if (message.externalAccountId) { return `/api/mail/external-accounts/${encodeURIComponent(message.externalAccountId)}/attachments/${encodeURIComponent(message.id)}/${encodeURIComponent(attachmentId)}` } + if (!message.mailboxId) return `/api/admin/attachments/${encodeURIComponent(attachmentId)}` return `/api/mail/attachments/${attachmentId}` } @@ -2571,6 +2685,7 @@ function CompactMailView({ canManageLabels, canDownloadAttachments, language, + tools, }: { title: string icon?: React.ReactNode @@ -2611,6 +2726,7 @@ function CompactMailView({ canManageLabels: boolean canDownloadAttachments: boolean language: Language + tools?: React.ReactNode }) { const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1 const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined @@ -2662,7 +2778,10 @@ function CompactMailView({ {canOrganize && } ) : ( -
{messages.length} / {total} 封
+
+
{messages.length} / {total} 封
+ {tools} +
)}
@@ -2829,7 +2948,7 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan const { toast } = useToast() const targetLanguage = normalizeTranslationLanguage(language) const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet]) - const shouldShow = targetLanguage && shouldOfferMessageTranslation(sourceText, language) + const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language) const translatedMessage = React.useMemo(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText]) const translate = useMutation({ mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!), diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index c6ffb97..b24768c 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate, useSearchParams } from "react-router-dom" -import { ArrowLeft, BarChart3, Ban, BookOpen, ChevronDown, Clock3, Code2, Contact, Copy, ExternalLink, HardDrive, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react" +import { ArrowLeft, BarChart3, Ban, Bell, BellOff, BookOpen, ChevronDown, ChevronUp, Clock3, Code2, Contact, Copy, HardDrive, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, PlayCircle, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, Users, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" @@ -24,6 +24,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" import { ScrollArea } from "@/components/ui/scroll-area" import { ConfirmDialog } from "@/components/confirm-dialog" @@ -52,7 +53,6 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [ { key: "security", label: "安全" }, ] const actionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "邮件转发" } - export function ProfilePage() { const me = useMe() const qc = useQueryClient() @@ -107,6 +107,8 @@ export function ProfilePage() { 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 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 selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId]) const activeMailboxId = selectedMailbox?.id || "" @@ -211,6 +213,27 @@ export function ProfilePage() { onError: (error) => toast({ title: "保存失败", description: error.message }), }) const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } }) + const updateRule = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => api.updateRule(id, payload), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) }, + onError: (error) => toast({ title: "更新失败", description: error.message }), + }) + const moveRule = useMutation({ + mutationFn: ({ id, direction }: { id: string; direction: "up" | "down" }) => api.moveRule(id, direction), + onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), + onError: (error) => toast({ title: "排序失败", description: error.message }), + }) + const applyRule = useMutation({ + mutationFn: api.applyRule, + onSuccess: (res) => { + qc.invalidateQueries({ queryKey: ["messages"] }) + qc.invalidateQueries({ queryKey: ["folders"] }) + qc.invalidateQueries({ queryKey: ["mail-stats"] }) + qc.invalidateQueries({ queryKey: ["labels"] }) + toast({ title: `规则已应用到 ${res.affected} 封现有邮件` }) + }, + onError: (error) => toast({ title: "应用失败", description: error.message }), + }) const createBlocked = useMutation({ mutationFn: (form: FormData) => api.createBlockedSender({ mailboxId: blockedMailboxId === "all" ? "" : blockedMailboxId, email: String(form.get("email") || ""), reason: String(form.get("reason") || "") }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) }, @@ -315,7 +338,7 @@ export function ProfilePage() { if (me.isError || !user) return
登录状态已失效
const sidebarContent = ( - + ) const pageTitle = tab === "feedback" ? "反馈与工单" : tabs[tab].label + const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined + const pageAction = tab === "stats" + ? + : tab === "apiTokens" + ? + : undefined return (
@@ -367,9 +396,9 @@ export function ProfilePage() { -
- -
{renderTab()}
+
+ +
{renderTab()}
@@ -377,9 +406,9 @@ export function ProfilePage() {
{sidebarContent}
-
- -
{renderTab()}
+
+ +
{renderTab()}
@@ -449,11 +478,10 @@ export function ProfilePage() { onSyncExternalFolder={(id, folder) => syncExternalImapFolder.mutate({ id, folder })} /> ) - if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "cleanup") return cleanup.mutate(target)} /> if (tab === "cleanupQueue") return - if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> + if (tab === "rules") return 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 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 stats.refetch()} /> if (tab === "feedback") return @@ -462,12 +490,29 @@ export function ProfilePage() { } } -function SettingsPageHeader({ title, activeTab, onAccountTabChange }: { title: string; activeTab?: AccountSettingsTab; onAccountTabChange: (tab: AccountSettingsTab) => void }) { +function contentFrameClass(tab: Tab) { + return cn( + "w-full", + tab === "mailboxes" ? "pt-[34px]" : "pt-6", + tab === "profile" || tab === "mailboxes" ? "mx-auto max-w-[896px]" : + tab === "stats" ? "px-4 sm:px-6" : + tab === "rules" || tab === "apiTokens" ? "mx-auto max-w-[896px] px-4 sm:px-0" : + "mx-auto max-w-[1024px] px-4 sm:px-0", + ) +} + +function SettingsPageHeader({ title, subtitle, action, activeTab, onAccountTabChange }: { title: string; subtitle?: string; action?: React.ReactNode; activeTab?: AccountSettingsTab; onAccountTabChange: (tab: AccountSettingsTab) => void }) { return ( -
-

{title}

+
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action &&
{action}
} +
{activeTab && ( -
+
{accountSettingTabs.map((item) => ( + ))} +
+ ) +} + type AccountSettingsSectionProps = { activeTab: AccountSettingsTab user: { id: string; email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits } @@ -602,12 +669,17 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
- +
@@ -754,7 +826,7 @@ function MailPreferencesSection({
- setLabelColor(event.target.value)} className="h-10 w-12 cursor-pointer rounded-md border border-input bg-background p-1" aria-label="标签颜色" /> + setLabelColor(event.target.value)} className="h-10 w-12 cursor-pointer bg-background p-1" aria-label="标签颜色" />
@@ -762,9 +834,9 @@ function MailPreferencesSection({ {label.name} - + ))} {!labelsLoading && labels.length === 0 && 暂无标签} @@ -1130,11 +1202,7 @@ function writeFeedbackTickets(items: FeedbackTicket[]) { } function SwitchButton({ checked, onClick }: { checked: boolean; onClick: () => void }) { - return ( - - ) + return } function readLocalString(key: string) { @@ -2313,29 +2381,19 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet } return ( -
- - - 创建密钥} - contentClassName="space-y-3" - > +
+
+
+

API 密钥

+ +
+
{createdToken && ( -
+
只显示一次
{createdToken} - +
)} @@ -2343,11 +2401,11 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet {items.map((item) => { const expired = item.expiresAt ? new Date(item.expiresAt).getTime() <= Date.now() : false return ( -
+
{item.name}
- {item.disabled ? "已禁用" : expired ? "已过期" : "可用"} + {item.disabled ? "已禁用" : expired ? "已过期" : "可用"}
创建:{formatDateTime(item.createdAt)} @@ -2355,20 +2413,21 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet 最后使用:{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"}
- {(item.scopes || ["*"]).map((scope) => {scope})} + {(item.scopes || ["*"]).map((scope) => {scope})}
- - - + + +
) })} - {!loading && items.length === 0 && } text="暂无 API 密钥" description="点击上方按钮创建" action={} />} - {loading && items.length === 0 && } text="正在加载 API 密钥" />} - + {!loading && items.length === 0 &&
暂无 API 密钥,点击上方按钮创建
} + {loading && items.length === 0 &&
正在加载 API 密钥
} +
+
@@ -2610,22 +2669,29 @@ const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[ const commonRuleFolders = ["Inbox", "Archive", "Spam", "Trash"] const ruleActionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到", forward: "邮件转发" } -function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) { +function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenChange, onCreate, onUpdate, onToggle, onMove, onApply, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onUpdate: (id: string, payload: RuleCreatePayload) => void; onToggle: (item: MailRule) => void; onMove: (id: string, direction: "up" | "down") => void; onApply: (id: string) => void; onDelete: (id: string) => void; pending: boolean }) { + const [editingRule, setEditingRule] = React.useState(null) + + function setDialogOpen(next: boolean) { + if (!next) setEditingRule(null) + onOpenChange(next) + } + return ( -
+
- +
-
- {items.map((item) => )} - {items.length === 0 && } text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="border-solid bg-card" />} +
+ {items.map((item, index) => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)} + {items.length === 0 && } text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
- + editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
) } -function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }: { open: boolean; onOpenChange: (open: boolean) => void; mailboxes: Mailbox[]; labels: MailLabel[]; pending: boolean; onCreate: (payload: RuleCreatePayload) => void }) { +function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pending, initialRule, onSave }: { open: boolean; onOpenChange: (open: boolean) => void; mailboxes: Mailbox[]; labels: MailLabel[]; verifiedEmails: string[]; pending: boolean; initialRule: MailRule | null; onSave: (payload: RuleCreatePayload) => void }) { const [name, setName] = React.useState("我的规则") const [mailboxId, setMailboxId] = React.useState("all") const [matchMode, setMatchMode] = React.useState<"all" | "any">("all") @@ -2640,15 +2706,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate } React.useEffect(() => { if (!open) return - setName("我的规则") - setMailboxId("all") - setMatchMode("all") - setConditions([{ field: "to", operator: "contains", value: "" }]) - setActions([{ type: "forward", value: "" }]) - setEnabled(true) - setApplyToExisting(false) - setStopProcessing(false) - }, [open, labels]) + setName(initialRule?.name || "我的规则") + setMailboxId(initialRule?.mailboxId || "all") + setMatchMode(initialRule?.matchMode || "all") + setConditions(initialRule?.conditions.length ? initialRule.conditions : [{ field: "to", operator: "contains", value: "" }]) + setActions(initialRule?.actions.length ? initialRule.actions : [{ type: "forward", value: "" }]) + setEnabled(initialRule?.enabled ?? true) + setApplyToExisting(initialRule?.applyToExisting ?? false) + setStopProcessing(initialRule?.stopProcessing ?? false) + }, [initialRule, open]) function updateCondition(index: number, patch: Partial) { setConditions((items) => items.map((item, i) => { @@ -2669,20 +2735,20 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate } function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) } const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value) - const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value).filter((item) => item.type !== "forward" || item.value) + const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).map((item) => item.type === "forward" ? { ...item, value: verifiedRuleForwardTargets(item.value || "", verifiedEmails).join(", ") } : item).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value).filter((item) => item.type !== "forward" || item.value) const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending function submit(event: React.FormEvent) { event.preventDefault() if (!canCreate) return - onCreate({ mailboxId: selectedMailboxId, name: name.trim() || "我的规则", matchMode, conditions: validConditions, actions: validActions, applyToExisting, stopProcessing, enabled }) + onSave({ mailboxId: selectedMailboxId, name: name.trim() || "我的规则", matchMode, conditions: validConditions, actions: validActions, applyToExisting, stopProcessing, enabled }) } return ( - 新建规则 + {initialRule ? "编辑规则" : "新建规则"}
@@ -2725,7 +2791,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate } {(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => {ruleActionLabels[value]})} - updateAction(index, patch)} /> + updateAction(index, patch)} /> {action.type !== "forward" && }
@@ -2746,7 +2812,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
- + @@ -2754,7 +2820,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate } ) } -function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; labels: MailLabel[]; onChange: (patch: Partial) => void }) { +function RuleActionValue({ action, labels, verifiedEmails, onChange }: { action: MailRuleAction; labels: MailLabel[]; verifiedEmails: string[]; onChange: (patch: Partial) => void }) { if (action.type === "label") { if (labels.length > 0) { return ( @@ -2785,69 +2851,19 @@ function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; ) } if (action.type === "forward") { - return onChange({ value })} /> + return onChange({ value })} /> } return } -function RuleForwardTargets({ value, onChange }: { value: string; onChange: (value: string) => void }) { - const [rows, setRows] = React.useState(() => ruleForwardTargetRows(value)) - - React.useEffect(() => { - const next = ruleForwardTargetRows(value) - if (ruleForwardTargetsValue(next) !== ruleForwardTargetsValue(rows)) { - setRows(next) - } - }, [value]) - - function commit(next: string[]) { - const normalized = next.length > 0 ? next : [""] - setRows(normalized) - onChange(ruleForwardTargetsValue(normalized)) - } - - function updateRow(index: number, nextValue: string) { - const pasted = ruleForwardTargetRows(nextValue) - const next = [...rows] - if (pasted.length > 1) { - next.splice(index, 1, ...pasted) - } else { - next[index] = nextValue - } - commit(next) - } - - function addRow(index: number) { - const next = [...rows] - next.splice(index + 1, 0, "") - commit(next) - } - - function removeRow(index: number) { - const next = rows.filter((_, itemIndex) => itemIndex !== index) - commit(next.length > 0 ? next : [""]) - } - - return ( -
- {rows.map((email, index) => ( -
- updateRow(index, event.target.value)} placeholder={`目标邮箱 ${index + 1}`} /> - - -
- ))} -
- ) +function RuleForwardTargets({ value, emails, onChange }: { value: string; emails: string[]; onChange: (value: string) => void }) { + const selected = React.useMemo(() => verifiedRuleForwardTargets(value, emails), [emails, value]) + return onChange(targets.join(", "))} placement="top" /> } -function ruleForwardTargetRows(value: string) { - const rows = value.split(/[\n\r,,;;]+/).map((item) => item.trim()).filter(Boolean) - return rows.length > 0 ? rows : [""] -} - -function ruleForwardTargetsValue(rows: string[]) { - return rows.map((item) => item.trim()).filter(Boolean).join(", ") +function verifiedRuleForwardTargets(value: string, verifiedEmails: string[]) { + const verifiedByAddress = new Map(verifiedEmails.map((email) => [email.trim().toLowerCase(), email.trim()])) + return Array.from(new Set(value.split(/[\n\r,,;;]+/).map((item) => verifiedByAddress.get(item.trim().toLowerCase())).filter((item): item is string => !!item))) } function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) { @@ -2855,25 +2871,32 @@ function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; o return
onCheckedChange(value === true)} />
} -function RuleListItem({ item, mailboxes, onDelete }: { item: MailRule; mailboxes: Mailbox[]; onDelete: (id: string) => void }) { - const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱" +function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) { const [confirmOpen, setConfirmOpen] = React.useState(false) const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains) const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作" + const moveDirection = index === 0 ? "down" : "up" + const canMove = count > 1 return ( -
-
-
- {item.name} - {item.enabled ? "已启用" : "已停用"} +
+
+
+

{item.name}

+ {item.enabled ? "已启用" : "已停用"}
-
-

条件:{conditionText}

-

动作:{actionText}

+
+

条件: {conditionText}

+

动作: {actionText}

-
{mailbox} · {item.matchMode === "any" ? "任一条件" : "所有条件"}
- +
+ + + + + + +
{ onDelete(item.id); setConfirmOpen(false) }} />
) @@ -2928,7 +2951,7 @@ function ruleConditionItemSummary(item: MailRuleCondition): string { function ruleActionSummary(action: MailRuleAction) { if (action.type === "label") return `${ruleActionLabels[action.type]}${action.value ? `:"${action.value}"` : ""}` - if (action.type === "move") return `${ruleActionLabels[action.type]}:"${folderLabel(action.value || "Archive")}"` + if (action.type === "move") return `${ruleActionLabels[action.type]}"${folderLabel(action.value || "Archive")}"` if (action.type === "forward") return `${ruleActionLabels[action.type]}${action.value ? `:${action.value}` : ""}` return ruleActionLabels[action.type] } @@ -2979,14 +3002,14 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang ) } -function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) { +function StatsSection({ stats }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) { 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 = [ - { label: "总收件", value: stats?.totalIncoming || 0, icon: , tone: "bg-blue-50 text-blue-600" }, + { label: "总收件", value: stats?.totalIncoming || 0, icon: , tone: "bg-muted text-foreground" }, { label: "总发件", value: stats?.totalOutgoing || 0, icon: , tone: "bg-emerald-50 text-emerald-600" }, { label: "未读邮件", value: stats?.unreadMessages || 0, icon: , tone: "bg-amber-50 text-amber-600" }, - { label: "存储用量", value: quotaLabel, detail: stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限", icon: , tone: "bg-slate-100 text-slate-700" }, + { label: "存储用量", value: formatBytes(stats?.storageBytes || 0), subvalue: stats?.quotaBytes ? `/ ${formatBytes(stats.quotaBytes)} (${quotaPct.toFixed(0)}%)` : "不限", icon: , tone: "bg-violet-50 text-violet-600" }, ] const secondaryStats = [ { label: "今日发件", value: stats?.todayOutgoing || 0 }, @@ -2995,68 +3018,40 @@ function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: { { label: "平均邮件大小", value: formatBytes(stats?.averageMessageBytes || 0) }, ] return ( -
-
-
-

查看邮件收发趋势、分布情况和常用联系人。

- {mailbox &&

{mailbox.address}

} -
-
-
- {[ - [7, "7天"], - [30, "30天"], - [90, "90天"], - [365, "365天"], - ].map(([value, label]) => ( - - ))} -
- -
-
-
+
+
{primaryCards.map((card) => ( -
-
{card.icon}
+
+
{card.icon}
-
-
{card.label}
- {card.detail && {card.detail}} -
-
{card.value}
+
{card.label}
+
{card.value}
+ {"subvalue" in card &&
{card.subvalue}
}
))}
-
+
{secondaryStats.map((item) => ( -
-
{item.label}
+
+
{item.label}
{item.value}
))}
-
- +
+ - +
-
- +
+ - +
@@ -3064,13 +3059,13 @@ function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: { ) } -function StatsPanel({ title, children }: { title: string; children: React.ReactNode }) { +function StatsPanel({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) { return ( -
-
-

{title}

+
+
+

{title}

-
{children}
+
{children}
) } @@ -3088,21 +3083,21 @@ function StatsTrendChart({ points }: { points: MailStats["trend"] }) { const pathFor = (key: "incoming" | "outgoing") => data.map((item, index) => `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(1)} ${yFor(item[key]).toFixed(1)}`).join(" ") const ticks = trendTicks(data) return ( -
+
- 收件 + 收件 发件
- + {[0, 0.25, 0.5, 0.75, 1].map((step) => { const y = padding.top + plotHeight * step return })} - + {data.map((item, index) => ( - + ))} @@ -3147,7 +3142,7 @@ function StatsDistribution({ items }: { items: MailStats["distribution"] }) { ] const maxCount = Math.max(...rows.map((row) => row.count), 1) return ( -
+
{rows.map((row) => (
{row.label}
@@ -3172,7 +3167,7 @@ function distributionBarTone(key: string) { function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string; quotaPct: number; hasQuota: boolean }) { return ( -
+
{quotaLabel}
{hasQuota ? `${quotaPct.toFixed(0)}%` : "不限"}
@@ -3186,11 +3181,11 @@ function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string; } function StatsContacts({ contacts }: { contacts: MailStats["topContacts"] }) { - if (contacts.length === 0) return } text="暂无常用联系人" description="有邮件往来后会显示联系人排行" className="h-[136px] min-h-0 py-4" /> + if (contacts.length === 0) return } text="暂无常用联系人" description="有邮件往来后会显示联系人排行" className="h-[260px] min-h-0 py-4" /> return ( -
- {contacts.slice(0, 7).map((item, index) => ( -
+
+ {contacts.slice(0, 10).map((item, index) => ( +
{index + 1}
{item.email}
{item.count} 封
diff --git a/deploy/.env.example b/deploy/.env.example index 04d082a..c04f993 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -7,14 +7,18 @@ # ========================= # 单容器部署镜像。默认使用你的 GHCR 镜像。 # 本地源码构建请用:docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build -LANQIN_IMAGE=ghcr.io/lanqin996/lanqin-email:latest +LANQIN_IMAGE=ghcr.io/zxyszx/newszxcn-email:latest # 多容器调试版镜像。只在 docker-compose.stack.yml 中使用。 -LANQIN_API_IMAGE=ghcr.io/lanqin996/lanqin-email-api:latest -LANQIN_WEB_IMAGE=ghcr.io/lanqin996/lanqin-email-web:latest -LANQIN_POSTFIX_IMAGE=ghcr.io/lanqin996/lanqin-email-postfix:latest -LANQIN_DOVECOT_IMAGE=ghcr.io/lanqin996/lanqin-email-dovecot:latest -LANQIN_RSPAMD_IMAGE=ghcr.io/lanqin996/lanqin-email-rspamd:latest +LANQIN_API_IMAGE=ghcr.io/zxyszx/newszxcn-email-api:latest +LANQIN_WEB_IMAGE=ghcr.io/zxyszx/newszxcn-email-web:latest +LANQIN_POSTFIX_IMAGE=ghcr.io/zxyszx/newszxcn-email-postfix:latest +LANQIN_DOVECOT_IMAGE=ghcr.io/zxyszx/newszxcn-email-dovecot:latest +LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest + +# 后台“立即更新”使用的内部鉴权令牌。一键安装会自动生成,请勿对外公开。 +# 手动部署可执行:openssl rand -hex 24 +LANQIN_UPDATE_TOKEN= # ========================= # 对外访问地址 @@ -167,4 +171,3 @@ LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET= # ========================= # 容器时区。 TZ=Asia/Shanghai - diff --git a/deploy/README.md b/deploy/README.md index 0c7311c..136d150 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,4 +1,23 @@ -# LanQin Email Docker 部署说明 +# NewSzxcn Email Docker 部署说明 + +## 一键安装与更新 + +推荐直接使用仓库根目录的管理脚本: + +```bash +curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash +``` + +后续操作: + +```bash +sudo newszxcn-email update +sudo newszxcn-email status +sudo newszxcn-email logs +sudo newszxcn-email rollback +``` + +一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。 ## 最简单部署:单容器镜像版 @@ -55,12 +74,12 @@ docker compose down 默认镜像: ```text -ghcr.io/lanqin996/lanqin-email:latest -ghcr.io/lanqin996/lanqin-email-api:latest -ghcr.io/lanqin996/lanqin-email-web:latest -ghcr.io/lanqin996/lanqin-email-postfix:latest -ghcr.io/lanqin996/lanqin-email-dovecot:latest -ghcr.io/lanqin996/lanqin-email-rspamd:latest +ghcr.io/zxyszx/newszxcn-email:latest +ghcr.io/zxyszx/newszxcn-email-api:latest +ghcr.io/zxyszx/newszxcn-email-web:latest +ghcr.io/zxyszx/newszxcn-email-postfix:latest +ghcr.io/zxyszx/newszxcn-email-dovecot:latest +ghcr.io/zxyszx/newszxcn-email-rspamd:latest ``` 如果拉取时报: @@ -212,4 +231,3 @@ docker compose logs --tail=200 lanqin-email - 建议在服务器或边缘网关配置 HTTPS。 - 云厂商通常默认封禁 25 端口,需要单独申请解封。 - SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。 - diff --git a/deploy/all-in-one/Dockerfile b/deploy/all-in-one/Dockerfile index c996e89..d852e99 100644 --- a/deploy/all-in-one/Dockerfile +++ b/deploy/all-in-one/Dockerfile @@ -6,9 +6,13 @@ COPY apps/api/go.mod apps/api/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY apps/api ./ +ARG APP_VERSION="dev" +ARG APP_COMMIT="" RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/lanqin-api ./cmd/server + CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags "-s -w -X lanqin-email-api/internal/app.BuildVersion=${APP_VERSION} -X lanqin-email-api/internal/app.BuildCommit=${APP_COMMIT}" \ + -o /out/lanqin-api ./cmd/server FROM node:20-bookworm-slim AS web-build WORKDIR /src diff --git a/deploy/api.Dockerfile b/deploy/api.Dockerfile index d8c99a2..0209696 100644 --- a/deploy/api.Dockerfile +++ b/deploy/api.Dockerfile @@ -6,9 +6,13 @@ COPY apps/api/go.mod apps/api/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY apps/api ./ +ARG APP_VERSION="dev" +ARG APP_COMMIT="" RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/lanqin-api ./cmd/server + CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags "-s -w -X lanqin-email-api/internal/app.BuildVersion=${APP_VERSION} -X lanqin-email-api/internal/app.BuildCommit=${APP_COMMIT}" \ + -o /out/lanqin-api ./cmd/server FROM debian:bookworm-slim RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ diff --git a/deploy/docker-compose.build.yml b/deploy/docker-compose.build.yml index 0fbcd74..558ada9 100644 --- a/deploy/docker-compose.build.yml +++ b/deploy/docker-compose.build.yml @@ -3,3 +3,8 @@ services: build: context: .. dockerfile: deploy/all-in-one/Dockerfile + args: + APP_VERSION: ${LANQIN_BUILD_VERSION:-dev} + APP_COMMIT: ${LANQIN_BUILD_COMMIT:-local} + VITE_APP_VERSION: ${LANQIN_BUILD_VERSION:-dev} + VITE_RELEASE_URL: ${LANQIN_BUILD_RELEASE_URL:-} diff --git a/deploy/docker-compose.stack.build.yml b/deploy/docker-compose.stack.build.yml index f55a533..52ce4b6 100644 --- a/deploy/docker-compose.stack.build.yml +++ b/deploy/docker-compose.stack.build.yml @@ -3,11 +3,17 @@ services: build: context: .. dockerfile: deploy/api.Dockerfile + args: + APP_VERSION: ${LANQIN_BUILD_VERSION:-dev} + APP_COMMIT: ${LANQIN_BUILD_COMMIT:-local} web: build: context: .. dockerfile: deploy/web.Dockerfile + args: + VITE_APP_VERSION: ${LANQIN_BUILD_VERSION:-dev} + VITE_RELEASE_URL: ${LANQIN_BUILD_RELEASE_URL:-} postfix: build: diff --git a/deploy/docker-compose.stack.yml b/deploy/docker-compose.stack.yml index 5d85790..57d277f 100644 --- a/deploy/docker-compose.stack.yml +++ b/deploy/docker-compose.stack.yml @@ -1,6 +1,6 @@ services: api: - image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest} + image: ${LANQIN_API_IMAGE:-ghcr.io/zxyszx/newszxcn-email-api:latest} env_file: .env environment: LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix} @@ -23,7 +23,7 @@ services: restart: unless-stopped web: - image: ${LANQIN_WEB_IMAGE:-ghcr.io/lanqin996/lanqin-email-web:latest} + image: ${LANQIN_WEB_IMAGE:-ghcr.io/zxyszx/newszxcn-email-web:latest} restart: unless-stopped nginx: @@ -39,7 +39,7 @@ services: restart: unless-stopped postfix: - image: ${LANQIN_POSTFIX_IMAGE:-ghcr.io/lanqin996/lanqin-email-postfix:latest} + image: ${LANQIN_POSTFIX_IMAGE:-ghcr.io/zxyszx/newszxcn-email-postfix:latest} env_file: .env volumes: - ./data:/data @@ -54,7 +54,7 @@ services: restart: unless-stopped dovecot: - image: ${LANQIN_DOVECOT_IMAGE:-ghcr.io/lanqin996/lanqin-email-dovecot:latest} + image: ${LANQIN_DOVECOT_IMAGE:-ghcr.io/zxyszx/newszxcn-email-dovecot:latest} env_file: .env volumes: - ./data:/data @@ -69,7 +69,7 @@ services: restart: unless-stopped rspamd: - image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/lanqin996/lanqin-email-rspamd:latest} + image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/zxyszx/newszxcn-email-rspamd:latest} env_file: .env volumes: - ./data:/data:ro diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 231ccc7..7632b17 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,7 +1,10 @@ services: lanqin-email: - image: ${LANQIN_IMAGE:-ghcr.io/lanqin996/lanqin-email:latest} + image: ${LANQIN_IMAGE:-ghcr.io/zxyszx/newszxcn-email:latest} env_file: .env + environment: + LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update + LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-} ports: - "80:80" - "443:443" @@ -16,4 +19,20 @@ services: - ./dkim:/var/lib/rspamd/dkim # 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置: # - /etc/letsencrypt:/etc/letsencrypt:ro + labels: + com.centurylinklabs.watchtower.enable: "true" + com.centurylinklabs.watchtower.scope: "newszxcn-email" + restart: unless-stopped + + updater: + image: nickfedor/watchtower:1.20.2 + environment: + WATCHTOWER_HTTP_API_TOKEN: ${LANQIN_UPDATE_TOKEN:?请在 .env 设置 LANQIN_UPDATE_TOKEN} + command: + - --http-api-update + - --label-enable + - --scope + - newszxcn-email + volumes: + - /var/run/docker.sock:/var/run/docker.sock restart: unless-stopped diff --git a/deploy/install.sh b/deploy/install.sh old mode 100644 new mode 100755 index 8aa0d24..10089ce --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,34 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail +set -Eeuo pipefail -cd "$(dirname "$0")" - -if ! command -v docker >/dev/null 2>&1; then - echo "未检测到 docker,请先安装 Docker Engine / Docker Compose。" >&2 - exit 1 -fi - -if ! docker compose version >/dev/null 2>&1; then - echo "未检测到 docker compose,请先安装 Docker Compose v2。" >&2 - exit 1 -fi - -if [ ! -f .env ]; then - cp .env.example .env - echo "已生成 deploy/.env。请先编辑域名和管理员密码:" - echo " LANQIN_PUBLIC_HOSTNAME" - echo " LANQIN_PUBLIC_BASE_URL" - echo " LANQIN_ADMIN_EMAIL" - echo " LANQIN_ADMIN_PASSWORD" - echo - echo "编辑完成后再次执行:./install.sh" - exit 0 -fi - -echo "拉取镜像..." -docker compose pull - -echo "启动服务..." -docker compose up -d - -echo "完成。查看日志:docker compose logs -f lanqin-email" +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/install.sh" "$@" diff --git a/docs/screenshots/admin-dashboard.png b/docs/screenshots/admin-dashboard.png new file mode 100644 index 0000000..ef068df Binary files /dev/null and b/docs/screenshots/admin-dashboard.png differ diff --git a/docs/screenshots/mail-client-settings.png b/docs/screenshots/mail-client-settings.png new file mode 100644 index 0000000..7b31aaa Binary files /dev/null and b/docs/screenshots/mail-client-settings.png differ diff --git a/docs/screenshots/mail-rules.png b/docs/screenshots/mail-rules.png new file mode 100644 index 0000000..0cf29c0 Binary files /dev/null and b/docs/screenshots/mail-rules.png differ diff --git a/docs/screenshots/mailbox-management.png b/docs/screenshots/mailbox-management.png new file mode 100644 index 0000000..1d72542 Binary files /dev/null and b/docs/screenshots/mailbox-management.png differ diff --git a/docs/screenshots/mailbox-switcher.png b/docs/screenshots/mailbox-switcher.png new file mode 100644 index 0000000..cbc0d3a Binary files /dev/null and b/docs/screenshots/mailbox-switcher.png differ diff --git a/docs/screenshots/webmail.png b/docs/screenshots/webmail.png new file mode 100644 index 0000000..3324531 Binary files /dev/null and b/docs/screenshots/webmail.png differ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..50995d6 --- /dev/null +++ b/install.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +REPOSITORY="zxyszx/NewSzxcn-Email" +RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main" +INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}" +COMMAND="${1:-install}" +ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image" + +log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; } +success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[提示]\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m[错误]\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + cat <<'EOF' +NewSzxcn Email 管理命令 + +用法:newszxcn-email + + install 首次安装或修复部署 + update 备份数据库并更新到最新版 + status 查看容器与健康状态 + logs 持续查看运行日志 + rollback 回滚到上次命令行更新前的镜像 + uninstall 停止并移除容器,保留邮件与配置 +EOF +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + fail "请使用 root 运行,例如:curl -fsSL ${RAW_BASE}/install.sh | sudo bash" + fi +} + +require_curl() { + command -v curl >/dev/null 2>&1 || fail "系统缺少 curl,请先安装 curl。" +} + +ensure_docker() { + if ! command -v docker >/dev/null 2>&1; then + log "未检测到 Docker,正在安装 Docker Engine..." + curl -fsSL https://get.docker.com | sh + fi + if command -v systemctl >/dev/null 2>&1; then + systemctl enable --now docker >/dev/null 2>&1 || true + fi + docker compose version >/dev/null 2>&1 || fail "需要 Docker Compose v2。" +} + +compose() { + docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@" +} + +script_dir() { + cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd +} + +refresh_assets() { + local source_dir + source_dir="$(script_dir || true)" + install -d -m 0755 "${INSTALL_DIR}" + if [[ -f "${source_dir}/deploy/docker-compose.yml" && -f "${source_dir}/deploy/.env.example" ]]; then + install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" + install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example" + install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email + else + curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${INSTALL_DIR}/docker-compose.yml" + curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${INSTALL_DIR}/.env.example" + curl -fsSL "${RAW_BASE}/install.sh" -o /usr/local/bin/newszxcn-email.new + chmod 0755 /usr/local/bin/newszxcn-email.new + mv /usr/local/bin/newszxcn-email.new /usr/local/bin/newszxcn-email + fi +} + +random_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 24 + else + od -An -N24 -tx1 /dev/urandom | tr -d ' \n' + fi +} + +set_env() { + local key="$1" value="$2" file="${INSTALL_DIR}/.env" tmp + tmp="$(mktemp)" + awk -v key="${key}" -v value="${value}" ' + BEGIN { found=0 } + $0 ~ "^" key "=" { print key "=" value; found=1; next } + { print } + END { if (!found) print key "=" value } + ' "${file}" > "${tmp}" + cat "${tmp}" > "${file}" + rm -f "${tmp}" +} + +env_value() { + local key="$1" + sed -n "s/^${key}=//p" "${INSTALL_DIR}/.env" | tail -n 1 +} + +prompt_value() { + local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}" + local value="${!variable:-}" + if [[ -z "${value}" && -r /dev/tty ]]; then + if [[ "${secret}" == "true" ]]; then + read -r -s -p "${prompt}${default_value:+ [自动生成]}: " value /dev/tty + else + read -r -p "${prompt}${default_value:+ [${default_value}]}: " value /dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +backup_database() { + local timestamp + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + if [[ -n "$(compose ps -q lanqin-email 2>/dev/null || true)" ]]; then + compose exec -T lanqin-email sh -c "mkdir -p /data/backups && sqlite3 /data/lanqin.db \".backup '/data/backups/cli-update-${timestamp}.db'\"" >/dev/null + log "数据库已备份到 data/backups/cli-update-${timestamp}.db" + fi +} + +remember_current_image() { + local container_id image_id rollback_tag + container_id="$(compose ps -q lanqin-email 2>/dev/null || true)" + [[ -n "${container_id}" ]] || return 0 + image_id="$(docker inspect --format '{{.Image}}' "${container_id}")" + rollback_tag="newszxcn-email:rollback-$(date -u +%Y%m%d%H%M%S)" + docker image tag "${image_id}" "${rollback_tag}" + printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}" +} + +do_install() { + ensure_docker + refresh_assets + configure_first_install + ensure_update_token + prepare_directories + log "正在拉取 NewSzxcn Email 镜像..." + compose pull + log "正在启动服务..." + compose up -d --remove-orphans + wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。" + success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)" + warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。" +} + +do_update() { + [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。" + ensure_docker + refresh_assets + ensure_update_token + backup_database + remember_current_image + log "正在拉取最新版..." + compose pull + compose up -d --remove-orphans + if ! wait_for_health 90; then + warn "新版本健康检查失败,正在自动回滚。" + do_rollback + fail "更新失败,已回滚到原镜像。" + fi + success "系统已更新,配置、邮件和数据库均已保留。" +} + +do_rollback() { + [[ -f "${ROLLBACK_FILE}" ]] || fail "没有可用的回滚镜像。" + local image + image="$(tr -d '\r\n' < "${ROLLBACK_FILE}")" + docker image inspect "${image}" >/dev/null 2>&1 || fail "回滚镜像已不存在:${image}" + log "正在回滚到 ${image}..." + LANQIN_IMAGE="${image}" compose up -d --no-deps --force-recreate lanqin-email + wait_for_health 90 || fail "回滚后服务仍未通过健康检查,请查看日志。" + success "已回滚到 ${image}。" +} + +do_status() { + [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" + compose ps + if curl -fsS --max-time 3 http://127.0.0.1/healthz >/dev/null 2>&1; then + success "Web 与 API 健康检查正常。" + else + fail "健康检查失败。" + fi +} + +do_uninstall() { + [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" + compose down --remove-orphans + success "容器已移除,${INSTALL_DIR} 中的配置、邮件和数据库仍然保留。" +} + +require_root +require_curl +case "${COMMAND}" in + install) do_install ;; + update) do_update ;; + status) ensure_docker; do_status ;; + logs) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;; + rollback) ensure_docker; do_rollback ;; + uninstall) ensure_docker; do_uninstall ;; + help|-h|--help) usage ;; + *) usage; fail "未知命令:${COMMAND}" ;; +esac